Remove input accessory toolbar

This commit is contained in:
Shadowfacts 2025-03-03 19:05:47 -05:00
parent 84ed9e92ee
commit 1135094c21
9 changed files with 19 additions and 320 deletions

View File

@ -72,53 +72,17 @@ struct FocusedInputModifier: ViewModifier {
} }
} }
// In the input accessory view toolbar, we get the focused input through the box injected from the ComposeView.
// Otherwise we get it from @FocusedValue (which doesn't seem to work via the hacks we use for the input accessory).
// This property wrapper abstracts over them both.
@propertyWrapper
struct FocusedInput: DynamicProperty {
@FocusedValue(\.composeInput) private var input
#if !targetEnvironment(macCatalyst) && !os(visionOS)
@Environment(\.toolbarInjectedFocusedInputBox) private var box
@StateObject private var updater = Updater()
#endif
var wrappedValue: (any ComposeInput)? {
#if !targetEnvironment(macCatalyst) && !os(visionOS)
box?.wrappedValue ?? input ?? nil
#else
input ?? nil
#endif
}
#if !targetEnvironment(macCatalyst) && !os(visionOS)
func update() {
updater.update(box: box)
}
private class Updater: ObservableObject {
private var cancellable: AnyCancellable?
func update(box: MutableObservableBox<(any ComposeInput)?>?) {
cancellable = box?.objectWillChange.sink { [unowned self] _ in
self.objectWillChange.send()
}
}
}
#endif
}
@propertyWrapper @propertyWrapper
struct FocusedInputAutocompleteState: DynamicProperty { struct FocusedInputAutocompleteState: DynamicProperty {
@FocusedInput private var input @FocusedValue(\.composeInput) private var input
@StateObject private var updater = Updater() @StateObject private var updater = Updater()
var wrappedValue: AutocompleteState? { var wrappedValue: AutocompleteState? {
input?.autocompleteState input??.autocompleteState
} }
func update() { func update() {
updater.update(input: input) updater.update(input: input ?? nil)
} }
@MainActor @MainActor
@ -133,8 +97,8 @@ struct FocusedInputAutocompleteState: DynamicProperty {
lastInput = input lastInput = input
cancellable = input?.autocompleteStatePublisher.sink { [unowned self] _ in cancellable = input?.autocompleteStatePublisher.sink { [unowned self] _ in
// the autocomplete state sometimes changes during a view update, so defer this // the autocomplete state sometimes changes during a view update, so defer this
DispatchQueue.main.async { DispatchQueue.main.async { [weak self] in
self.objectWillChange.send() self?.objectWillChange.send()
} }
} }
} }

View File

@ -1,40 +0,0 @@
//
// KeyboardReader.swift
// ComposeUI
//
// Created by Shadowfacts on 3/7/23.
//
#if !os(visionOS)
import UIKit
import Combine
@available(iOS, obsoleted: 16.0)
class KeyboardReader: ObservableObject {
@Published var keyboardHeight: CGFloat = 0
var isVisible: Bool {
// when a hardware keyboard is connected, the height is very short, so we don't consider that being "visible"
keyboardHeight > 72
}
init() {
NotificationCenter.default.addObserver(self, selector: #selector(willShow), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(willHide), name: UIResponder.keyboardWillHideNotification, object: nil)
}
@objc func willShow(_ notification: Foundation.Notification) {
let endFrame = notification.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect
keyboardHeight = endFrame.height
}
@objc func willHide() {
// sometimes willHide is called during a SwiftUI view update
DispatchQueue.main.async {
self.keyboardHeight = 0
}
}
}
#endif

View File

@ -144,12 +144,12 @@ private struct ExpandedEmojiSection: View {
private struct AutocompleteEmojiButton: View { private struct AutocompleteEmojiButton: View {
let emoji: Emoji let emoji: Emoji
@FocusedInput private var input @FocusedValue(\.composeInput) private var input
@Environment(\.composeUIDelegate) private var delegate @Environment(\.composeUIDelegate) private var delegate
var body: some View { var body: some View {
Button { Button {
input?.autocomplete(with: ":\(emoji.shortcode):") input??.autocomplete(with: ":\(emoji.shortcode):")
} label: { } label: {
Label { Label {
Text(verbatim: ":\(emoji.shortcode):") Text(verbatim: ":\(emoji.shortcode):")

View File

@ -14,7 +14,7 @@ struct AutocompleteHashtagView: View {
let mastodonController: any ComposeMastodonContext let mastodonController: any ComposeMastodonContext
@State private var loading = false @State private var loading = false
@State private var hashtags: [Hashtag] = [] @State private var hashtags: [Hashtag] = []
@FocusedInput private var input @FocusedValue(\.composeInput) private var input
var body: some View { var body: some View {
ScrollView(.horizontal) { ScrollView(.horizontal) {
@ -90,6 +90,6 @@ struct AutocompleteHashtagView: View {
} }
private func autocomplete(hashtag: Hashtag) { private func autocomplete(hashtag: Hashtag) {
input?.autocomplete(with: "#\(hashtag.name)") input??.autocomplete(with: "#\(hashtag.name)")
} }
} }

View File

@ -15,7 +15,7 @@ struct AutocompleteMentionView: View {
let mastodonController: any ComposeMastodonContext let mastodonController: any ComposeMastodonContext
@State private var loading = false @State private var loading = false
@State private var accounts: [AnyAccount] = [] @State private var accounts: [AnyAccount] = []
@FocusedInput private var input @FocusedValue(\.composeInput) private var input
var body: some View { var body: some View {
ScrollView(.horizontal) { ScrollView(.horizontal) {
@ -48,7 +48,7 @@ struct AutocompleteMentionView: View {
} }
private func autocomplete(account: AnyAccount) { private func autocomplete(account: AnyAccount) {
input?.autocomplete(with: "@\(account.value.acct)") input??.autocomplete(with: "@\(account.value.acct)")
} }
private func queryChanged() async { private func queryChanged() async {

View File

@ -207,11 +207,11 @@ private struct LocalOnlyButton: View {
} }
private struct InsertEmojiButton: View { private struct InsertEmojiButton: View {
@FocusedInput private var input @FocusedValue(\.composeInput) private var input
@ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22 @ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22
var body: some View { var body: some View {
if input?.toolbarElements.contains(.emojiPicker) == true { if input??.toolbarElements.contains(.emojiPicker) == true {
Button(action: beginAutocompletingEmoji) { Button(action: beginAutocompletingEmoji) {
Label("Insert custom emoji", systemImage: "face.smiling") Label("Insert custom emoji", systemImage: "face.smiling")
} }
@ -225,6 +225,7 @@ private struct InsertEmojiButton: View {
private func beginAutocompletingEmoji() { private func beginAutocompletingEmoji() {
if let input, if let input,
let input,
input.autocompleteState == nil { input.autocompleteState == nil {
input.beginAutocompletingEmoji() input.beginAutocompletingEmoji()
} }
@ -232,11 +233,12 @@ private struct InsertEmojiButton: View {
} }
private struct FormatButtons: View { private struct FormatButtons: View {
@FocusedInput private var input @FocusedValue(\.composeInput) private var input
@PreferenceObserving(\.$statusContentType) private var contentType @PreferenceObserving(\.$statusContentType) private var contentType
var body: some View { var body: some View {
if let input, if let input,
let input,
input.toolbarElements.contains(.formattingButtons), input.toolbarElements.contains(.formattingButtons),
contentType != .plain { contentType != .plain {
@ -269,16 +271,6 @@ private struct FormatButton: View {
} }
} }
private struct InputAccessoryToolbarHost: EnvironmentKey {
static var defaultValue: UIView? { nil }
}
extension EnvironmentValues {
var inputAccessoryToolbarHost: UIView? {
get { self[InputAccessoryToolbarHost.self] }
set { self[InputAccessoryToolbarHost.self] = newValue }
}
}
//#Preview { //#Preview {
// ComposeToolbarView() // ComposeToolbarView()
//} //}

View File

@ -59,14 +59,6 @@ public struct ComposeView: View {
.environment(\.composeUIConfig, config) .environment(\.composeUIConfig, config)
.environment(\.composeUIDelegate, delegate) .environment(\.composeUIDelegate, delegate)
.environment(\.currentAccount, currentAccount) .environment(\.currentAccount, currentAccount)
#if !targetEnvironment(macCatalyst) && !os(visionOS)
.injectInputAccessoryHost(
state: state,
mastodonController: mastodonController,
focusedField: $focusedField,
delegate: delegate
)
#endif
.onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext), perform: self.managedObjectsDidChange) .onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext), perform: self.managedObjectsDidChange)
} }
@ -154,9 +146,6 @@ private struct ComposeViewBody: View {
#if !os(visionOS) #if !os(visionOS)
.scrollDismissesKeyboardInteractivelyIfAvailable() .scrollDismissesKeyboardInteractivelyIfAvailable()
#endif #endif
#if !os(visionOS) && !targetEnvironment(macCatalyst)
.modifier(ToolbarSafeAreaInsetModifier())
#endif
} }
.overlay(alignment: .top) { .overlay(alignment: .top) {
if let poster = state.poster { if let poster = state.poster {
@ -165,33 +154,13 @@ private struct ComposeViewBody: View {
} }
} }
#if !os(visionOS) #if !os(visionOS)
.overlay(alignment: .bottom, content: { .safeAreaInset(edge: .bottom) {
// This needs to be in an overlay, ignoring the keyboard safe area if config.showToolbar {
// doesn't work with the safeAreaInset modifier.
// When we're using the input accessory toolbar, hide the overlay toolbar so that,
// on iPad, we don't get two toolbars showing.
#if targetEnvironment(macCatalyst) || os(visionOS)
let showOverlayToolbar = true
#else
let showOverlayToolbar = if #available(iOS 16.0, *) {
focusedField == nil
} else {
true
}
#endif
if config.showToolbar,
showOverlayToolbar {
toolbarView toolbarView
.frame(maxHeight: .infinity, alignment: .bottom)
#if !targetEnvironment(macCatalyst) && !os(visionOS)
.modifier(IgnoreKeyboardSafeAreaIfUsingInputAccessory())
#endif
.transition(.move(edge: .bottom)) .transition(.move(edge: .bottom))
.animation(.snappy, value: config.showToolbar) .animation(.snappy, value: config.showToolbar)
} }
}) }
#endif #endif
// Have these after the overlays so they barely work instead of not working at all. FB11790805 // Have these after the overlays so they barely work instead of not working at all. FB11790805
.modifier(DropAttachmentModifier(draft: draft)) .modifier(DropAttachmentModifier(draft: draft))
@ -304,180 +273,6 @@ enum FocusableField: Hashable {
case pollOption(NSManagedObjectID) case pollOption(NSManagedObjectID)
} }
#if !os(visionOS) && !targetEnvironment(macCatalyst)
private struct ToolbarSafeAreaInsetModifier: ViewModifier {
@StateObject private var keyboardReader = KeyboardReader()
@FocusedInputAutocompleteState private var autocompleteState
private var inset: CGFloat {
var height: CGFloat = 0
if keyboardReader.isVisible {
height += ComposeToolbarView.toolbarHeight
}
if autocompleteState != nil {
height += ComposeToolbarView.autocompleteHeight
}
return height
}
func body(content: Content) -> some View {
if #available(iOS 17.0, *) {
content
.safeAreaPadding(.bottom, inset)
} else {
content
.safeAreaInset(edge: .bottom) {
if !keyboardReader.isVisible {
Color.clear.frame(height: inset)
}
}
}
}
}
#endif
#if !targetEnvironment(macCatalyst) && !os(visionOS)
private struct IgnoreKeyboardSafeAreaIfUsingInputAccessory: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 16.0, *) {
content
.ignoresSafeArea(.keyboard)
} else {
content
}
}
}
private extension View {
@ViewBuilder
func injectInputAccessoryHost(
state: ComposeViewState,
mastodonController: any ComposeMastodonContext,
focusedField: FocusState<FocusableField?>.Binding,
delegate: (any ComposeUIDelegate)?
) -> some View {
if #available(iOS 16.0, *) {
self.modifier(
InputAccessoryHostInjector(
state: state,
mastodonController: mastodonController,
focusedField: focusedField,
delegate: delegate
)
)
} else {
self
}
}
}
@available(iOS 16.0, *)
private struct InputAccessoryHostInjector: ViewModifier {
// This is in a StateObject so we can use the autoclosure StateObject initializer.
@StateObject private var factory: ViewFactory
@FocusedValue(\.composeInput) private var composeInput
init(
state: ComposeViewState,
mastodonController: any ComposeMastodonContext,
focusedField: FocusState<FocusableField?>.Binding,
delegate: (any ComposeUIDelegate)?
) {
self._factory = StateObject(
wrappedValue: ViewFactory(
state: state,
mastodonController: mastodonController,
focusedField: focusedField,
delegate: delegate
)
)
}
func body(content: Content) -> some View {
content
.environment(\.inputAccessoryToolbarHost, factory.controller.view)
.onChange(of: ComposeInputEquatableBox(input: composeInput ?? nil)) { newValue in
factory.focusedInput = newValue.input ?? nil
}
}
@MainActor
private class ViewFactory: ObservableObject {
let controller: UIViewController
@MutableObservableBox var focusedInput: (any ComposeInput)?
init(
state: ComposeViewState,
mastodonController: any ComposeMastodonContext,
focusedField: FocusState<FocusableField?>.Binding,
delegate: (any ComposeUIDelegate)?
) {
self._focusedInput = MutableObservableBox(wrappedValue: nil)
let view = InputAccessoryToolbarView(
state: state,
mastodonController: mastodonController,
focusedField: focusedField,
focusedInputBox: _focusedInput,
delegate: delegate
)
let controller = UIHostingController(rootView: view)
controller.sizingOptions = .intrinsicContentSize
controller.view.autoresizingMask = .flexibleHeight
self.controller = controller
}
}
}
private struct ComposeInputEquatableBox: Equatable {
let input: (any ComposeInput)?
static func ==(lhs: Self, rhs: Self) -> Bool {
lhs.input === rhs.input
}
}
/// FocusedValue doesn't seem to work through the hacks we're doing for the input accessory.
private struct FocusedInputBoxEnvironmentKey: EnvironmentKey {
static var defaultValue: MutableObservableBox<(any ComposeInput)?>? { nil }
}
extension EnvironmentValues {
var toolbarInjectedFocusedInputBox: MutableObservableBox<(any ComposeInput)?>? {
get { self[FocusedInputBoxEnvironmentKey.self] }
set { self[FocusedInputBoxEnvironmentKey.self] = newValue }
}
}
private struct InputAccessoryToolbarView: View {
@ObservedObject var state: ComposeViewState
let mastodonController: any ComposeMastodonContext
@FocusState.Binding var focusedField: FocusableField?
let focusedInputBox: MutableObservableBox<(any ComposeInput)?>
let delegate: (any ComposeUIDelegate)?
@PreferenceObserving(\.$accentColor) private var accentColor
init(
state: ComposeViewState,
mastodonController: any ComposeMastodonContext,
focusedField: FocusState<FocusableField?>.Binding,
focusedInputBox: MutableObservableBox<(any ComposeInput)?>,
delegate: (any ComposeUIDelegate)?
) {
self.state = state
self.mastodonController = mastodonController
self._focusedField = focusedField
self.focusedInputBox = focusedInputBox
self.delegate = delegate
}
var body: some View {
ComposeToolbarView(draft: state.draft, mastodonController: mastodonController, focusedField: $focusedField)
.environment(\.toolbarInjectedFocusedInputBox, focusedInputBox)
.environment(\.composeUIDelegate, delegate)
.tint(accentColor.color.map(Color.init(uiColor:)))
}
}
#endif
//#Preview { //#Preview {
// ComposeView() // ComposeView()
//} //}

View File

@ -13,7 +13,6 @@ struct EmojiTextField: UIViewRepresentable {
@Environment(\.composeUIConfig.fillColor) private var fillColor @Environment(\.composeUIConfig.fillColor) private var fillColor
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@Environment(\.composeInputBox) private var inputBox @Environment(\.composeInputBox) private var inputBox
@Environment(\.inputAccessoryToolbarHost) private var inputAccessoryToolbarHost
@Binding var text: String @Binding var text: String
let placeholder: String let placeholder: String
@ -64,10 +63,6 @@ struct EmojiTextField: UIViewRepresentable {
#if !os(visionOS) #if !os(visionOS)
uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground
if uiView.inputAccessoryView !== inputAccessoryToolbarHost {
uiView.inputAccessoryView = inputAccessoryToolbarHost
}
#endif #endif
} }

View File

@ -41,7 +41,6 @@ private struct NewMainTextViewRepresentable: UIViewRepresentable {
@PreferenceObserving(\.$useTwitterKeyboard) private var useTwitterKeyboard @PreferenceObserving(\.$useTwitterKeyboard) private var useTwitterKeyboard
@Environment(\.composeUIConfig.textSelectionStartsAtBeginning) private var textSelectionStartsAtBeginning @Environment(\.composeUIConfig.textSelectionStartsAtBeginning) private var textSelectionStartsAtBeginning
@PreferenceObserving(\.$statusContentType) private var statusContentType @PreferenceObserving(\.$statusContentType) private var statusContentType
@Environment(\.inputAccessoryToolbarHost) private var inputAccessoryToolbarHost
func makeUIView(context: Context) -> WrappedTextView { func makeUIView(context: Context) -> WrappedTextView {
// TODO: if we're not doing the pill background, reevaluate whether this version fork is necessary // TODO: if we're not doing the pill background, reevaluate whether this version fork is necessary
@ -95,12 +94,6 @@ private struct NewMainTextViewRepresentable: UIViewRepresentable {
// uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground // uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground
// #endif // #endif
#if !os(visionOS)
if uiView.inputAccessoryView !== inputAccessoryToolbarHost {
uiView.inputAccessoryView = inputAccessoryToolbarHost
}
#endif
// Trying to set this with the @FocusState binding in onAppear results in the // Trying to set this with the @FocusState binding in onAppear results in the
// keyboard not appearing until after the sheet presentation animation completes :/ // keyboard not appearing until after the sheet presentation animation completes :/
if becomeFirstResponder { if becomeFirstResponder {