From abf6ff8115697d8c941720aaa20cafe8b8791eee Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 9 Apr 2022 11:41:27 -0400 Subject: [PATCH] Unify compose screen input accessory toolbars --- .../Compose/ComposeAutocompleteView.swift | 6 +- .../Compose/ComposeEmojiTextField.swift | 14 +- .../Compose/ComposeHostingController.swift | 95 +++++++++--- Tusker/Screens/Compose/ComposeUIState.swift | 14 +- .../Screens/Compose/EmojiPickerWrapper.swift | 2 +- .../Screens/Compose/MainComposeTextView.swift | 137 ++---------------- 6 files changed, 110 insertions(+), 158 deletions(-) diff --git a/Tusker/Screens/Compose/ComposeAutocompleteView.swift b/Tusker/Screens/Compose/ComposeAutocompleteView.swift index 641a9002..07672a10 100644 --- a/Tusker/Screens/Compose/ComposeAutocompleteView.swift +++ b/Tusker/Screens/Compose/ComposeAutocompleteView.swift @@ -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)) diff --git a/Tusker/Screens/Compose/ComposeEmojiTextField.swift b/Tusker/Screens/Compose/ComposeEmojiTextField.swift index 0c2947c1..0517ea09 100644 --- a/Tusker/Screens/Compose/ComposeEmojiTextField.swift +++ b/Tusker/Screens/Compose/ComposeEmojiTextField.swift @@ -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! - 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, diff --git a/Tusker/Screens/Compose/ComposeHostingController.swift b/Tusker/Screens/Compose/ComposeHostingController.swift index f73492e7..e4f8d68e 100644 --- a/Tusker/Screens/Compose/ComposeHostingController.swift +++ b/Tusker/Screens/Compose/ComposeHostingController.swift @@ -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 { weak var delegate: ComposeHostingControllerDelegate? @@ -31,8 +34,6 @@ class ComposeHostingController: UIHostingController { 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 { // 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 { 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 { 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 { } 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 { } 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 { 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 diff --git a/Tusker/Screens/Compose/ComposeUIState.swift b/Tusker/Screens/Compose/ComposeUIState.swift index 279fb6fd..9e625324 100644 --- a/Tusker/Screens/Compose/ComposeUIState.swift +++ b/Tusker/Screens/Compose/ComposeUIState.swift @@ -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 + } } diff --git a/Tusker/Screens/Compose/EmojiPickerWrapper.swift b/Tusker/Screens/Compose/EmojiPickerWrapper.swift index cb81e54e..5d1a9c08 100644 --- a/Tusker/Screens/Compose/EmojiPickerWrapper.swift +++ b/Tusker/Screens/Compose/EmojiPickerWrapper.swift @@ -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 } } diff --git a/Tusker/Screens/Compose/MainComposeTextView.swift b/Tusker/Screens/Compose/MainComposeTextView.swift index a7271030..3795a6b6 100644 --- a/Tusker/Screens/Compose/MainComposeTextView.swift +++ b/Tusker/Screens/Compose/MainComposeTextView.swift @@ -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.. 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 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, 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() }