diff --git a/Packages/ComposeUI/Sources/ComposeUI/ComposeInput.swift b/Packages/ComposeUI/Sources/ComposeUI/ComposeInput.swift index 5581eb7f..b1f32f3a 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/ComposeInput.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/ComposeInput.swift @@ -70,3 +70,31 @@ struct FocusedInputModifier: ViewModifier { .focusedValue(\.composeInput, box.wrappedValue) } } + +// 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 { + @Environment(\.toolbarInjectedFocusedInputBox) private var box + @FocusedValue(\.composeInput) private var input + @StateObject private var updater = Updater() + + var wrappedValue: (any ComposeInput)? { + box?.wrappedValue ?? input ?? nil + } + + 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() + } + } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/ViewController.swift b/Packages/ComposeUI/Sources/ComposeUI/ViewController.swift deleted file mode 100644 index e3c41481..00000000 --- a/Packages/ComposeUI/Sources/ComposeUI/ViewController.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// ViewController.swift -// ComposeUI -// -// Created by Shadowfacts on 3/4/23. -// - -import SwiftUI -import Combine - -public protocol ViewController: ObservableObject { - associatedtype ContentView: View - - @MainActor - @ViewBuilder - var view: ContentView { get } -} - -public struct ControllerView: View { - @StateObject private var controller: Controller - - public init(controller: @escaping () -> Controller) { - self._controller = StateObject(wrappedValue: controller()) - } - - public var body: some View { - controller.view - .environmentObject(controller) - } -} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeToolbarView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeToolbarView.swift index 683004ae..3f093145 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeToolbarView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeToolbarView.swift @@ -178,11 +178,11 @@ private struct LocalOnlyButton: View { } private struct InsertEmojiButton: View { - @FocusedValue(\.composeInput) private var input + @FocusedInput private var input @ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22 var body: some View { - if input??.toolbarElements.contains(.emojiPicker) == true { + if input?.toolbarElements.contains(.emojiPicker) == true { Button(action: beginAutocompletingEmoji) { Label("Insert custom emoji", systemImage: "face.smiling") } @@ -195,16 +195,16 @@ private struct InsertEmojiButton: View { } private func beginAutocompletingEmoji() { - input??.beginAutocompletingEmoji() + input?.beginAutocompletingEmoji() } } private struct FormatButtons: View { - @FocusedValue(\.composeInput) private var input + @FocusedInput private var input @PreferenceObserving(\.$statusContentType) private var contentType var body: some View { - if let input = input.flatMap(\.self), + if let input, input.toolbarElements.contains(.formattingButtons), contentType != .plain { @@ -237,6 +237,16 @@ 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 { // ComposeToolbarView() //} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift index b70b4a7d..68b60969 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift @@ -31,6 +31,7 @@ public struct ComposeView: View { let mastodonController: any ComposeMastodonContext let currentAccount: (any AccountProtocol)? let config: ComposeUIConfig + @FocusState private var focusedField: FocusableField? public init( state: ComposeViewState, @@ -49,10 +50,12 @@ public struct ComposeView: View { draft: state.draft, mastodonController: mastodonController, state: state, - setDraft: self.setDraft + setDraft: self.setDraft, + focusedField: $focusedField ) .environment(\.composeUIConfig, config) .environment(\.currentAccount, currentAccount) + .injectInputAccessoryHost(state: state, mastodonController: mastodonController, focusedField: $focusedField) .onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext), perform: self.managedObjectsDidChange) } @@ -82,8 +85,8 @@ private struct ComposeViewBody: View { let mastodonController: any ComposeMastodonContext @ObservedObject var state: ComposeViewState let setDraft: (Draft) -> Void + @FocusState.Binding var focusedField: FocusableField? @State private var postError: PostService.Error? - @FocusState private var focusedField: FocusableField? @State private var isShowingDrafts = false @State private var isDismissing = false @State private var userConfirmedDelete = false @@ -154,11 +157,11 @@ private struct ComposeViewBody: View { .overlay(alignment: .bottom, content: { // This needs to be in an overlay, ignoring the keyboard safe area // doesn't work with the safeAreaInset modifier. - if config.showToolbar { + if config.showToolbar, + focusedField == nil { toolbarView .frame(maxHeight: .infinity, alignment: .bottom) - // TODO: use a input accessory view (controller) for the toolbar -// .ignoresSafeArea(.keyboard) + .modifier(IgnoreKeyboardSafeAreaIfUsingInputAccessory()) .transition(.move(edge: .bottom)) .animation(.snappy, value: config.showToolbar) } @@ -295,6 +298,119 @@ private struct ToolbarSafeAreaInsetModifier: ViewModifier { } #endif +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.Binding + ) -> some View { + if #available(iOS 16.0, *) { + self.modifier(InputAccessoryHostInjector(state: state, mastodonController: mastodonController, focusedField: focusedField)) + } 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.Binding + ) { + self._factory = StateObject(wrappedValue: ViewFactory(state: state, mastodonController: mastodonController, focusedField: focusedField)) + } + + func body(content: Content) -> some View { + content + .environment(\.inputAccessoryToolbarHost, factory.view) + .onChange(of: ComposeInputEquatableBox(input: composeInput ?? nil)) { newValue in + factory.focusedInput = newValue.input ?? nil + } + } + + @MainActor + private class ViewFactory: ObservableObject { + let view: UIView + @MutableObservableBox var focusedInput: (any ComposeInput)? + + init( + state: ComposeViewState, + mastodonController: any ComposeMastodonContext, + focusedField: FocusState.Binding + ) { + self._focusedInput = MutableObservableBox(wrappedValue: nil) + let view = InputAccessoryToolbarView(state: state, mastodonController: mastodonController, focusedField: focusedField, focusedInputBox: _focusedInput) + let controller = UIHostingController(rootView: view) + controller.sizingOptions = .intrinsicContentSize + controller.view.autoresizingMask = .flexibleHeight + self.view = controller.view + } + } +} + +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)?> + @PreferenceObserving(\.$accentColor) private var accentColor + + init( + state: ComposeViewState, + mastodonController: any ComposeMastodonContext, + focusedField: FocusState.Binding, + focusedInputBox: MutableObservableBox<(any ComposeInput)?> + ) { + self.state = state + self.mastodonController = mastodonController + self._focusedField = focusedField + self.focusedInputBox = focusedInputBox + } + + var body: some View { + ComposeToolbarView(draft: state.draft, mastodonController: mastodonController, focusedField: $focusedField) + .environment(\.toolbarInjectedFocusedInputBox, focusedInputBox) + .tint(accentColor.color.map(Color.init(uiColor:))) + } +} + //#Preview { // ComposeView() //} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/EmojiTextField.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/EmojiTextField.swift index 7f7f1f34..f09c59a8 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/EmojiTextField.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/EmojiTextField.swift @@ -13,6 +13,7 @@ struct EmojiTextField: UIViewRepresentable { @Environment(\.composeUIConfig.fillColor) private var fillColor @Environment(\.colorScheme) private var colorScheme @Environment(\.composeInputBox) private var inputBox + @Environment(\.inputAccessoryToolbarHost) private var inputAccessoryToolbarHost @Binding var text: String let placeholder: String @@ -64,6 +65,10 @@ struct EmojiTextField: UIViewRepresentable { #if !os(visionOS) uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground #endif + + if uiView.inputAccessoryView !== inputAccessoryToolbarHost { + uiView.inputAccessoryView = inputAccessoryToolbarHost + } } func makeCoordinator() -> Coordinator { diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/NewMainTextView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/NewMainTextView.swift index eccd7f77..8335f2d9 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/NewMainTextView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/NewMainTextView.swift @@ -41,6 +41,7 @@ private struct NewMainTextViewRepresentable: UIViewRepresentable { @PreferenceObserving(\.$useTwitterKeyboard) private var useTwitterKeyboard @Environment(\.composeUIConfig.textSelectionStartsAtBeginning) private var textSelectionStartsAtBeginning @PreferenceObserving(\.$statusContentType) private var statusContentType + @Environment(\.inputAccessoryToolbarHost) private var inputAccessoryToolbarHost func makeUIView(context: Context) -> WrappedTextView { // TODO: if we're not doing the pill background, reevaluate whether this version fork is necessary @@ -93,6 +94,10 @@ private struct NewMainTextViewRepresentable: UIViewRepresentable { // uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground // #endif + if uiView.inputAccessoryView !== inputAccessoryToolbarHost { + uiView.inputAccessoryView = inputAccessoryToolbarHost + } + // Trying to set this with the @FocusState binding in onAppear results in the // keyboard not appearing until after the sheet presentation animation completes :/ if becomeFirstResponder { diff --git a/Tusker/Screens/Compose/ComposeHostingController.swift b/Tusker/Screens/Compose/ComposeHostingController.swift index f63e60b7..01fd9ea2 100644 --- a/Tusker/Screens/Compose/ComposeHostingController.swift +++ b/Tusker/Screens/Compose/ComposeHostingController.swift @@ -149,7 +149,7 @@ class ComposeHostingController: UIHostingController