Use input accessory view for compose toolbar

This commit is contained in:
Shadowfacts 2025-02-25 23:41:39 -05:00
parent 964cef0ff5
commit dc7eaf5ada
7 changed files with 175 additions and 41 deletions

View File

@ -70,3 +70,31 @@ struct FocusedInputModifier: ViewModifier {
.focusedValue(\.composeInput, box.wrappedValue) .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()
}
}
}
}

View File

@ -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<Controller: ViewController>: 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)
}
}

View File

@ -178,11 +178,11 @@ private struct LocalOnlyButton: View {
} }
private struct InsertEmojiButton: View { private struct InsertEmojiButton: View {
@FocusedValue(\.composeInput) private var input @FocusedInput 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")
} }
@ -195,16 +195,16 @@ private struct InsertEmojiButton: View {
} }
private func beginAutocompletingEmoji() { private func beginAutocompletingEmoji() {
input??.beginAutocompletingEmoji() input?.beginAutocompletingEmoji()
} }
} }
private struct FormatButtons: View { private struct FormatButtons: View {
@FocusedValue(\.composeInput) private var input @FocusedInput private var input
@PreferenceObserving(\.$statusContentType) private var contentType @PreferenceObserving(\.$statusContentType) private var contentType
var body: some View { var body: some View {
if let input = input.flatMap(\.self), if let input,
input.toolbarElements.contains(.formattingButtons), input.toolbarElements.contains(.formattingButtons),
contentType != .plain { 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 { //#Preview {
// ComposeToolbarView() // ComposeToolbarView()
//} //}

View File

@ -31,6 +31,7 @@ public struct ComposeView: View {
let mastodonController: any ComposeMastodonContext let mastodonController: any ComposeMastodonContext
let currentAccount: (any AccountProtocol)? let currentAccount: (any AccountProtocol)?
let config: ComposeUIConfig let config: ComposeUIConfig
@FocusState private var focusedField: FocusableField?
public init( public init(
state: ComposeViewState, state: ComposeViewState,
@ -49,10 +50,12 @@ public struct ComposeView: View {
draft: state.draft, draft: state.draft,
mastodonController: mastodonController, mastodonController: mastodonController,
state: state, state: state,
setDraft: self.setDraft setDraft: self.setDraft,
focusedField: $focusedField
) )
.environment(\.composeUIConfig, config) .environment(\.composeUIConfig, config)
.environment(\.currentAccount, currentAccount) .environment(\.currentAccount, currentAccount)
.injectInputAccessoryHost(state: state, mastodonController: mastodonController, focusedField: $focusedField)
.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)
} }
@ -82,8 +85,8 @@ private struct ComposeViewBody: View {
let mastodonController: any ComposeMastodonContext let mastodonController: any ComposeMastodonContext
@ObservedObject var state: ComposeViewState @ObservedObject var state: ComposeViewState
let setDraft: (Draft) -> Void let setDraft: (Draft) -> Void
@FocusState.Binding var focusedField: FocusableField?
@State private var postError: PostService.Error? @State private var postError: PostService.Error?
@FocusState private var focusedField: FocusableField?
@State private var isShowingDrafts = false @State private var isShowingDrafts = false
@State private var isDismissing = false @State private var isDismissing = false
@State private var userConfirmedDelete = false @State private var userConfirmedDelete = false
@ -154,11 +157,11 @@ private struct ComposeViewBody: View {
.overlay(alignment: .bottom, content: { .overlay(alignment: .bottom, content: {
// This needs to be in an overlay, ignoring the keyboard safe area // This needs to be in an overlay, ignoring the keyboard safe area
// doesn't work with the safeAreaInset modifier. // doesn't work with the safeAreaInset modifier.
if config.showToolbar { if config.showToolbar,
focusedField == nil {
toolbarView toolbarView
.frame(maxHeight: .infinity, alignment: .bottom) .frame(maxHeight: .infinity, alignment: .bottom)
// TODO: use a input accessory view (controller) for the toolbar .modifier(IgnoreKeyboardSafeAreaIfUsingInputAccessory())
// .ignoresSafeArea(.keyboard)
.transition(.move(edge: .bottom)) .transition(.move(edge: .bottom))
.animation(.snappy, value: config.showToolbar) .animation(.snappy, value: config.showToolbar)
} }
@ -295,6 +298,119 @@ private struct ToolbarSafeAreaInsetModifier: ViewModifier {
} }
#endif #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<FocusableField?>.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<FocusableField?>.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<FocusableField?>.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<FocusableField?>.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 { //#Preview {
// ComposeView() // ComposeView()
//} //}

View File

@ -13,6 +13,7 @@ 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,6 +65,10 @@ struct EmojiTextField: UIViewRepresentable {
#if !os(visionOS) #if !os(visionOS)
uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground
#endif #endif
if uiView.inputAccessoryView !== inputAccessoryToolbarHost {
uiView.inputAccessoryView = inputAccessoryToolbarHost
}
} }
func makeCoordinator() -> Coordinator { func makeCoordinator() -> Coordinator {

View File

@ -41,6 +41,7 @@ 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
@ -93,6 +94,10 @@ private struct NewMainTextViewRepresentable: UIViewRepresentable {
// uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground // uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground
// #endif // #endif
if uiView.inputAccessoryView !== inputAccessoryToolbarHost {
uiView.inputAccessoryView = inputAccessoryToolbarHost
}
// 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 {

View File

@ -149,7 +149,7 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
case .edit(let status), .post(let status): case .edit(let status), .post(let status):
if let presentingViewController, if let presentingViewController,
let host = findNavDelegate(in: presentingViewController) { let host = findNavDelegate(in: presentingViewController) {
mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status) _ = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status)
var config = ToastConfiguration(title: "Posted Successfully") var config = ToastConfiguration(title: "Posted Successfully")
config.actionTitle = "View" config.actionTitle = "View"
config.systemImageName = "checkmark" config.systemImageName = "checkmark"