Unify compose screen input accessory toolbars

This commit is contained in:
Shadowfacts 2022-04-09 11:41:27 -04:00
parent a718721537
commit abf6ff8115
6 changed files with 110 additions and 158 deletions

View File

@ -60,7 +60,7 @@ struct ComposeAutocompleteMentionsView: View {
HStack(spacing: 8) { HStack(spacing: 8) {
ForEach(accounts, id: \.id) { (account) in ForEach(accounts, id: \.id) { (account) in
Button { Button {
uiState.autocompleteHandler?.autocomplete(with: "@\(account.acct)") uiState.currentInput?.autocomplete(with: "@\(account.acct)")
} label: { } label: {
HStack(spacing: 4) { HStack(spacing: 4) {
ComposeAvatarImageView(url: account.avatar) ComposeAvatarImageView(url: account.avatar)
@ -242,7 +242,7 @@ struct ComposeAutocompleteEmojisView: View {
HStack(spacing: 8) { HStack(spacing: 8) {
ForEach(emojis, id: \.shortcode) { (emoji) in ForEach(emojis, id: \.shortcode) { (emoji) in
Button { Button {
uiState.autocompleteHandler?.autocomplete(with: ":\(emoji.shortcode):") uiState.currentInput?.autocomplete(with: ":\(emoji.shortcode):")
} label: { } label: {
HStack(spacing: 4) { HStack(spacing: 4) {
CustomEmojiImageView(emoji: emoji) CustomEmojiImageView(emoji: emoji)
@ -306,7 +306,7 @@ struct ComposeAutocompleteHashtagsView: View {
HStack(spacing: 8) { HStack(spacing: 8) {
ForEach(hashtags, id: \.name) { (hashtag) in ForEach(hashtags, id: \.name) { (hashtag) in
Button { Button {
uiState.autocompleteHandler?.autocomplete(with: "#\(hashtag.name)") uiState.currentInput?.autocomplete(with: "#\(hashtag.name)")
} label: { } label: {
Text(verbatim: "#\(hashtag.name)") Text(verbatim: "#\(hashtag.name)")
.foregroundColor(Color(UIColor.label)) .foregroundColor(Color(UIColor.label))

View File

@ -73,22 +73,27 @@ struct ComposeEmojiTextField: UIViewRepresentable {
return Coordinator() return Coordinator()
} }
class Coordinator: NSObject, UITextFieldDelegate, ComposeAutocompleteHandler { class Coordinator: NSObject, UITextFieldDelegate, ComposeInput {
weak var textField: UITextField? weak var textField: UITextField?
var text: Binding<String>! var text: Binding<String>!
var uiState: ComposeUIState! // break retained cycle through ComposeUIState.currentInput
unowned var uiState: ComposeUIState!
var didChange: ((String) -> Void)? var didChange: ((String) -> Void)?
var didEndEditing: (() -> Void)? var didEndEditing: (() -> Void)?
var skipSettingTextOnNextUpdate = false var skipSettingTextOnNextUpdate = false
var toolbarElements: [ComposeUIState.ToolbarElement] {
[]
}
@objc func didChange(_ textField: UITextField) { @objc func didChange(_ textField: UITextField) {
text.wrappedValue = textField.text ?? "" text.wrappedValue = textField.text ?? ""
didChange?(text.wrappedValue) didChange?(text.wrappedValue)
} }
func textFieldDidBeginEditing(_ textField: UITextField) { func textFieldDidBeginEditing(_ textField: UITextField) {
uiState.autocompleteHandler = self uiState.currentInput = self
updateAutocompleteState(textField: textField) updateAutocompleteState(textField: textField)
} }
@ -103,6 +108,9 @@ struct ComposeEmojiTextField: UIViewRepresentable {
self.updateAutocompleteState(textField: textField) self.updateAutocompleteState(textField: textField)
} }
func applyFormat(_ format: StatusFormat) {
}
func autocomplete(with string: String) { func autocomplete(with string: String) {
guard let textField = textField, guard let textField = textField,
let text = textField.text, let text = textField.text,

View File

@ -15,6 +15,9 @@ protocol ComposeHostingControllerDelegate: AnyObject {
func dismissCompose(mode: ComposeUIState.DismissMode) -> Bool 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> { class ComposeHostingController: UIHostingController<ComposeContainerView> {
weak var delegate: ComposeHostingControllerDelegate? weak var delegate: ComposeHostingControllerDelegate?
@ -31,8 +34,6 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
private var mainToolbar: UIToolbar! private var mainToolbar: UIToolbar!
private var inputAccessoryToolbar: UIToolbar! private var inputAccessoryToolbar: UIToolbar!
private var visibilityBarButtonItems = [UIBarButtonItem]()
private var localOnlyItems = [UIBarButtonItem]()
override var inputAccessoryView: UIView? { inputAccessoryToolbar } 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 // 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) // (except for MainComposeTextView which has its own accessory to add formatting buttons)
mainToolbar = createToolbar() mainToolbar = UIToolbar()
inputAccessoryToolbar = createToolbar() 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(keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil)
@ -87,6 +94,12 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
DraftsManager.save() DraftsManager.save()
} }
.store(in: &cancellables) .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) { required init?(coder aDecoder: NSCoder) {
@ -116,30 +129,57 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
DraftsManager.save() DraftsManager.save()
} }
private func createToolbar() -> UIToolbar { private func setupToolbarItems(toolbar: UIToolbar, input: ComposeInput?) {
let toolbar = UIToolbar() var items: [UIBarButtonItem] = []
toolbar.translatesAutoresizingMaskIntoConstraints = false
toolbar.isAccessibilityElement = true items.append(UIBarButtonItem(title: "CW", style: .plain, target: self, action: #selector(cwButtonPressed)))
let visibilityItem = UIBarButtonItem(image: nil, style: .plain, target: nil, action: nil) let visibilityItem = UIBarButtonItem(image: nil, style: .plain, target: nil, action: nil)
visibilityBarButtonItems.append(visibilityItem) visibilityItem.tag = VISIBILITY_BAR_BUTTON_TAG
visibilityChanged(draft.visibility) items.append(visibilityItem)
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))
]
if mastodonController.instanceFeatures.localOnlyPosts { if mastodonController.instanceFeatures.localOnlyPosts {
let item = UIBarButtonItem(image: nil, style: .plain, target: nil, action: nil) let item = UIBarButtonItem(image: nil, style: .plain, target: nil, action: nil)
toolbar.items!.insert(item, at: 2) item.tag = LOCAL_ONLY_BAR_BUTTON_TAG
localOnlyItems.append(item) items.append(item)
localOnlyChanged(draft.localOnly) 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() { private func updateAdditionalSafeAreaInsets() {
@ -198,7 +238,10 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
} }
private func visibilityChanged(_ newVisibility: Status.Visibility) { 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.image = UIImage(systemName: newVisibility.imageName)
item.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), draft.visibility.displayName) item.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), draft.visibility.displayName)
let elements = Status.Visibility.allCases.map { (visibility) -> UIMenuElement in let elements = Status.Visibility.allCases.map { (visibility) -> UIMenuElement in
@ -212,7 +255,10 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
} }
private func localOnlyChanged(_ localOnly: Bool) { 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 { if localOnly {
item.image = UIImage(named: "link.broken") item.image = UIImage(named: "link.broken")
item.accessibilityLabel = "Local-only" item.accessibilityLabel = "Local-only"
@ -260,6 +306,11 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
draft.contentWarningEnabled = !draft.contentWarningEnabled draft.contentWarningEnabled = !draft.contentWarningEnabled
} }
@objc func formatButtonPressed(_ sender: UIBarButtonItem) {
let format = StatusFormat.allCases[sender.tag]
uiState.currentInput?.applyFormat(format)
}
@objc func draftsButtonPresed() { @objc func draftsButtonPresed() {
let draftsVC = DraftsTableViewController(account: mastodonController.accountInfo!, exclude: draft) let draftsVC = DraftsTableViewController(account: mastodonController.accountInfo!, exclude: draft)
draftsVC.delegate = self draftsVC.delegate = self

View File

@ -31,7 +31,7 @@ class ComposeUIState: ObservableObject {
var composeDrawingMode: ComposeDrawingMode? var composeDrawingMode: ComposeDrawingMode?
weak var autocompleteHandler: ComposeAutocompleteHandler? @Published var currentInput: ComposeInput?
init(draft: Draft) { init(draft: Draft) {
self.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 autocomplete(with string: String)
func applyFormat(_ format: StatusFormat)
}
extension ComposeUIState {
enum ToolbarElement {
case formattingButtons
}
} }

View File

@ -39,7 +39,7 @@ struct EmojiPickerWrapper: UIViewControllerRepresentable {
} }
func selectedEmoji(_ emoji: Emoji) { func selectedEmoji(_ emoji: Emoji) {
uiState.autocompleteHandler?.autocomplete(with: ":\(emoji.shortcode):") uiState.currentInput?.autocomplete(with: ":\(emoji.shortcode):")
uiState.autocompleteState = nil uiState.autocompleteState = nil
} }
} }

View File

@ -57,9 +57,6 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
@EnvironmentObject var uiState: ComposeUIState @EnvironmentObject var uiState: ComposeUIState
@EnvironmentObject var mastodonController: MastodonController @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 { func makeUIView(context: Context) -> UITextView {
let textView = WrappedTextView() let textView = WrappedTextView()
@ -69,101 +66,9 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
textView.font = .systemFont(ofSize: 20) textView.font = .systemFont(ofSize: 20)
textView.textContainer.lineBreakMode = .byWordWrapping textView.textContainer.lineBreakMode = .byWordWrapping
context.coordinator.textView = textView 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 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) { func updateUIView(_ uiView: UITextView, context: Context) {
if context.coordinator.skipSettingTextOnNextUpdate { if context.coordinator.skipSettingTextOnNextUpdate {
context.coordinator.skipSettingTextOnNextUpdate = false context.coordinator.skipSettingTextOnNextUpdate = false
@ -171,18 +76,6 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
uiView.text = text 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.text = $text
context.coordinator.didChange = textDidChange context.coordinator.didChange = textDidChange
context.coordinator.uiState = uiState context.coordinator.uiState = uiState
@ -232,17 +125,23 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
command.attributes.remove(.disabled) command.attributes.remove(.disabled)
} }
} }
} }
class Coordinator: NSObject, UITextViewDelegate, ComposeAutocompleteHandler, ComposeTextViewCaretScrolling { class Coordinator: NSObject, UITextViewDelegate, ComposeInput, ComposeTextViewCaretScrolling {
weak var textView: UITextView? weak var textView: UITextView?
var text: Binding<String> var text: Binding<String>
var didChange: (UITextView) -> Void var didChange: (UITextView) -> Void
var uiState: ComposeUIState // break retained cycle through ComposeUIState.currentInput
unowned var uiState: ComposeUIState
var caretScrollPositionAnimator: UIViewPropertyAnimator? var caretScrollPositionAnimator: UIViewPropertyAnimator?
var skipSettingTextOnNextUpdate = false var skipSettingTextOnNextUpdate = false
var toolbarElements: [ComposeUIState.ToolbarElement] {
[.formattingButtons]
}
init(text: Binding<String>, uiState: ComposeUIState, didChange: @escaping (UITextView) -> Void) { init(text: Binding<String>, uiState: ComposeUIState, didChange: @escaping (UITextView) -> Void) {
self.text = text self.text = text
self.didChange = didChange self.didChange = didChange
@ -256,11 +155,6 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
ensureCursorVisible(textView: textView) ensureCursorVisible(textView: textView)
} }
@objc func formatButtonPressed(_ sender: UIBarButtonItem) {
let format = StatusFormat.allCases[sender.tag]
applyFormat(format)
}
func applyFormat(_ format: StatusFormat) { func applyFormat(_ format: StatusFormat) {
guard let textView = textView, guard let textView = textView,
textView.isFirstResponder, 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) { func textViewDidBeginEditing(_ textView: UITextView) {
uiState.autocompleteHandler = self uiState.currentInput = self
updateAutocompleteState() updateAutocompleteState()
} }
func textViewDidEndEditing(_ textView: UITextView) { func textViewDidEndEditing(_ textView: UITextView) {
uiState.currentInput = nil
updateAutocompleteState() updateAutocompleteState()
} }