Re-add compose ducking
This commit is contained in:
parent
99a12c58de
commit
fb39a93569
@ -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)
|
||||||
|
@ -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(
|
||||||
.environment(\.composeUIConfig, config)
|
draft: state.draft,
|
||||||
.environment(\.currentAccount, currentAccount)
|
mastodonController: mastodonController,
|
||||||
|
state: state,
|
||||||
|
setDraft: self.setDraft
|
||||||
|
)
|
||||||
|
.environment(\.composeUIConfig, config)
|
||||||
|
.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.
|
||||||
toolbarView
|
if config.showToolbar {
|
||||||
.frame(maxHeight: .infinity, alignment: .bottom)
|
toolbarView
|
||||||
.ignoresSafeArea(.keyboard)
|
.frame(maxHeight: .infinity, alignment: .bottom)
|
||||||
|
.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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
private struct NavigationTitleModifier: ViewModifier {
|
||||||
public struct NavigationTitlePreferenceKey: PreferenceKey {
|
let draft: Draft
|
||||||
public static var defaultValue: String? { nil }
|
let mastodonController: any ComposeMastodonContext
|
||||||
public static func reduce(value: inout String?, nextValue: () -> String?) {
|
|
||||||
value = value ?? nextValue()
|
func body(content: Content) -> some View {
|
||||||
|
let title = ComposeView.navigationTitle(for: draft, mastodonController: mastodonController)
|
||||||
|
content
|
||||||
|
.navigationTitle(title)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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) {
|
||||||
|
@ -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? {
|
||||||
|
@ -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
|
return .dismiss
|
||||||
// }
|
}
|
||||||
// TODO: ducking
|
|
||||||
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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user