forked from shadowfacts/Tusker
Unify compose screen input accessory toolbars
This commit is contained in:
parent
a718721537
commit
abf6ff8115
|
@ -60,7 +60,7 @@ struct ComposeAutocompleteMentionsView: View {
|
|||
HStack(spacing: 8) {
|
||||
ForEach(accounts, id: \.id) { (account) in
|
||||
Button {
|
||||
uiState.autocompleteHandler?.autocomplete(with: "@\(account.acct)")
|
||||
uiState.currentInput?.autocomplete(with: "@\(account.acct)")
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
ComposeAvatarImageView(url: account.avatar)
|
||||
|
@ -242,7 +242,7 @@ struct ComposeAutocompleteEmojisView: View {
|
|||
HStack(spacing: 8) {
|
||||
ForEach(emojis, id: \.shortcode) { (emoji) in
|
||||
Button {
|
||||
uiState.autocompleteHandler?.autocomplete(with: ":\(emoji.shortcode):")
|
||||
uiState.currentInput?.autocomplete(with: ":\(emoji.shortcode):")
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
CustomEmojiImageView(emoji: emoji)
|
||||
|
@ -306,7 +306,7 @@ struct ComposeAutocompleteHashtagsView: View {
|
|||
HStack(spacing: 8) {
|
||||
ForEach(hashtags, id: \.name) { (hashtag) in
|
||||
Button {
|
||||
uiState.autocompleteHandler?.autocomplete(with: "#\(hashtag.name)")
|
||||
uiState.currentInput?.autocomplete(with: "#\(hashtag.name)")
|
||||
} label: {
|
||||
Text(verbatim: "#\(hashtag.name)")
|
||||
.foregroundColor(Color(UIColor.label))
|
||||
|
|
|
@ -73,22 +73,27 @@ struct ComposeEmojiTextField: UIViewRepresentable {
|
|||
return Coordinator()
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, UITextFieldDelegate, ComposeAutocompleteHandler {
|
||||
class Coordinator: NSObject, UITextFieldDelegate, ComposeInput {
|
||||
weak var textField: UITextField?
|
||||
var text: Binding<String>!
|
||||
var uiState: ComposeUIState!
|
||||
// break retained cycle through ComposeUIState.currentInput
|
||||
unowned var uiState: ComposeUIState!
|
||||
var didChange: ((String) -> Void)?
|
||||
var didEndEditing: (() -> Void)?
|
||||
|
||||
var skipSettingTextOnNextUpdate = false
|
||||
|
||||
var toolbarElements: [ComposeUIState.ToolbarElement] {
|
||||
[]
|
||||
}
|
||||
|
||||
@objc func didChange(_ textField: UITextField) {
|
||||
text.wrappedValue = textField.text ?? ""
|
||||
didChange?(text.wrappedValue)
|
||||
}
|
||||
|
||||
func textFieldDidBeginEditing(_ textField: UITextField) {
|
||||
uiState.autocompleteHandler = self
|
||||
uiState.currentInput = self
|
||||
updateAutocompleteState(textField: textField)
|
||||
}
|
||||
|
||||
|
@ -103,6 +108,9 @@ struct ComposeEmojiTextField: UIViewRepresentable {
|
|||
self.updateAutocompleteState(textField: textField)
|
||||
}
|
||||
|
||||
func applyFormat(_ format: StatusFormat) {
|
||||
}
|
||||
|
||||
func autocomplete(with string: String) {
|
||||
guard let textField = textField,
|
||||
let text = textField.text,
|
||||
|
|
|
@ -15,6 +15,9 @@ protocol ComposeHostingControllerDelegate: AnyObject {
|
|||
func dismissCompose(mode: ComposeUIState.DismissMode) -> Bool
|
||||
}
|
||||
|
||||
private let VISIBILITY_BAR_BUTTON_TAG = 42001
|
||||
private let LOCAL_ONLY_BAR_BUTTON_TAG = 42002
|
||||
|
||||
class ComposeHostingController: UIHostingController<ComposeContainerView> {
|
||||
|
||||
weak var delegate: ComposeHostingControllerDelegate?
|
||||
|
@ -31,8 +34,6 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
|
|||
|
||||
private var mainToolbar: UIToolbar!
|
||||
private var inputAccessoryToolbar: UIToolbar!
|
||||
private var visibilityBarButtonItems = [UIBarButtonItem]()
|
||||
private var localOnlyItems = [UIBarButtonItem]()
|
||||
|
||||
override var inputAccessoryView: UIView? { inputAccessoryToolbar }
|
||||
|
||||
|
@ -56,8 +57,14 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
|
|||
|
||||
// main toolbar is shown at the bottom of the screen, the input accessory is attached to the keyboard while editing
|
||||
// (except for MainComposeTextView which has its own accessory to add formatting buttons)
|
||||
mainToolbar = createToolbar()
|
||||
inputAccessoryToolbar = createToolbar()
|
||||
mainToolbar = UIToolbar()
|
||||
mainToolbar.translatesAutoresizingMaskIntoConstraints = false
|
||||
mainToolbar.isAccessibilityElement = true
|
||||
setupToolbarItems(toolbar: mainToolbar, input: nil)
|
||||
inputAccessoryToolbar = UIToolbar()
|
||||
inputAccessoryToolbar.translatesAutoresizingMaskIntoConstraints = false
|
||||
inputAccessoryToolbar.isAccessibilityElement = true
|
||||
setupToolbarItems(toolbar: inputAccessoryToolbar, input: nil)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil)
|
||||
|
@ -87,6 +94,12 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
|
|||
DraftsManager.save()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
self.uiState.$currentInput
|
||||
.sink { [unowned self] in
|
||||
self.setupToolbarItems(toolbar: self.inputAccessoryToolbar, input: $0)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
|
@ -116,30 +129,57 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
|
|||
DraftsManager.save()
|
||||
}
|
||||
|
||||
private func createToolbar() -> UIToolbar {
|
||||
let toolbar = UIToolbar()
|
||||
toolbar.translatesAutoresizingMaskIntoConstraints = false
|
||||
toolbar.isAccessibilityElement = true
|
||||
private func setupToolbarItems(toolbar: UIToolbar, input: ComposeInput?) {
|
||||
var items: [UIBarButtonItem] = []
|
||||
|
||||
items.append(UIBarButtonItem(title: "CW", style: .plain, target: self, action: #selector(cwButtonPressed)))
|
||||
|
||||
let visibilityItem = UIBarButtonItem(image: nil, style: .plain, target: nil, action: nil)
|
||||
visibilityBarButtonItems.append(visibilityItem)
|
||||
visibilityChanged(draft.visibility)
|
||||
|
||||
toolbar.items = [
|
||||
UIBarButtonItem(title: "CW", style: .plain, target: self, action: #selector(cwButtonPressed)),
|
||||
visibilityItem,
|
||||
UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil),
|
||||
UIBarButtonItem(title: "Drafts", style: .plain, target: self, action: #selector(draftsButtonPresed))
|
||||
]
|
||||
visibilityItem.tag = VISIBILITY_BAR_BUTTON_TAG
|
||||
items.append(visibilityItem)
|
||||
|
||||
if mastodonController.instanceFeatures.localOnlyPosts {
|
||||
let item = UIBarButtonItem(image: nil, style: .plain, target: nil, action: nil)
|
||||
toolbar.items!.insert(item, at: 2)
|
||||
localOnlyItems.append(item)
|
||||
item.tag = LOCAL_ONLY_BAR_BUTTON_TAG
|
||||
items.append(item)
|
||||
localOnlyChanged(draft.localOnly)
|
||||
}
|
||||
|
||||
return toolbar
|
||||
items.append(UIBarButtonItem(systemItem: .flexibleSpace))
|
||||
|
||||
if input?.toolbarElements.contains(.formattingButtons) == true,
|
||||
Preferences.shared.statusContentType != .plain {
|
||||
|
||||
for (idx, format) in StatusFormat.allCases.enumerated() {
|
||||
let item: UIBarButtonItem
|
||||
if let image = format.image {
|
||||
item = UIBarButtonItem(image: image, style: .plain, target: self, action: #selector(formatButtonPressed(_:)))
|
||||
} else if let (str, attributes) = format.title {
|
||||
item = UIBarButtonItem(title: str, style: .plain, target: self, action: #selector(formatButtonPressed(_:)))
|
||||
item.setTitleTextAttributes(attributes, for: .normal)
|
||||
item.setTitleTextAttributes(attributes, for: .highlighted)
|
||||
} else {
|
||||
fatalError("StatusFormat must have either image or title")
|
||||
}
|
||||
item.tag = StatusFormat.allCases.firstIndex(of: format)!
|
||||
item.accessibilityLabel = format.accessibilityLabel
|
||||
|
||||
items.append(item)
|
||||
if idx != StatusFormat.allCases.count - 1 {
|
||||
let spacer = UIBarButtonItem(systemItem: .fixedSpace)
|
||||
spacer.width = 8
|
||||
items.append(spacer)
|
||||
}
|
||||
}
|
||||
|
||||
items.append(UIBarButtonItem(systemItem: .flexibleSpace))
|
||||
}
|
||||
|
||||
items.append(UIBarButtonItem(title: "Drafts", style: .plain, target: self, action: #selector(draftsButtonPresed)))
|
||||
|
||||
toolbar.items = items
|
||||
visibilityChanged(draft.visibility)
|
||||
localOnlyChanged(draft.localOnly)
|
||||
}
|
||||
|
||||
private func updateAdditionalSafeAreaInsets() {
|
||||
|
@ -198,7 +238,10 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
|
|||
}
|
||||
|
||||
private func visibilityChanged(_ newVisibility: Status.Visibility) {
|
||||
for item in visibilityBarButtonItems {
|
||||
for toolbar in [mainToolbar, inputAccessoryToolbar] {
|
||||
guard let item = toolbar?.items?.first(where: { $0.tag == VISIBILITY_BAR_BUTTON_TAG }) else {
|
||||
continue
|
||||
}
|
||||
item.image = UIImage(systemName: newVisibility.imageName)
|
||||
item.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), draft.visibility.displayName)
|
||||
let elements = Status.Visibility.allCases.map { (visibility) -> UIMenuElement in
|
||||
|
@ -212,7 +255,10 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
|
|||
}
|
||||
|
||||
private func localOnlyChanged(_ localOnly: Bool) {
|
||||
for item in localOnlyItems {
|
||||
for toolbar in [mainToolbar, inputAccessoryToolbar] {
|
||||
guard let item = toolbar?.items?.first(where: { $0.tag == LOCAL_ONLY_BAR_BUTTON_TAG }) else {
|
||||
continue
|
||||
}
|
||||
if localOnly {
|
||||
item.image = UIImage(named: "link.broken")
|
||||
item.accessibilityLabel = "Local-only"
|
||||
|
@ -260,6 +306,11 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
|
|||
draft.contentWarningEnabled = !draft.contentWarningEnabled
|
||||
}
|
||||
|
||||
@objc func formatButtonPressed(_ sender: UIBarButtonItem) {
|
||||
let format = StatusFormat.allCases[sender.tag]
|
||||
uiState.currentInput?.applyFormat(format)
|
||||
}
|
||||
|
||||
@objc func draftsButtonPresed() {
|
||||
let draftsVC = DraftsTableViewController(account: mastodonController.accountInfo!, exclude: draft)
|
||||
draftsVC.delegate = self
|
||||
|
|
|
@ -31,7 +31,7 @@ class ComposeUIState: ObservableObject {
|
|||
|
||||
var composeDrawingMode: ComposeDrawingMode?
|
||||
|
||||
weak var autocompleteHandler: ComposeAutocompleteHandler?
|
||||
@Published var currentInput: ComposeInput?
|
||||
|
||||
init(draft: Draft) {
|
||||
self.draft = draft
|
||||
|
@ -60,6 +60,16 @@ extension ComposeUIState {
|
|||
}
|
||||
}
|
||||
|
||||
protocol ComposeAutocompleteHandler: AnyObject {
|
||||
protocol ComposeInput: AnyObject {
|
||||
var toolbarElements: [ComposeUIState.ToolbarElement] { get }
|
||||
|
||||
func autocomplete(with string: String)
|
||||
|
||||
func applyFormat(_ format: StatusFormat)
|
||||
}
|
||||
|
||||
extension ComposeUIState {
|
||||
enum ToolbarElement {
|
||||
case formattingButtons
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,7 +39,7 @@ struct EmojiPickerWrapper: UIViewControllerRepresentable {
|
|||
}
|
||||
|
||||
func selectedEmoji(_ emoji: Emoji) {
|
||||
uiState.autocompleteHandler?.autocomplete(with: ":\(emoji.shortcode):")
|
||||
uiState.currentInput?.autocomplete(with: ":\(emoji.shortcode):")
|
||||
uiState.autocompleteState = nil
|
||||
}
|
||||
}
|
||||
|
|
|
@ -57,9 +57,6 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
|||
|
||||
@EnvironmentObject var uiState: ComposeUIState
|
||||
@EnvironmentObject var mastodonController: MastodonController
|
||||
// todo: should these be part of the coordinator?
|
||||
@State var visibilityButton: UIBarButtonItem?
|
||||
@State var localOnlyButton: UIBarButtonItem?
|
||||
|
||||
func makeUIView(context: Context) -> UITextView {
|
||||
let textView = WrappedTextView()
|
||||
|
@ -69,101 +66,9 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
|||
textView.font = .systemFont(ofSize: 20)
|
||||
textView.textContainer.lineBreakMode = .byWordWrapping
|
||||
context.coordinator.textView = textView
|
||||
|
||||
uiState.autocompleteHandler = context.coordinator
|
||||
|
||||
let visibilityButton = UIBarButtonItem(image: UIImage(systemName: visibility.imageName), style: .plain, target: nil, action: nil)
|
||||
updateVisibilityMenu(visibilityButton)
|
||||
let toolbar = UIToolbar()
|
||||
toolbar.translatesAutoresizingMaskIntoConstraints = false
|
||||
toolbar.items = [
|
||||
UIBarButtonItem(title: "CW", style: .plain, target: nil, action: #selector(ComposeHostingController.cwButtonPressed)),
|
||||
visibilityButton,
|
||||
UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil),
|
||||
] + createFormattingButtons(coordinator: context.coordinator) + [
|
||||
UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil),
|
||||
UIBarButtonItem(title: "Drafts", style: .plain, target: nil, action: #selector(ComposeHostingController.draftsButtonPresed)),
|
||||
]
|
||||
textView.inputAccessoryView = toolbar
|
||||
// can't modify @State during view update
|
||||
DispatchQueue.main.async {
|
||||
self.visibilityButton = visibilityButton
|
||||
}
|
||||
|
||||
if mastodonController.instanceFeatures.localOnlyPosts {
|
||||
let image: UIImage
|
||||
if uiState.draft.localOnly {
|
||||
image = UIImage(named: "link.broken")!
|
||||
} else {
|
||||
image = UIImage(systemName: "link")!
|
||||
}
|
||||
let item = UIBarButtonItem(image: image, style: .plain, target: nil, action: nil)
|
||||
toolbar.items!.insert(item, at: 2)
|
||||
updateLocalOnlyMenu(item)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.localOnlyButton = item
|
||||
}
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(context.coordinator, selector: #selector(Coordinator.keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(context.coordinator, selector: #selector(Coordinator.keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(context.coordinator, selector: #selector(Coordinator.keyboardDidHide(_:)), name: UIResponder.keyboardDidHideNotification, object: nil)
|
||||
|
||||
return textView
|
||||
}
|
||||
|
||||
private func createFormattingButtons(coordinator: Coordinator) -> [UIBarButtonItem] {
|
||||
guard Preferences.shared.statusContentType != .plain else {
|
||||
return []
|
||||
}
|
||||
|
||||
var formatButtons = StatusFormat.allCases.map { (format) -> UIBarButtonItem in
|
||||
let item: UIBarButtonItem
|
||||
if let image = format.image {
|
||||
item = UIBarButtonItem(image: image, style: .plain, target: coordinator, action: #selector(Coordinator.formatButtonPressed(_:)))
|
||||
} else if let (str, attributes) = format.title {
|
||||
item = UIBarButtonItem(title: str, style: .plain, target: coordinator, action: #selector(Coordinator.formatButtonPressed(_:)))
|
||||
item.setTitleTextAttributes(attributes, for: .normal)
|
||||
item.setTitleTextAttributes(attributes, for: .highlighted)
|
||||
} else {
|
||||
fatalError("StatusFormat must have either an image or a title")
|
||||
}
|
||||
item.tag = StatusFormat.allCases.firstIndex(of: format)!
|
||||
item.accessibilityLabel = format.accessibilityLabel
|
||||
return item
|
||||
}
|
||||
|
||||
for i in (1..<StatusFormat.allCases.count).reversed() {
|
||||
let spacer = UIBarButtonItem(barButtonSystemItem: .fixedSpace, target: nil, action: nil)
|
||||
spacer.width = 8
|
||||
formatButtons.insert(spacer, at: i)
|
||||
}
|
||||
|
||||
return formatButtons
|
||||
}
|
||||
|
||||
private func updateVisibilityMenu(_ visibilityButton: UIBarButtonItem) {
|
||||
let elements = Status.Visibility.allCases.map { (visibility) -> UIMenuElement in
|
||||
let state = visibility == self.visibility ? UIMenuElement.State.on : .off
|
||||
return UIAction(title: visibility.displayName, image: UIImage(systemName: visibility.unfilledImageName), identifier: nil, discoverabilityTitle: nil, attributes: [], state: state) { (_) in
|
||||
self.uiState.draft.visibility = visibility
|
||||
}
|
||||
}
|
||||
visibilityButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: elements)
|
||||
}
|
||||
|
||||
private func updateLocalOnlyMenu(_ localOnlyButton: UIBarButtonItem) {
|
||||
localOnlyButton.menu = UIMenu(children: [
|
||||
UIAction(title: "Local-only", image: UIImage(named: "link.broken"), state: uiState.draft.localOnly ? .on : .off) { (_) in
|
||||
self.uiState.draft.localOnly = true
|
||||
},
|
||||
UIAction(title: "Federated", image: UIImage(systemName: "link"), state: uiState.draft.localOnly ? .off : .on) { (_) in
|
||||
self.uiState.draft.localOnly = false
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UITextView, context: Context) {
|
||||
if context.coordinator.skipSettingTextOnNextUpdate {
|
||||
context.coordinator.skipSettingTextOnNextUpdate = false
|
||||
|
@ -171,18 +76,6 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
|||
uiView.text = text
|
||||
}
|
||||
|
||||
if let visibilityButton = visibilityButton {
|
||||
visibilityButton.image = UIImage(systemName: visibility.imageName)
|
||||
updateVisibilityMenu(visibilityButton)
|
||||
}
|
||||
if let localOnlyButton = localOnlyButton {
|
||||
if uiState.draft.localOnly {
|
||||
localOnlyButton.image = UIImage(named: "link.broken")
|
||||
} else {
|
||||
localOnlyButton.image = UIImage(systemName: "link")
|
||||
}
|
||||
updateLocalOnlyMenu(localOnlyButton)
|
||||
}
|
||||
context.coordinator.text = $text
|
||||
context.coordinator.didChange = textDidChange
|
||||
context.coordinator.uiState = uiState
|
||||
|
@ -232,17 +125,23 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
|||
command.attributes.remove(.disabled)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, UITextViewDelegate, ComposeAutocompleteHandler, ComposeTextViewCaretScrolling {
|
||||
class Coordinator: NSObject, UITextViewDelegate, ComposeInput, ComposeTextViewCaretScrolling {
|
||||
weak var textView: UITextView?
|
||||
var text: Binding<String>
|
||||
var didChange: (UITextView) -> Void
|
||||
var uiState: ComposeUIState
|
||||
// break retained cycle through ComposeUIState.currentInput
|
||||
unowned var uiState: ComposeUIState
|
||||
var caretScrollPositionAnimator: UIViewPropertyAnimator?
|
||||
|
||||
var skipSettingTextOnNextUpdate = false
|
||||
|
||||
var toolbarElements: [ComposeUIState.ToolbarElement] {
|
||||
[.formattingButtons]
|
||||
}
|
||||
|
||||
init(text: Binding<String>, uiState: ComposeUIState, didChange: @escaping (UITextView) -> Void) {
|
||||
self.text = text
|
||||
self.didChange = didChange
|
||||
|
@ -256,11 +155,6 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
|||
ensureCursorVisible(textView: textView)
|
||||
}
|
||||
|
||||
@objc func formatButtonPressed(_ sender: UIBarButtonItem) {
|
||||
let format = StatusFormat.allCases[sender.tag]
|
||||
applyFormat(format)
|
||||
}
|
||||
|
||||
func applyFormat(_ format: StatusFormat) {
|
||||
guard let textView = textView,
|
||||
textView.isFirstResponder,
|
||||
|
@ -281,24 +175,13 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
|||
}
|
||||
}
|
||||
|
||||
@objc func keyboardWillShow(_ notification: Foundation.Notification) {
|
||||
uiState.delegate?.keyboardWillShow(accessoryView: textView!.inputAccessoryView!, notification: notification)
|
||||
}
|
||||
|
||||
@objc func keyboardWillHide(_ notification: Foundation.Notification) {
|
||||
uiState.delegate?.keyboardWillHide(accessoryView: textView!.inputAccessoryView!, notification: notification)
|
||||
}
|
||||
|
||||
@objc func keyboardDidHide(_ notification: Foundation.Notification) {
|
||||
uiState.delegate?.keyboardDidHide(accessoryView: textView!.inputAccessoryView!, notification: notification)
|
||||
}
|
||||
|
||||
func textViewDidBeginEditing(_ textView: UITextView) {
|
||||
uiState.autocompleteHandler = self
|
||||
uiState.currentInput = self
|
||||
updateAutocompleteState()
|
||||
}
|
||||
|
||||
func textViewDidEndEditing(_ textView: UITextView) {
|
||||
uiState.currentInput = nil
|
||||
updateAutocompleteState()
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue