From fb39a93569b3d78e8b4ace612365cfbbb708b2ec Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Mon, 3 Feb 2025 15:26:47 -0500 Subject: [PATCH] Re-add compose ducking --- .../Sources/ComposeUI/ComposeUIConfig.swift | 3 +- .../Sources/ComposeUI/Views/ComposeView.swift | 91 +++++++++++-------- .../DuckedPlaceholderViewController.swift | 8 +- Tusker/Scenes/ComposeSceneDelegate.swift | 22 ++--- .../Compose/ComposeHostingController.swift | 54 ++++++----- 5 files changed, 103 insertions(+), 75 deletions(-) diff --git a/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift b/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift index ee55e476..eb1d9e46 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift @@ -11,11 +11,12 @@ import PhotosUI import PencilKit import TuskerComponents +// Configuration/data injected from outside the compose UI. public struct ComposeUIConfig { // Config public var allowSwitchingDrafts = true public var textSelectionStartsAtBeginning = false - public var deleteDraftOnDisappear = true + public var showToolbar = true // Style public var backgroundColor = Color(uiColor: .systemBackground) diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift index ef36c929..b4afc4d7 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift @@ -9,33 +9,53 @@ import SwiftUI import CoreData 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 { - @State var draft: Draft + @ObservedObject var state: ComposeViewState let mastodonController: any ComposeMastodonContext let currentAccount: (any AccountProtocol)? let config: ComposeUIConfig public init( - initialDraft: Draft, + state: ComposeViewState, mastodonController: any ComposeMastodonContext, currentAccount: (any AccountProtocol)?, config: ComposeUIConfig ) { - self.draft = initialDraft + self.state = state self.mastodonController = mastodonController self.currentAccount = currentAccount self.config = config } public var body: some View { - ComposeViewBody(draft: draft, mastodonController: mastodonController, setDraft: self.setDraft) - .environment(\.composeUIConfig, config) - .environment(\.currentAccount, currentAccount) + ComposeViewBody( + draft: state.draft, + mastodonController: mastodonController, + state: state, + setDraft: self.setDraft + ) + .environment(\.composeUIConfig, config) + .environment(\.currentAccount, currentAccount) } private func setDraft(_ draft: Draft) { - let oldDraft = self.draft - self.draft = draft + let oldDraft = state.draft + state.draft = draft if oldDraft.hasContent { oldDraft.lastModified = Date() @@ -50,10 +70,9 @@ public struct ComposeView: View { private struct ComposeViewBody: View { @ObservedObject var draft: Draft let mastodonController: any ComposeMastodonContext + @ObservedObject var state: ComposeViewState let setDraft: (Draft) -> Void - @State private var poster: PostService? @State private var postError: PostService.Error? - @State private var didPostSuccessfully = false @FocusState private var focusedField: FocusableField? @State private var isShowingDrafts = false @State private var isDismissing = false @@ -108,19 +127,22 @@ private struct ComposeViewBody: View { #endif } .overlay(alignment: .top) { - if let poster { + if let poster = state.poster { PostProgressView(poster: poster) .frame(alignment: .top) } } #if !os(visionOS) .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 // doesn't work with the safeAreaInset modifier. - toolbarView - .frame(maxHeight: .infinity, alignment: .bottom) - .ignoresSafeArea(.keyboard) + if config.showToolbar { + toolbarView + .frame(maxHeight: .infinity, alignment: .bottom) + .ignoresSafeArea(.keyboard) + .transition(.move(edge: .bottom)) + .animation(.snappy, value: config.showToolbar) + } }) #endif // 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)) .navigationBarTitleDisplayMode(.inline) .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) ToolbarItem(placement: .bottomOrnament) { toolbarView @@ -158,7 +180,7 @@ private struct ComposeViewBody: View { private func deleteOrSaveDraft() { if isDismissing, - !draft.hasContent || didPostSuccessfully || userConfirmedDelete { + !draft.hasContent || state.didPostSuccessfully || userConfirmedDelete { DraftsPersistentContainer.shared.viewContext.delete(draft) } else { draft.lastModified = Date() @@ -173,19 +195,19 @@ private struct ComposeViewBody: View { } private func postStatus() async { - guard poster == nil, + guard !state.isPosting, draft.editedStatusID != nil || draft.hasContent else { return } let poster = PostService(mastodonController: mastodonController, config: config, draft: draft) - self.poster = poster + state.poster = poster do { try await poster.post() isDismissing = true - didPostSuccessfully = true + state.didPostSuccessfully = true // wait .25 seconds so the user can see the progress bar has completed try? await Task.sleep(nanoseconds: 250_000_000) @@ -195,16 +217,13 @@ private struct ComposeViewBody: View { config.dismiss(.post) } catch { self.postError = error - self.poster = nil + state.poster = nil } } } -private struct NavigationTitleModifier: ViewModifier { - let draft: Draft - let mastodonController: any ComposeMastodonContext - - private var navigationTitle: String { +extension ComposeView { + public static func navigationTitle(for draft: Draft, mastodonController: any ComposeMastodonContext) -> String { if let id = draft.inReplyToID, let status = mastodonController.fetchStatus(id: id) { return "Reply to @\(status.account.acct)" @@ -214,20 +233,16 @@ private struct NavigationTitleModifier: ViewModifier { return "New Post" } } - - func body(content: Content) -> some View { - let title = navigationTitle - content - .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() +private struct NavigationTitleModifier: ViewModifier { + let draft: Draft + let mastodonController: any ComposeMastodonContext + + func body(content: Content) -> some View { + let title = ComposeView.navigationTitle(for: draft, mastodonController: mastodonController) + content + .navigationTitle(title) } } diff --git a/Packages/Duckable/Sources/Duckable/DuckedPlaceholderViewController.swift b/Packages/Duckable/Sources/Duckable/DuckedPlaceholderViewController.swift index d1390a71..f32689b3 100644 --- a/Packages/Duckable/Sources/Duckable/DuckedPlaceholderViewController.swift +++ b/Packages/Duckable/Sources/Duckable/DuckedPlaceholderViewController.swift @@ -14,6 +14,8 @@ class DuckedPlaceholderViewController: UIViewController { var topConstraint: NSLayoutConstraint! + private var titleObservation: NSKeyValueObservation? + init(for duckableViewController: DuckableViewController, owner: DuckableContainerViewController) { self.owner = owner @@ -21,8 +23,12 @@ class DuckedPlaceholderViewController: UIViewController { let item = UINavigationItem() item.title = duckableViewController.navigationItem.title - item.titleView = duckableViewController.navigationItem.titleView + assert(duckableViewController.navigationItem.titleView == nil) navBar.setItems([item], animated: false) + + titleObservation = duckableViewController.navigationItem.observe(\.title, changeHandler: { _, _ in + item.title = duckableViewController.navigationItem.title + }) } required init?(coder: NSCoder) { diff --git a/Tusker/Scenes/ComposeSceneDelegate.swift b/Tusker/Scenes/ComposeSceneDelegate.swift index efb78920..8664bee1 100644 --- a/Tusker/Scenes/ComposeSceneDelegate.swift +++ b/Tusker/Scenes/ComposeSceneDelegate.swift @@ -68,11 +68,10 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg window!.rootViewController = composeVC window!.makeKeyAndVisible() - // TODO: get the title out here somehow -// updateTitle(draft: composeVC.controller.draft) -// composeVC.controller.$draft -// .sink { [unowned self] in self.updateTitle(draft: $0) } -// .store(in: &cancellables) + updateTitle(draft: composeVC.state.draft) + composeVC.state.$draft + .sink { [unowned self] in self.updateTitle(draft: $0) } + .store(in: &cancellables) NotificationCenter.default.addObserver(self, selector: #selector(themePrefChanged), name: .themePreferenceChanged, object: nil) themePrefChanged() @@ -81,13 +80,12 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg func sceneWillResignActive(_ scene: UIScene) { DraftsPersistentContainer.shared.save() - // TODO: update user activity -// if let window = window, -// let nav = window.rootViewController as? UINavigationController, -// let compose = nav.topViewController as? ComposeHostingController, -// !compose.controller.didPostSuccessfully { -// scene.userActivity = UserActivityManager.editDraftActivity(id: compose.controller.draft.id, accountID: scene.session.mastodonController!.accountInfo!.id) -// } + if let window = window, + let nav = window.rootViewController as? UINavigationController, + let composeVC = nav.topViewController as? ComposeHostingController, + !composeVC.state.didPostSuccessfully { + scene.userActivity = UserActivityManager.editDraftActivity(id: composeVC.state.draft.id, accountID: scene.session.mastodonController!.accountInfo!.id) + } } func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? { diff --git a/Tusker/Screens/Compose/ComposeHostingController.swift b/Tusker/Screens/Compose/ComposeHostingController.swift index 55402403..ee3a08bc 100644 --- a/Tusker/Screens/Compose/ComposeHostingController.swift +++ b/Tusker/Screens/Compose/ComposeHostingController.swift @@ -36,6 +36,8 @@ class ComposeHostingController: UIHostingController, currentAccount: ObservableObjectBox) { - self.initialDraft = initialDraft + fileprivate init( + state: ComposeViewState, + mastodonController: MastodonController, + config: ObservableObjectBox, + currentAccount: ObservableObjectBox + ) { + self.state = state self.mastodonController = mastodonController self._config = ObservedObject(wrappedValue: config) self._currentAccount = ObservedObject(wrappedValue: currentAccount) @@ -156,7 +170,7 @@ class ComposeHostingController: UIHostingController: ObservableObject { #if canImport(Duckable) extension ComposeHostingController: DuckableViewController { func duckableViewControllerShouldDuck() -> DuckAttemptAction { -// if controller.isPosting { -// return .block -// } else if controller.draft.hasContent { -// return .duck -// } else { -// return .dismiss -// } - // TODO: ducking - return .dismiss + if state.isPosting { + return .block + } else if state.draft.hasContent { + return .duck + } else { + return .dismiss + } } func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) { - config.deleteDraftOnDisappear = false - - // TODO: figure out if this is still necessary -// withAnimation(.linear(duration: duration).delay(delay)) { -// controller.showToolbar = false -// } + navigationItem.title = ComposeView.navigationTitle(for: state.draft, mastodonController: mastodonController) + config.showToolbar = false } func duckableViewControllerDidFinishAnimatingDuck() { -// controller.showToolbar = true + config.showToolbar = true } } #endif