Use input accessory view for compose toolbar
This commit is contained in:
parent
964cef0ff5
commit
dc7eaf5ada
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
@ -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()
|
||||||
//}
|
//}
|
||||||
|
@ -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()
|
||||||
//}
|
//}
|
||||||
|
@ -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 {
|
||||||
|
@ -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 {
|
||||||
|
@ -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"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user