From 6d2830cf783ce14a84f58ecee516863537a27c6c Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 12 Nov 2022 22:48:02 -0500 Subject: [PATCH] Rewrite Compose toolbar with SwiftUI Fixes buttons not being accessible with VoiceOver Fixes content overflowing on small devices Closes #232 Closes #218 --- Tusker.xcodeproj/project.pbxproj | 4 + Tusker/Models/StatusFormat.swift | 12 +- .../Compose/ComposeHostingController.swift | 214 +----------------- Tusker/Screens/Compose/ComposePollView.swift | 7 +- Tusker/Screens/Compose/ComposeToolbar.swift | 148 ++++++++++++ Tusker/Screens/Compose/ComposeUIState.swift | 5 +- Tusker/Screens/Compose/ComposeView.swift | 24 +- .../Screens/Compose/MainComposeTextView.swift | 7 +- Tusker/Screens/Mute/MuteAccountView.swift | 4 +- Tusker/ViewTags.swift | 12 +- Tusker/Views/MenuPicker.swift | 91 +++++--- 11 files changed, 257 insertions(+), 271 deletions(-) create mode 100644 Tusker/Screens/Compose/ComposeToolbar.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 06a99e6c..e8a8cfd3 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -316,6 +316,7 @@ D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A551291F098700F496A8 /* RenameListService.swift */; }; D6F6A554291F0D9600F496A8 /* DeleteListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A553291F0D9600F496A8 /* DeleteListService.swift */; }; D6F6A557291F4F1600F496A8 /* MuteAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A556291F4F1600F496A8 /* MuteAccountView.swift */; }; + D6F6A5592920676800F496A8 /* ComposeToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A5582920676800F496A8 /* ComposeToolbar.swift */; }; D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F953EF21251A2900CF0F2B /* MastodonController.swift */; }; D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */; }; /* End PBXBuildFile section */ @@ -687,6 +688,7 @@ D6F6A551291F098700F496A8 /* RenameListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameListService.swift; sourceTree = ""; }; D6F6A553291F0D9600F496A8 /* DeleteListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteListService.swift; sourceTree = ""; }; D6F6A556291F4F1600F496A8 /* MuteAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MuteAccountView.swift; sourceTree = ""; }; + D6F6A5582920676800F496A8 /* ComposeToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbar.swift; sourceTree = ""; }; D6F953EF21251A2900CF0F2B /* MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonController.swift; sourceTree = ""; }; D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSwitchingContainerViewController.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -1003,6 +1005,7 @@ D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */, D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */, D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */, + D6F6A5582920676800F496A8 /* ComposeToolbar.swift */, D6BEA248291C6118002F4D01 /* DraftsView.swift */, ); path = Compose; @@ -1996,6 +1999,7 @@ D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */, D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */, D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */, + D6F6A5592920676800F496A8 /* ComposeToolbar.swift in Sources */, 04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */, D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */, D6B9366B281EE77E00237D0E /* PollVoteButton.swift in Sources */, diff --git a/Tusker/Models/StatusFormat.swift b/Tusker/Models/StatusFormat.swift index 47579a7f..1f78df13 100644 --- a/Tusker/Models/StatusFormat.swift +++ b/Tusker/Models/StatusFormat.swift @@ -9,7 +9,7 @@ import UIKit import Pachyderm -enum StatusFormat: CaseIterable { +enum StatusFormat: Int, CaseIterable { case bold, italics, strikethrough, code var insertionResult: FormatInsertionResult? { @@ -23,19 +23,17 @@ enum StatusFormat: CaseIterable { } } - var image: UIImage? { - let name: String + var imageName: String? { switch self { case .italics: - name = "italic" + return "italic" case .bold: - name = "bold" + return "bold" case .strikethrough: - name = "strikethrough" + return "strikethrough" default: return nil } - return UIImage(systemName: name) } var title: (String, [NSAttributedString.Key: Any])? { diff --git a/Tusker/Screens/Compose/ComposeHostingController.swift b/Tusker/Screens/Compose/ComposeHostingController.swift index d4423d6d..7531fee3 100644 --- a/Tusker/Screens/Compose/ComposeHostingController.swift +++ b/Tusker/Screens/Compose/ComposeHostingController.swift @@ -29,13 +29,6 @@ class ComposeHostingController: UIHostingController, Ducka private var cancellables = [AnyCancellable]() - private var toolbarHeight: CGFloat = 44 - - private var mainToolbar: UIToolbar! - private var inputAccessoryToolbar: UIToolbar! - - override var inputAccessoryView: UIView? { inputAccessoryToolbar } - init(draft: Draft? = nil, mastodonController: MastodonController) { self.mastodonController = mastodonController let realDraft = draft ?? Draft(accountID: mastodonController.accountInfo!.id) @@ -54,38 +47,10 @@ class ComposeHostingController: UIHostingController, Ducka self.uiState.delegate = self - // 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 = 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(composeKeyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(composeKeyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(composeKeyboardDidHide(_:)), name: UIResponder.keyboardDidHideNotification, object: nil) - - // add the height of the toolbar itself to the bottom of the safe area so content inside SwiftUI ScrollView doesn't underflow it - updateAdditionalSafeAreaInsets() - pasteConfiguration = UIPasteConfiguration(forAccepting: CompositionAttachment.self) userActivity = UserActivityManager.newPostActivity(accountID: mastodonController.accountInfo!.id) - self.uiState.$draft - .flatMap(\.$visibility) - .sink(receiveValue: self.visibilityChanged) - .store(in: &cancellables) - - self.uiState.$draft - .flatMap(\.$localOnly) - .sink(receiveValue: self.localOnlyChanged) - .store(in: &cancellables) - self.uiState.$draft .flatMap(\.objectWillChange) .debounce(for: .milliseconds(250), scheduler: DispatchQueue.global(qos: .utility)) @@ -93,31 +58,12 @@ class ComposeHostingController: UIHostingController, Ducka 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) { fatalError("init(coder:) has not been implemented") } - override func willMove(toParent parent: UIViewController?) { - super.willMove(toParent: parent) - - if let parent = parent { - parent.view.addSubview(mainToolbar) - NSLayoutConstraint.activate([ - mainToolbar.leadingAnchor.constraint(equalTo: parent.view.leadingAnchor), - mainToolbar.trailingAnchor.constraint(equalTo: parent.view.trailingAnchor), - mainToolbar.bottomAnchor.constraint(equalTo: parent.view.safeAreaLayoutGuide.bottomAnchor), - ]) - } - } - override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) @@ -127,159 +73,6 @@ class ComposeHostingController: UIHostingController, Ducka DraftsManager.save() } - 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) - visibilityItem.tag = ViewTags.composeVisibilityBarButton - items.append(visibilityItem) - - if mastodonController.instanceFeatures.localOnlyPosts { - let item = UIBarButtonItem(image: nil, style: .plain, target: nil, action: nil) - item.tag = ViewTags.composeLocalOnlyBarButton - items.append(item) - localOnlyChanged(draft.localOnly) - } - - if input?.toolbarElements.contains(.emojiPicker) == true { - items.append(UIBarButtonItem(image: UIImage(systemName: "face.smiling"), style: .plain, target: self, action: #selector(emojiPickerButtonPressed))) - } - - 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() { - additionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 0, bottom: toolbarHeight, right: 0) - } - - @objc private func composeKeyboardWillShow(_ notification: Foundation.Notification) { - keyboardWillShow(accessoryView: inputAccessoryToolbar, notification: notification) - } - - func keyboardWillShow(accessoryView: UIView, notification: Foundation.Notification) { - mainToolbar.isHidden = true - - accessoryView.alpha = 1 - accessoryView.isHidden = false - } - - @objc private func composeKeyboardWillHide(_ notification: Foundation.Notification) { - keyboardWillHide(accessoryView: inputAccessoryToolbar, notification: notification) - } - - func keyboardWillHide(accessoryView: UIView, notification: Foundation.Notification) { - mainToolbar.isHidden = false - - let userInfo = notification.userInfo! - let durationObj = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as! NSNumber - let duration = TimeInterval(durationObj.doubleValue) - let curveValue = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as! NSNumber - let curve = UIView.AnimationCurve(rawValue: curveValue.intValue)! - let curveOption: UIView.AnimationOptions - switch curve { - case .easeInOut: - curveOption = .curveEaseInOut - case .easeIn: - curveOption = .curveEaseIn - case .easeOut: - curveOption = .curveEaseOut - case .linear: - curveOption = .curveLinear - @unknown default: - curveOption = .curveLinear - } - UIView.animate(withDuration: duration, delay: 0, options: curveOption) { - accessoryView.alpha = 0 - } completion: { (finished) in - accessoryView.alpha = 1 - } - } - - @objc private func composeKeyboardDidHide(_ notification: Foundation.Notification) { - keyboardDidHide(accessoryView: inputAccessoryToolbar, notification: notification) - } - - func keyboardDidHide(accessoryView: UIView, notification: Foundation.Notification) { - accessoryView.isHidden = true - } - - private func visibilityChanged(_ newVisibility: Status.Visibility) { - for toolbar in [mainToolbar, inputAccessoryToolbar] { - guard let item = toolbar?.items?.first(where: { $0.tag == ViewTags.composeVisibilityBarButton }) 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 - let state = visibility == newVisibility ? UIMenuElement.State.on : .off - return UIAction(title: visibility.displayName, subtitle: visibility.subtitle, image: UIImage(systemName: visibility.unfilledImageName), state: state) { [unowned self] (_) in - self.draft.visibility = visibility - } - } - item.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: elements) - } - } - - private func localOnlyChanged(_ localOnly: Bool) { - for toolbar in [mainToolbar, inputAccessoryToolbar] { - guard let item = toolbar?.items?.first(where: { $0.tag == ViewTags.composeLocalOnlyBarButton }) else { - continue - } - if localOnly { - item.image = UIImage(named: "link.broken") - item.accessibilityLabel = "Local-only" - } else { - item.image = UIImage(systemName: "link") - item.accessibilityLabel = "Federated" - } - let instanceSubtitle = "Only \(mastodonController.accountInfo!.instanceURL.host!)" - item.menu = UIMenu(children: [ - UIAction(title: "Local-only", subtitle: instanceSubtitle, image: UIImage(named: "link.broken"), state: localOnly ? .on : .off) { [unowned self] (_) in - self.draft.localOnly = true - }, - UIAction(title: "Federated", image: UIImage(systemName: "link"), state: localOnly ? .off : .on) { [unowned self] (_) in - self.draft.localOnly = false - }, - ]) - } - } - override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool { guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: CompositionAttachment.self) }) else { return false } if mastodonController.instanceFeatures.mastodonAttachmentRestrictions { @@ -305,14 +98,13 @@ class ComposeHostingController: UIHostingController, Ducka // MARK: Duckable func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) { - let animator = UIViewPropertyAnimator(duration: duration, curve: .linear) { - self.mainToolbar.layer.opacity = 0 + withAnimation(.linear(duration: duration).delay(delay)) { + uiState.isDucking = true } - animator.startAnimation(afterDelay: delay) } func duckableViewControllerDidFinishAnimatingDuck() { - mainToolbar.layer.opacity = 1 + uiState.isDucking = false } // MARK: Interaction diff --git a/Tusker/Screens/Compose/ComposePollView.swift b/Tusker/Screens/Compose/ComposePollView.swift index 4b66808f..f1d9f8b5 100644 --- a/Tusker/Screens/Compose/ComposePollView.swift +++ b/Tusker/Screens/Compose/ComposePollView.swift @@ -44,6 +44,7 @@ struct ComposePollView: View { .imageScale(.small) .padding(4) } + .accessibilityLabel("Remove poll") .buttonStyle(.plain) .accentColor(buttonForegroundColor) .background(Circle().foregroundColor(buttonBackgroundColor)) @@ -61,13 +62,13 @@ struct ComposePollView: View { HStack { MenuPicker(selection: $poll.multiple, options: [ - .init(title: "Allow multiple", value: true), - .init(title: "Single choice", value: false), + .init(value: true, title: "Allow multiple"), + .init(value: false, title: "Single choice"), ]) .frame(maxWidth: .infinity) MenuPicker(selection: $duration, options: Duration.allCases.map { - .init(title: ComposePollView.formatter.string(from: $0.timeInterval)!, value: $0) + .init(value: $0, title: ComposePollView.formatter.string(from: $0.timeInterval)!) }) .frame(maxWidth: .infinity) } diff --git a/Tusker/Screens/Compose/ComposeToolbar.swift b/Tusker/Screens/Compose/ComposeToolbar.swift new file mode 100644 index 00000000..32d869c7 --- /dev/null +++ b/Tusker/Screens/Compose/ComposeToolbar.swift @@ -0,0 +1,148 @@ +// +// ComposeToolbar.swift +// Tusker +// +// Created by Shadowfacts on 11/12/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import SwiftUI +import Pachyderm + +struct ComposeToolbar: View { + static let height: CGFloat = 44 + private static let visibilityOptions: [MenuPicker.Option] = Status.Visibility.allCases.map { vis in + .init(value: vis, title: vis.displayName, subtitle: vis.subtitle, image: UIImage(systemName: vis.unfilledImageName), accessibilityLabel: "Visibility: \(vis.displayName)") + } + + @ObservedObject var draft: Draft + + @EnvironmentObject private var uiState: ComposeUIState + @EnvironmentObject private var mastodonController: MastodonController + @ObservedObject private var preferences = Preferences.shared + @ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22 + @State private var minWidth: CGFloat? + @State private var realWidth: CGFloat? + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + Button("CW") { + draft.contentWarningEnabled.toggle() + } + .accessibilityLabel(draft.contentWarningEnabled ? "Remove content warning" : "Add content warning") + + MenuPicker(selection: $draft.visibility, options: Self.visibilityOptions, buttonStyle: .iconOnly) + // the button has a bunch of extra space by default, but combined with what we add it's too much + .padding(.horizontal, -8) + + if mastodonController.instanceFeatures.localOnlyPosts { + MenuPicker(selection: $draft.localOnly, options: [ + .init(value: true, title: "Local-only", subtitle: "Only \(mastodonController.accountInfo!.instanceURL.host!)", image: UIImage(named: "link.broken")), + .init(value: false, title: "Federated", image: UIImage(systemName: "link")) + ], buttonStyle: .iconOnly) + .padding(.horizontal, -8) + } + + if let currentInput = uiState.currentInput, currentInput.toolbarElements.contains(.emojiPicker) { + Button(action: self.emojiPickerButtonPressed) { + Label("Insert custom emoji", systemImage: "face.smiling") + } + .labelStyle(.iconOnly) + .font(.system(size: imageSize)) + } + + if let currentInput = uiState.currentInput, + currentInput.toolbarElements.contains(.formattingButtons), + preferences.statusContentType != .plain { + Spacer() + + ForEach(StatusFormat.allCases, id: \.rawValue) { format in + Button(action: self.formatAction(format)) { + if let imageName = format.imageName { + Image(systemName: imageName) + .font(.system(size: imageSize)) + } else if let (str, attrs) = format.title { + let container = try! AttributeContainer(attrs, including: \.uiKit) + Text(AttributedString(str, attributes: container)) + } + } + .accessibilityLabel(format.accessibilityLabel) + } + } + + Spacer() + + Button(action: self.draftsButtonPressed) { + Text("Drafts") + } + } + .padding(.horizontal, 16) + .frame(minWidth: minWidth) + .background(GeometryReader { proxy in + Color.clear + .preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width) + .onPreferenceChange(ToolbarWidthPrefKey.self) { width in + realWidth = width + } + }) + } + .scrollDisabledIfAvailable(realWidth ?? 0 <= minWidth ?? 0) + .frame(height: Self.height) + .frame(maxWidth: .infinity) + .background(.regularMaterial, ignoresSafeAreaEdges: .bottom) + .overlay(alignment: .top) { + Divider() + } + .background(GeometryReader { proxy in + Color.clear + .preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width) + .onPreferenceChange(ToolbarWidthPrefKey.self) { width in + minWidth = width + } + }) + } + + private func emojiPickerButtonPressed() { + guard uiState.autocompleteState == nil else { + return + } + uiState.shouldEmojiAutocompletionBeginExpanded = true + uiState.currentInput?.beginAutocompletingEmoji() + } + + private func draftsButtonPressed() { + uiState.isShowingDraftsList = true + } + + private func formatAction(_ format: StatusFormat) -> () -> Void { + { + uiState.currentInput?.applyFormat(format) + } + } +} + +private struct ToolbarWidthPrefKey: PreferenceKey { + static var defaultValue: CGFloat? = nil + static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) { + value = nextValue() + } +} + +private extension View { + @available(iOS, obsoleted: 16.0) + @ViewBuilder + func scrollDisabledIfAvailable(_ disabled: Bool) -> some View { + if #available(iOS 16.0, *) { + self.scrollDisabled(disabled) + } else { + self + } + } +} + +struct ComposeToolbar_Previews: PreviewProvider { + static var previews: some View { + ComposeToolbar(draft: Draft(accountID: "")) + } +} diff --git a/Tusker/Screens/Compose/ComposeUIState.swift b/Tusker/Screens/Compose/ComposeUIState.swift index 95c0d0ca..ca8b24d7 100644 --- a/Tusker/Screens/Compose/ComposeUIState.swift +++ b/Tusker/Screens/Compose/ComposeUIState.swift @@ -16,10 +16,6 @@ protocol ComposeUIStateDelegate: AnyObject { func presentAssetPickerSheet() func presentComposeDrawing() func selectDraft(_ draft: Draft) - - func keyboardWillShow(accessoryView: UIView, notification: Notification) - func keyboardWillHide(accessoryView: UIView, notification: Notification) - func keyboardDidHide(accessoryView: UIView, notification: Notification) } class ComposeUIState: ObservableObject { @@ -31,6 +27,7 @@ class ComposeUIState: ObservableObject { @Published var isShowingDraftsList = false @Published var attachmentsMissingDescriptions = Set() @Published var autocompleteState: AutocompleteState? = nil + @Published var isDucking = false var composeDrawingMode: ComposeDrawingMode? diff --git a/Tusker/Screens/Compose/ComposeView.swift b/Tusker/Screens/Compose/ComposeView.swift index be2a3023..6d74e4cd 100644 --- a/Tusker/Screens/Compose/ComposeView.swift +++ b/Tusker/Screens/Compose/ComposeView.swift @@ -89,8 +89,18 @@ struct ComposeView: View { // can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149 WrappedProgressView(value: poster.currentStep, total: poster.totalSteps) } - - autocompleteSuggestions + } + .safeAreaInset(edge: .bottom, spacing: 0) { + if !uiState.isDucking { + VStack(spacing: 0) { + autocompleteSuggestions + .transition(.move(edge: .bottom)) + .animation(.default, value: uiState.autocompleteState) + + ComposeToolbar(draft: draft) + } + .transition(.move(edge: .bottom)) + } } .background(GeometryReader { proxy in Color.clear @@ -119,14 +129,9 @@ struct ComposeView: View { @ViewBuilder var autocompleteSuggestions: some View { - VStack(spacing: 0) { - Spacer() - if let state = uiState.autocompleteState { - ComposeAutocompleteView(autocompleteState: state) - } + if let state = uiState.autocompleteState { + ComposeAutocompleteView(autocompleteState: state) } - .transition(.move(edge: .bottom)) - .animation(.default, value: uiState.autocompleteState) } var mainList: some View { @@ -179,7 +184,6 @@ struct ComposeView: View { .scrollDismissesKeyboardInteractivelyIfAvailable() .listStyle(.plain) .disabled(isPosting) - .padding(.bottom, uiState.autocompleteState != nil ? 46 : 0) .onChange(of: draft.contentWarningEnabled) { newValue in if newValue { contentWarningBecomeFirstResponder = true diff --git a/Tusker/Screens/Compose/MainComposeTextView.swift b/Tusker/Screens/Compose/MainComposeTextView.swift index a962ecf8..63617f33 100644 --- a/Tusker/Screens/Compose/MainComposeTextView.swift +++ b/Tusker/Screens/Compose/MainComposeTextView.swift @@ -48,6 +48,7 @@ struct MainComposeTextView: View { .font(.system(size: fontSize)) .foregroundColor(.secondary) .offset(x: 4, y: 8) + .accessibilityHidden(true) } MainComposeWrappedTextView( @@ -235,7 +236,11 @@ struct MainComposeWrappedTextView: UIViewRepresentable { if range.length > 0 { let formatMenu = suggestedActions[index] as! UIMenu let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in - UIAction(title: fmt.accessibilityLabel, image: fmt.image) { [weak self] _ in + var image: UIImage? + if let imageName = fmt.imageName { + image = UIImage(systemName: imageName) + } + return UIAction(title: fmt.accessibilityLabel, image: image) { [weak self] _ in self?.applyFormat(fmt) } }) diff --git a/Tusker/Screens/Mute/MuteAccountView.swift b/Tusker/Screens/Mute/MuteAccountView.swift index 533c43a8..ea520849 100644 --- a/Tusker/Screens/Mute/MuteAccountView.swift +++ b/Tusker/Screens/Mute/MuteAccountView.swift @@ -25,8 +25,8 @@ struct MuteAccountView: View { 7 * 60 * 60 * 60, ] return [ - .init(title: "Forever", value: 0) - ] + durations.map { .init(title: f.string(from: $0)!, value: $0) } + .init(value: 0, title: "Forever") + ] + durations.map { .init(value: $0, title: f.string(from: $0)!) } }() let account: AccountMO diff --git a/Tusker/ViewTags.swift b/Tusker/ViewTags.swift index e032b21a..294ecccc 100644 --- a/Tusker/ViewTags.swift +++ b/Tusker/ViewTags.swift @@ -11,11 +11,9 @@ import Foundation struct ViewTags { private init() {} - static let composeVisibilityBarButton = 42001 - static let composeLocalOnlyBarButton = 42002 - static let navBackBarButton = 42003 - static let navForwardBarButton = 42004 - static let navEmptyTitleView = 42005 - static let splitNavCloseSecondaryButton = 42006 - static let customAlertSeparator = 42007 + static let navBackBarButton = 42001 + static let navForwardBarButton = 42002 + static let navEmptyTitleView = 42003 + static let splitNavCloseSecondaryButton = 42004 + static let customAlertSeparator = 42005 } diff --git a/Tusker/Views/MenuPicker.swift b/Tusker/Views/MenuPicker.swift index b6eaf8d7..f452b5df 100644 --- a/Tusker/Views/MenuPicker.swift +++ b/Tusker/Views/MenuPicker.swift @@ -8,41 +8,80 @@ import SwiftUI -struct MenuPicker: View { +struct MenuPicker: UIViewRepresentable { + typealias UIViewType = UIButton + @Binding var selection: Value let options: [Option] + var buttonStyle: ButtonStyle = .labelAndIcon private var selectedOption: Option { options.first(where: { $0.value == selection })! } - var body: some View { - Menu { - ForEach(options, id: \.value) { option in - Button { - selection = option.value - } label: { - Label(option.title, systemImage: selection == option.value ? "checkmark" : "") - } - } - } label: { - // zstack so that the size of the picker is the size of the largest option - ZStack { - ForEach(options, id: \.value) { option in - HStack { - Text(option.title) - Image(systemName: "chevron.up.chevron.down") - } - .opacity(option.value == selection ? 1 : 0) - } - } + func makeUIView(context: Context) -> UIButton { + let button = UIButton() + button.showsMenuAsPrimaryAction = true + button.setContentHuggingPriority(.required, for: .horizontal) + return button + } + + func updateUIView(_ button: UIButton, context: Context) { + var config = UIButton.Configuration.borderless() + if #available(iOS 16.0, *) { + config.indicator = .popup } - .menuStyle(.borderlessButton) + if buttonStyle.hasIcon { + config.image = selectedOption.image + } + if buttonStyle.hasLabel { + config.title = selectedOption.title + } + button.configuration = config + button.menu = UIMenu(children: options.map { opt in + UIAction(title: opt.title, subtitle: opt.subtitle, image: opt.image, state: opt.value == selection ? .on : .off) { _ in + selection = opt.value + } + }) + button.accessibilityLabel = selectedOption.accessibilityLabel ?? selectedOption.title } struct Option { - let title: String let value: Value + let title: String + let subtitle: String? + let image: UIImage? + let accessibilityLabel: String? + + init(value: Value, title: String, subtitle: String? = nil, image: UIImage? = nil, accessibilityLabel: String? = nil) { + self.value = value + self.title = title + self.subtitle = subtitle + self.image = image + self.accessibilityLabel = accessibilityLabel + } + } + + enum ButtonStyle { + case labelAndIcon, labelOnly, iconOnly + + var hasLabel: Bool { + switch self { + case .labelAndIcon, .labelOnly: + return true + default: + return false + } + } + + var hasIcon: Bool { + switch self { + case .labelAndIcon, .iconOnly: + return true + default: + return false + } + } } } @@ -50,9 +89,9 @@ struct MenuPicker_Previews: PreviewProvider { @State static var value = 0 static var previews: some View { MenuPicker(selection: $value, options: [ - .init(title: "Zero", value: 0), - .init(title: "One", value: 1), - .init(title: "Two", value: 2), + .init(value: 0, title: "Zero"), + .init(value: 1, title: "One"), + .init(value: 2, title: "Two"), ]) } }