Re-add compose ducking

This commit is contained in:
Shadowfacts 2025-02-03 15:26:47 -05:00
parent 99a12c58de
commit fb39a93569
5 changed files with 103 additions and 75 deletions

View File

@ -11,11 +11,12 @@ import PhotosUI
import PencilKit import PencilKit
import TuskerComponents import TuskerComponents
// Configuration/data injected from outside the compose UI.
public struct ComposeUIConfig { public struct ComposeUIConfig {
// Config // Config
public var allowSwitchingDrafts = true public var allowSwitchingDrafts = true
public var textSelectionStartsAtBeginning = false public var textSelectionStartsAtBeginning = false
public var deleteDraftOnDisappear = true public var showToolbar = true
// Style // Style
public var backgroundColor = Color(uiColor: .systemBackground) public var backgroundColor = Color(uiColor: .systemBackground)

View File

@ -9,33 +9,53 @@ import SwiftUI
import CoreData import CoreData
import Pachyderm import Pachyderm
// State owned by the compose UI but that needs to be accessible from outside.
public final class ComposeViewState: ObservableObject {
@Published var poster: PostService?
@Published public internal(set) var draft: Draft
@Published public internal(set) var didPostSuccessfully = false
public var isPosting: Bool {
poster != nil
}
public init(draft: Draft) {
self.draft = draft
}
}
public struct ComposeView: View { public struct ComposeView: View {
@State var draft: Draft @ObservedObject var state: ComposeViewState
let mastodonController: any ComposeMastodonContext let mastodonController: any ComposeMastodonContext
let currentAccount: (any AccountProtocol)? let currentAccount: (any AccountProtocol)?
let config: ComposeUIConfig let config: ComposeUIConfig
public init( public init(
initialDraft: Draft, state: ComposeViewState,
mastodonController: any ComposeMastodonContext, mastodonController: any ComposeMastodonContext,
currentAccount: (any AccountProtocol)?, currentAccount: (any AccountProtocol)?,
config: ComposeUIConfig config: ComposeUIConfig
) { ) {
self.draft = initialDraft self.state = state
self.mastodonController = mastodonController self.mastodonController = mastodonController
self.currentAccount = currentAccount self.currentAccount = currentAccount
self.config = config self.config = config
} }
public var body: some View { public var body: some View {
ComposeViewBody(draft: draft, mastodonController: mastodonController, setDraft: self.setDraft) ComposeViewBody(
draft: state.draft,
mastodonController: mastodonController,
state: state,
setDraft: self.setDraft
)
.environment(\.composeUIConfig, config) .environment(\.composeUIConfig, config)
.environment(\.currentAccount, currentAccount) .environment(\.currentAccount, currentAccount)
} }
private func setDraft(_ draft: Draft) { private func setDraft(_ draft: Draft) {
let oldDraft = self.draft let oldDraft = state.draft
self.draft = draft state.draft = draft
if oldDraft.hasContent { if oldDraft.hasContent {
oldDraft.lastModified = Date() oldDraft.lastModified = Date()
@ -50,10 +70,9 @@ public struct ComposeView: View {
private struct ComposeViewBody: View { private struct ComposeViewBody: View {
@ObservedObject var draft: Draft @ObservedObject var draft: Draft
let mastodonController: any ComposeMastodonContext let mastodonController: any ComposeMastodonContext
@ObservedObject var state: ComposeViewState
let setDraft: (Draft) -> Void let setDraft: (Draft) -> Void
@State private var poster: PostService?
@State private var postError: PostService.Error? @State private var postError: PostService.Error?
@State private var didPostSuccessfully = false
@FocusState private var focusedField: FocusableField? @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
@ -108,19 +127,22 @@ private struct ComposeViewBody: View {
#endif #endif
} }
.overlay(alignment: .top) { .overlay(alignment: .top) {
if let poster { if let poster = state.poster {
PostProgressView(poster: poster) PostProgressView(poster: poster)
.frame(alignment: .top) .frame(alignment: .top)
} }
} }
#if !os(visionOS) #if !os(visionOS)
.overlay(alignment: .bottom, content: { .overlay(alignment: .bottom, content: {
// TODO: during ducking animation, toolbar should move off the botto edge
// 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 {
toolbarView toolbarView
.frame(maxHeight: .infinity, alignment: .bottom) .frame(maxHeight: .infinity, alignment: .bottom)
.ignoresSafeArea(.keyboard) .ignoresSafeArea(.keyboard)
.transition(.move(edge: .bottom))
.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
@ -129,7 +151,7 @@ private struct ComposeViewBody: View {
.modifier(NavigationTitleModifier(draft: draft, mastodonController: mastodonController)) .modifier(NavigationTitleModifier(draft: draft, mastodonController: mastodonController))
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ComposeNavigationBarActions(draft: draft, isShowingDrafts: $isShowingDrafts, isPosting: poster != nil, cancel: self.cancel(deleteDraft:), postStatus: self.postStatus) ComposeNavigationBarActions(draft: draft, isShowingDrafts: $isShowingDrafts, isPosting: state.isPosting, cancel: self.cancel(deleteDraft:), postStatus: self.postStatus)
#if os(visionOS) #if os(visionOS)
ToolbarItem(placement: .bottomOrnament) { ToolbarItem(placement: .bottomOrnament) {
toolbarView toolbarView
@ -158,7 +180,7 @@ private struct ComposeViewBody: View {
private func deleteOrSaveDraft() { private func deleteOrSaveDraft() {
if isDismissing, if isDismissing,
!draft.hasContent || didPostSuccessfully || userConfirmedDelete { !draft.hasContent || state.didPostSuccessfully || userConfirmedDelete {
DraftsPersistentContainer.shared.viewContext.delete(draft) DraftsPersistentContainer.shared.viewContext.delete(draft)
} else { } else {
draft.lastModified = Date() draft.lastModified = Date()
@ -173,19 +195,19 @@ private struct ComposeViewBody: View {
} }
private func postStatus() async { private func postStatus() async {
guard poster == nil, guard !state.isPosting,
draft.editedStatusID != nil || draft.hasContent else { draft.editedStatusID != nil || draft.hasContent else {
return return
} }
let poster = PostService(mastodonController: mastodonController, config: config, draft: draft) let poster = PostService(mastodonController: mastodonController, config: config, draft: draft)
self.poster = poster state.poster = poster
do { do {
try await poster.post() try await poster.post()
isDismissing = true isDismissing = true
didPostSuccessfully = true state.didPostSuccessfully = true
// wait .25 seconds so the user can see the progress bar has completed // wait .25 seconds so the user can see the progress bar has completed
try? await Task.sleep(nanoseconds: 250_000_000) try? await Task.sleep(nanoseconds: 250_000_000)
@ -195,16 +217,13 @@ private struct ComposeViewBody: View {
config.dismiss(.post) config.dismiss(.post)
} catch { } catch {
self.postError = error self.postError = error
self.poster = nil state.poster = nil
} }
} }
} }
private struct NavigationTitleModifier: ViewModifier { extension ComposeView {
let draft: Draft public static func navigationTitle(for draft: Draft, mastodonController: any ComposeMastodonContext) -> String {
let mastodonController: any ComposeMastodonContext
private var navigationTitle: String {
if let id = draft.inReplyToID, if let id = draft.inReplyToID,
let status = mastodonController.fetchStatus(id: id) { let status = mastodonController.fetchStatus(id: id) {
return "Reply to @\(status.account.acct)" return "Reply to @\(status.account.acct)"
@ -214,20 +233,16 @@ private struct NavigationTitleModifier: ViewModifier {
return "New Post" return "New Post"
} }
} }
}
private struct NavigationTitleModifier: ViewModifier {
let draft: Draft
let mastodonController: any ComposeMastodonContext
func body(content: Content) -> some View { func body(content: Content) -> some View {
let title = navigationTitle let title = ComposeView.navigationTitle(for: draft, mastodonController: mastodonController)
content content
.navigationTitle(title) .navigationTitle(title)
.preference(key: NavigationTitlePreferenceKey.self, value: title)
}
}
// Public preference so that the host can read the title.
public struct NavigationTitlePreferenceKey: PreferenceKey {
public static var defaultValue: String? { nil }
public static func reduce(value: inout String?, nextValue: () -> String?) {
value = value ?? nextValue()
} }
} }

View File

@ -14,6 +14,8 @@ class DuckedPlaceholderViewController: UIViewController {
var topConstraint: NSLayoutConstraint! var topConstraint: NSLayoutConstraint!
private var titleObservation: NSKeyValueObservation?
init(for duckableViewController: DuckableViewController, owner: DuckableContainerViewController) { init(for duckableViewController: DuckableViewController, owner: DuckableContainerViewController) {
self.owner = owner self.owner = owner
@ -21,8 +23,12 @@ class DuckedPlaceholderViewController: UIViewController {
let item = UINavigationItem() let item = UINavigationItem()
item.title = duckableViewController.navigationItem.title item.title = duckableViewController.navigationItem.title
item.titleView = duckableViewController.navigationItem.titleView assert(duckableViewController.navigationItem.titleView == nil)
navBar.setItems([item], animated: false) navBar.setItems([item], animated: false)
titleObservation = duckableViewController.navigationItem.observe(\.title, changeHandler: { _, _ in
item.title = duckableViewController.navigationItem.title
})
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {

View File

@ -68,11 +68,10 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg
window!.rootViewController = composeVC window!.rootViewController = composeVC
window!.makeKeyAndVisible() window!.makeKeyAndVisible()
// TODO: get the title out here somehow updateTitle(draft: composeVC.state.draft)
// updateTitle(draft: composeVC.controller.draft) composeVC.state.$draft
// composeVC.controller.$draft .sink { [unowned self] in self.updateTitle(draft: $0) }
// .sink { [unowned self] in self.updateTitle(draft: $0) } .store(in: &cancellables)
// .store(in: &cancellables)
NotificationCenter.default.addObserver(self, selector: #selector(themePrefChanged), name: .themePreferenceChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(themePrefChanged), name: .themePreferenceChanged, object: nil)
themePrefChanged() themePrefChanged()
@ -81,13 +80,12 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg
func sceneWillResignActive(_ scene: UIScene) { func sceneWillResignActive(_ scene: UIScene) {
DraftsPersistentContainer.shared.save() DraftsPersistentContainer.shared.save()
// TODO: update user activity if let window = window,
// if let window = window, let nav = window.rootViewController as? UINavigationController,
// let nav = window.rootViewController as? UINavigationController, let composeVC = nav.topViewController as? ComposeHostingController,
// let compose = nav.topViewController as? ComposeHostingController, !composeVC.state.didPostSuccessfully {
// !compose.controller.didPostSuccessfully { scene.userActivity = UserActivityManager.editDraftActivity(id: composeVC.state.draft.id, accountID: scene.session.mastodonController!.accountInfo!.id)
// scene.userActivity = UserActivityManager.editDraftActivity(id: compose.controller.draft.id, accountID: scene.session.mastodonController!.accountInfo!.id) }
// }
} }
func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? { func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {

View File

@ -36,6 +36,8 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
@ObservableObjectBox private var config: ComposeUIConfig @ObservableObjectBox private var config: ComposeUIConfig
@ObservableObjectBox private var currentAccount: AccountMO? @ObservableObjectBox private var currentAccount: AccountMO?
// Internal visibility so it can be accessed from ComposeSceneDelegate for the window title
let state: ComposeViewState
init(draft: Draft?, mastodonController: MastodonController) { init(draft: Draft?, mastodonController: MastodonController) {
let draft = draft ?? mastodonController.createDraft() let draft = draft ?? mastodonController.createDraft()
@ -43,8 +45,16 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
self.mastodonController = mastodonController self.mastodonController = mastodonController
self.config = ComposeUIConfig() self.config = ComposeUIConfig()
self.currentAccount = mastodonController.account self.currentAccount = mastodonController.account
let state = ComposeViewState(draft: draft)
self.state = state
super.init(rootView: View(initialDraft: draft, mastodonController: mastodonController, config: _config, currentAccount: _currentAccount)) let rootView = View(
state: state,
mastodonController: mastodonController,
config: _config,
currentAccount: _currentAccount
)
super.init(rootView: rootView)
self.updateConfig() self.updateConfig()
@ -53,8 +63,7 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
NotificationCenter.default.addObserver(self, selector: #selector(updateConfig), name: .preferencesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(updateConfig), name: .preferencesChanged, object: nil)
// set an initial title immediately, in case we're starting ducked // set an initial title immediately, in case we're starting ducked
// self.navigationItem.title = self.controller.navigationTitle self.navigationItem.title = ComposeView.navigationTitle(for: draft, mastodonController: mastodonController)
// TODO: get the navigation title from somewhere
mastodonController.$account mastodonController.$account
.sink { [unowned self] in .sink { [unowned self] in
@ -142,13 +151,18 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
} }
struct View: SwiftUI.View { struct View: SwiftUI.View {
let initialDraft: Draft
let mastodonController: MastodonController let mastodonController: MastodonController
@ObservedObject @ObservableObjectBox private var config: ComposeUIConfig @ObservedObject @ObservableObjectBox private var config: ComposeUIConfig
@ObservedObject @ObservableObjectBox private var currentAccount: AccountMO? @ObservedObject @ObservableObjectBox private var currentAccount: AccountMO?
let state: ComposeViewState
fileprivate init(initialDraft: Draft, mastodonController: MastodonController, config: ObservableObjectBox<ComposeUIConfig>, currentAccount: ObservableObjectBox<AccountMO?>) { fileprivate init(
self.initialDraft = initialDraft state: ComposeViewState,
mastodonController: MastodonController,
config: ObservableObjectBox<ComposeUIConfig>,
currentAccount: ObservableObjectBox<AccountMO?>
) {
self.state = state
self.mastodonController = mastodonController self.mastodonController = mastodonController
self._config = ObservedObject(wrappedValue: config) self._config = ObservedObject(wrappedValue: config)
self._currentAccount = ObservedObject(wrappedValue: currentAccount) self._currentAccount = ObservedObject(wrappedValue: currentAccount)
@ -156,7 +170,7 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
var body: some SwiftUI.View { var body: some SwiftUI.View {
ComposeView( ComposeView(
initialDraft: initialDraft, state: state,
mastodonController: mastodonController, mastodonController: mastodonController,
currentAccount: currentAccount, currentAccount: currentAccount,
config: config config: config
@ -183,28 +197,22 @@ private final class ObservableObjectBox<T>: ObservableObject {
#if canImport(Duckable) #if canImport(Duckable)
extension ComposeHostingController: DuckableViewController { extension ComposeHostingController: DuckableViewController {
func duckableViewControllerShouldDuck() -> DuckAttemptAction { func duckableViewControllerShouldDuck() -> DuckAttemptAction {
// if controller.isPosting { if state.isPosting {
// return .block return .block
// } else if controller.draft.hasContent { } else if state.draft.hasContent {
// return .duck return .duck
// } else { } else {
// return .dismiss
// }
// TODO: ducking
return .dismiss return .dismiss
} }
}
func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) { func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) {
config.deleteDraftOnDisappear = false navigationItem.title = ComposeView.navigationTitle(for: state.draft, mastodonController: mastodonController)
config.showToolbar = false
// TODO: figure out if this is still necessary
// withAnimation(.linear(duration: duration).delay(delay)) {
// controller.showToolbar = false
// }
} }
func duckableViewControllerDidFinishAnimatingDuck() { func duckableViewControllerDidFinishAnimatingDuck() {
// controller.showToolbar = true config.showToolbar = true
} }
} }
#endif #endif