Rewrite Compose toolbar with SwiftUI
Fixes buttons not being accessible with VoiceOver Fixes content overflowing on small devices Closes #232 Closes #218
This commit is contained in:
parent
7294ff6e1a
commit
6d2830cf78
|
@ -316,6 +316,7 @@
|
||||||
D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A551291F098700F496A8 /* RenameListService.swift */; };
|
D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A551291F098700F496A8 /* RenameListService.swift */; };
|
||||||
D6F6A554291F0D9600F496A8 /* DeleteListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A553291F0D9600F496A8 /* DeleteListService.swift */; };
|
D6F6A554291F0D9600F496A8 /* DeleteListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A553291F0D9600F496A8 /* DeleteListService.swift */; };
|
||||||
D6F6A557291F4F1600F496A8 /* MuteAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A556291F4F1600F496A8 /* MuteAccountView.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 */; };
|
D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F953EF21251A2900CF0F2B /* MastodonController.swift */; };
|
||||||
D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */; };
|
D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
@ -687,6 +688,7 @@
|
||||||
D6F6A551291F098700F496A8 /* RenameListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameListService.swift; sourceTree = "<group>"; };
|
D6F6A551291F098700F496A8 /* RenameListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameListService.swift; sourceTree = "<group>"; };
|
||||||
D6F6A553291F0D9600F496A8 /* DeleteListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteListService.swift; sourceTree = "<group>"; };
|
D6F6A553291F0D9600F496A8 /* DeleteListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteListService.swift; sourceTree = "<group>"; };
|
||||||
D6F6A556291F4F1600F496A8 /* MuteAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MuteAccountView.swift; sourceTree = "<group>"; };
|
D6F6A556291F4F1600F496A8 /* MuteAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MuteAccountView.swift; sourceTree = "<group>"; };
|
||||||
|
D6F6A5582920676800F496A8 /* ComposeToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbar.swift; sourceTree = "<group>"; };
|
||||||
D6F953EF21251A2900CF0F2B /* MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonController.swift; sourceTree = "<group>"; };
|
D6F953EF21251A2900CF0F2B /* MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonController.swift; sourceTree = "<group>"; };
|
||||||
D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSwitchingContainerViewController.swift; sourceTree = "<group>"; };
|
D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSwitchingContainerViewController.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
@ -1003,6 +1005,7 @@
|
||||||
D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */,
|
D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */,
|
||||||
D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */,
|
D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */,
|
||||||
D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */,
|
D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */,
|
||||||
|
D6F6A5582920676800F496A8 /* ComposeToolbar.swift */,
|
||||||
D6BEA248291C6118002F4D01 /* DraftsView.swift */,
|
D6BEA248291C6118002F4D01 /* DraftsView.swift */,
|
||||||
);
|
);
|
||||||
path = Compose;
|
path = Compose;
|
||||||
|
@ -1996,6 +1999,7 @@
|
||||||
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */,
|
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */,
|
||||||
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */,
|
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */,
|
||||||
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
|
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
|
||||||
|
D6F6A5592920676800F496A8 /* ComposeToolbar.swift in Sources */,
|
||||||
04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */,
|
04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */,
|
||||||
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */,
|
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */,
|
||||||
D6B9366B281EE77E00237D0E /* PollVoteButton.swift in Sources */,
|
D6B9366B281EE77E00237D0E /* PollVoteButton.swift in Sources */,
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
|
||||||
enum StatusFormat: CaseIterable {
|
enum StatusFormat: Int, CaseIterable {
|
||||||
case bold, italics, strikethrough, code
|
case bold, italics, strikethrough, code
|
||||||
|
|
||||||
var insertionResult: FormatInsertionResult? {
|
var insertionResult: FormatInsertionResult? {
|
||||||
|
@ -23,19 +23,17 @@ enum StatusFormat: CaseIterable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var image: UIImage? {
|
var imageName: String? {
|
||||||
let name: String
|
|
||||||
switch self {
|
switch self {
|
||||||
case .italics:
|
case .italics:
|
||||||
name = "italic"
|
return "italic"
|
||||||
case .bold:
|
case .bold:
|
||||||
name = "bold"
|
return "bold"
|
||||||
case .strikethrough:
|
case .strikethrough:
|
||||||
name = "strikethrough"
|
return "strikethrough"
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return UIImage(systemName: name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var title: (String, [NSAttributedString.Key: Any])? {
|
var title: (String, [NSAttributedString.Key: Any])? {
|
||||||
|
|
|
@ -29,13 +29,6 @@ class ComposeHostingController: UIHostingController<ComposeContainerView>, Ducka
|
||||||
|
|
||||||
private var cancellables = [AnyCancellable]()
|
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) {
|
init(draft: Draft? = nil, mastodonController: MastodonController) {
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
let realDraft = draft ?? Draft(accountID: mastodonController.accountInfo!.id)
|
let realDraft = draft ?? Draft(accountID: mastodonController.accountInfo!.id)
|
||||||
|
@ -54,38 +47,10 @@ class ComposeHostingController: UIHostingController<ComposeContainerView>, Ducka
|
||||||
|
|
||||||
self.uiState.delegate = self
|
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)
|
pasteConfiguration = UIPasteConfiguration(forAccepting: CompositionAttachment.self)
|
||||||
|
|
||||||
userActivity = UserActivityManager.newPostActivity(accountID: mastodonController.accountInfo!.id)
|
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
|
self.uiState.$draft
|
||||||
.flatMap(\.objectWillChange)
|
.flatMap(\.objectWillChange)
|
||||||
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.global(qos: .utility))
|
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.global(qos: .utility))
|
||||||
|
@ -93,31 +58,12 @@ class ComposeHostingController: UIHostingController<ComposeContainerView>, Ducka
|
||||||
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) {
|
||||||
fatalError("init(coder:) has not been implemented")
|
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) {
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
super.viewWillDisappear(animated)
|
super.viewWillDisappear(animated)
|
||||||
|
|
||||||
|
@ -127,159 +73,6 @@ class ComposeHostingController: UIHostingController<ComposeContainerView>, Ducka
|
||||||
DraftsManager.save()
|
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 {
|
override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool {
|
||||||
guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: CompositionAttachment.self) }) else { return false }
|
guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: CompositionAttachment.self) }) else { return false }
|
||||||
if mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
|
if mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
|
||||||
|
@ -305,14 +98,13 @@ class ComposeHostingController: UIHostingController<ComposeContainerView>, Ducka
|
||||||
// MARK: Duckable
|
// MARK: Duckable
|
||||||
|
|
||||||
func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) {
|
func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) {
|
||||||
let animator = UIViewPropertyAnimator(duration: duration, curve: .linear) {
|
withAnimation(.linear(duration: duration).delay(delay)) {
|
||||||
self.mainToolbar.layer.opacity = 0
|
uiState.isDucking = true
|
||||||
}
|
}
|
||||||
animator.startAnimation(afterDelay: delay)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func duckableViewControllerDidFinishAnimatingDuck() {
|
func duckableViewControllerDidFinishAnimatingDuck() {
|
||||||
mainToolbar.layer.opacity = 1
|
uiState.isDucking = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Interaction
|
// MARK: Interaction
|
||||||
|
|
|
@ -44,6 +44,7 @@ struct ComposePollView: View {
|
||||||
.imageScale(.small)
|
.imageScale(.small)
|
||||||
.padding(4)
|
.padding(4)
|
||||||
}
|
}
|
||||||
|
.accessibilityLabel("Remove poll")
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accentColor(buttonForegroundColor)
|
.accentColor(buttonForegroundColor)
|
||||||
.background(Circle().foregroundColor(buttonBackgroundColor))
|
.background(Circle().foregroundColor(buttonBackgroundColor))
|
||||||
|
@ -61,13 +62,13 @@ struct ComposePollView: View {
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
MenuPicker(selection: $poll.multiple, options: [
|
MenuPicker(selection: $poll.multiple, options: [
|
||||||
.init(title: "Allow multiple", value: true),
|
.init(value: true, title: "Allow multiple"),
|
||||||
.init(title: "Single choice", value: false),
|
.init(value: false, title: "Single choice"),
|
||||||
])
|
])
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
MenuPicker(selection: $duration, options: Duration.allCases.map {
|
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)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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: ""))
|
||||||
|
}
|
||||||
|
}
|
|
@ -16,10 +16,6 @@ protocol ComposeUIStateDelegate: AnyObject {
|
||||||
func presentAssetPickerSheet()
|
func presentAssetPickerSheet()
|
||||||
func presentComposeDrawing()
|
func presentComposeDrawing()
|
||||||
func selectDraft(_ draft: Draft)
|
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 {
|
class ComposeUIState: ObservableObject {
|
||||||
|
@ -31,6 +27,7 @@ class ComposeUIState: ObservableObject {
|
||||||
@Published var isShowingDraftsList = false
|
@Published var isShowingDraftsList = false
|
||||||
@Published var attachmentsMissingDescriptions = Set<UUID>()
|
@Published var attachmentsMissingDescriptions = Set<UUID>()
|
||||||
@Published var autocompleteState: AutocompleteState? = nil
|
@Published var autocompleteState: AutocompleteState? = nil
|
||||||
|
@Published var isDucking = false
|
||||||
|
|
||||||
var composeDrawingMode: ComposeDrawingMode?
|
var composeDrawingMode: ComposeDrawingMode?
|
||||||
|
|
||||||
|
|
|
@ -89,8 +89,18 @@ struct ComposeView: View {
|
||||||
// can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
|
// can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
|
||||||
WrappedProgressView(value: poster.currentStep, total: poster.totalSteps)
|
WrappedProgressView(value: poster.currentStep, total: poster.totalSteps)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||||
|
if !uiState.isDucking {
|
||||||
|
VStack(spacing: 0) {
|
||||||
autocompleteSuggestions
|
autocompleteSuggestions
|
||||||
|
.transition(.move(edge: .bottom))
|
||||||
|
.animation(.default, value: uiState.autocompleteState)
|
||||||
|
|
||||||
|
ComposeToolbar(draft: draft)
|
||||||
|
}
|
||||||
|
.transition(.move(edge: .bottom))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.background(GeometryReader { proxy in
|
.background(GeometryReader { proxy in
|
||||||
Color.clear
|
Color.clear
|
||||||
|
@ -119,15 +129,10 @@ struct ComposeView: View {
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
var autocompleteSuggestions: some View {
|
var autocompleteSuggestions: some View {
|
||||||
VStack(spacing: 0) {
|
|
||||||
Spacer()
|
|
||||||
if let state = uiState.autocompleteState {
|
if let state = uiState.autocompleteState {
|
||||||
ComposeAutocompleteView(autocompleteState: state)
|
ComposeAutocompleteView(autocompleteState: state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.transition(.move(edge: .bottom))
|
|
||||||
.animation(.default, value: uiState.autocompleteState)
|
|
||||||
}
|
|
||||||
|
|
||||||
var mainList: some View {
|
var mainList: some View {
|
||||||
List {
|
List {
|
||||||
|
@ -179,7 +184,6 @@ struct ComposeView: View {
|
||||||
.scrollDismissesKeyboardInteractivelyIfAvailable()
|
.scrollDismissesKeyboardInteractivelyIfAvailable()
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
.disabled(isPosting)
|
.disabled(isPosting)
|
||||||
.padding(.bottom, uiState.autocompleteState != nil ? 46 : 0)
|
|
||||||
.onChange(of: draft.contentWarningEnabled) { newValue in
|
.onChange(of: draft.contentWarningEnabled) { newValue in
|
||||||
if newValue {
|
if newValue {
|
||||||
contentWarningBecomeFirstResponder = true
|
contentWarningBecomeFirstResponder = true
|
||||||
|
|
|
@ -48,6 +48,7 @@ struct MainComposeTextView: View {
|
||||||
.font(.system(size: fontSize))
|
.font(.system(size: fontSize))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.offset(x: 4, y: 8)
|
.offset(x: 4, y: 8)
|
||||||
|
.accessibilityHidden(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
MainComposeWrappedTextView(
|
MainComposeWrappedTextView(
|
||||||
|
@ -235,7 +236,11 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
||||||
if range.length > 0 {
|
if range.length > 0 {
|
||||||
let formatMenu = suggestedActions[index] as! UIMenu
|
let formatMenu = suggestedActions[index] as! UIMenu
|
||||||
let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in
|
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)
|
self?.applyFormat(fmt)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -25,8 +25,8 @@ struct MuteAccountView: View {
|
||||||
7 * 60 * 60 * 60,
|
7 * 60 * 60 * 60,
|
||||||
]
|
]
|
||||||
return [
|
return [
|
||||||
.init(title: "Forever", value: 0)
|
.init(value: 0, title: "Forever")
|
||||||
] + durations.map { .init(title: f.string(from: $0)!, value: $0) }
|
] + durations.map { .init(value: $0, title: f.string(from: $0)!) }
|
||||||
}()
|
}()
|
||||||
|
|
||||||
let account: AccountMO
|
let account: AccountMO
|
||||||
|
|
|
@ -11,11 +11,9 @@ import Foundation
|
||||||
struct ViewTags {
|
struct ViewTags {
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
static let composeVisibilityBarButton = 42001
|
static let navBackBarButton = 42001
|
||||||
static let composeLocalOnlyBarButton = 42002
|
static let navForwardBarButton = 42002
|
||||||
static let navBackBarButton = 42003
|
static let navEmptyTitleView = 42003
|
||||||
static let navForwardBarButton = 42004
|
static let splitNavCloseSecondaryButton = 42004
|
||||||
static let navEmptyTitleView = 42005
|
static let customAlertSeparator = 42005
|
||||||
static let splitNavCloseSecondaryButton = 42006
|
|
||||||
static let customAlertSeparator = 42007
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,41 +8,80 @@
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct MenuPicker<Value: Hashable>: View {
|
struct MenuPicker<Value: Hashable>: UIViewRepresentable {
|
||||||
|
typealias UIViewType = UIButton
|
||||||
|
|
||||||
@Binding var selection: Value
|
@Binding var selection: Value
|
||||||
let options: [Option]
|
let options: [Option]
|
||||||
|
var buttonStyle: ButtonStyle = .labelAndIcon
|
||||||
|
|
||||||
private var selectedOption: Option {
|
private var selectedOption: Option {
|
||||||
options.first(where: { $0.value == selection })!
|
options.first(where: { $0.value == selection })!
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
func makeUIView(context: Context) -> UIButton {
|
||||||
Menu {
|
let button = UIButton()
|
||||||
ForEach(options, id: \.value) { option in
|
button.showsMenuAsPrimaryAction = true
|
||||||
Button {
|
button.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
selection = option.value
|
return button
|
||||||
} label: {
|
|
||||||
Label(option.title, systemImage: selection == option.value ? "checkmark" : "")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ button: UIButton, context: Context) {
|
||||||
|
var config = UIButton.Configuration.borderless()
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
config.indicator = .popup
|
||||||
}
|
}
|
||||||
} label: {
|
if buttonStyle.hasIcon {
|
||||||
// zstack so that the size of the picker is the size of the largest option
|
config.image = selectedOption.image
|
||||||
ZStack {
|
|
||||||
ForEach(options, id: \.value) { option in
|
|
||||||
HStack {
|
|
||||||
Text(option.title)
|
|
||||||
Image(systemName: "chevron.up.chevron.down")
|
|
||||||
}
|
}
|
||||||
.opacity(option.value == selection ? 1 : 0)
|
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
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
.menuStyle(.borderlessButton)
|
button.accessibilityLabel = selectedOption.accessibilityLabel ?? selectedOption.title
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Option {
|
struct Option {
|
||||||
let title: String
|
|
||||||
let value: Value
|
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
|
@State static var value = 0
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
MenuPicker(selection: $value, options: [
|
MenuPicker(selection: $value, options: [
|
||||||
.init(title: "Zero", value: 0),
|
.init(value: 0, title: "Zero"),
|
||||||
.init(title: "One", value: 1),
|
.init(value: 1, title: "One"),
|
||||||
.init(title: "Two", value: 2),
|
.init(value: 2, title: "Two"),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue