State restoration for presented and edited drafts

Closes #270
This commit is contained in:
Shadowfacts 2022-11-28 15:21:05 -05:00
parent 3e5a3c81b5
commit 1e950b5ccb
8 changed files with 128 additions and 37 deletions

View File

@ -29,11 +29,11 @@ public protocol DuckableViewControllerDelegate: AnyObject {
extension UIViewController { extension UIViewController {
@available(iOS 16.0, *) @available(iOS 16.0, *)
public func presentDuckable(_ viewController: DuckableViewController) -> Bool { public func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool = false) -> Bool {
var cur: UIViewController? = self var cur: UIViewController? = self
while let vc = cur { while let vc = cur {
if let container = vc as? DuckableContainerViewController { if let container = vc as? DuckableContainerViewController {
container.presentDuckable(viewController, animated: true, completion: nil) container.presentDuckable(viewController, animated: animated, isDucked: isDucked, completion: nil)
return true return true
} else { } else {
cur = vc.parent cur = vc.parent

View File

@ -17,6 +17,14 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
private var bottomConstraint: NSLayoutConstraint! private var bottomConstraint: NSLayoutConstraint!
private(set) var state = State.idle private(set) var state = State.idle
public var duckedViewController: DuckableViewController? {
if case .ducked(let vc, placeholder: _) = state {
return vc
} else {
return nil
}
}
public init(child: UIViewController) { public init(child: UIViewController) {
self.child = child self.child = child
@ -50,7 +58,7 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
]) ])
} }
func presentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) { func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool, completion: (() -> Void)?) {
guard case .idle = state else { guard case .idle = state else {
if animated, if animated,
case .ducked(_, placeholder: let placeholder) = state { case .ducked(_, placeholder: let placeholder) = state {
@ -69,9 +77,14 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
} }
return return
} }
if isDucked {
state = .ducked(viewController, placeholder: createPlaceholderForDuckedViewController(viewController))
configureChildForDuckedPlaceholder()
} else {
state = .presentingDucked(viewController, isFirstPresentation: true) state = .presentingDucked(viewController, isFirstPresentation: true)
doPresentDuckable(viewController, animated: animated, completion: completion) doPresentDuckable(viewController, animated: animated, completion: completion)
} }
}
private func doPresentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) { private func doPresentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) {
viewController.duckableDelegate = self viewController.duckableDelegate = self
@ -79,9 +92,7 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
nav.modalPresentationStyle = .custom nav.modalPresentationStyle = .custom
nav.transitioningDelegate = self nav.transitioningDelegate = self
present(nav, animated: animated) { present(nav, animated: animated) {
self.bottomConstraint.isActive = false self.configureChildForDuckedPlaceholder()
self.bottomConstraint = self.child.view.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, constant: -detentHeight - 4)
self.bottomConstraint.isActive = true
completion?() completion?()
} }
} }
@ -127,10 +138,18 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
} }
let placeholder = createPlaceholderForDuckedViewController(viewController) let placeholder = createPlaceholderForDuckedViewController(viewController)
state = .ducked(viewController, placeholder: placeholder) state = .ducked(viewController, placeholder: placeholder)
configureChildForDuckedPlaceholder()
dismiss(animated: true)
}
private func configureChildForDuckedPlaceholder() {
bottomConstraint.isActive = false
bottomConstraint = child.view.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -detentHeight - 4)
bottomConstraint.isActive = true
child.view.layer.cornerRadius = duckedCornerRadius child.view.layer.cornerRadius = duckedCornerRadius
child.view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] child.view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
child.view.layer.masksToBounds = true child.view.layer.masksToBounds = true
dismiss(animated: true)
} }
@objc func unduckViewController() { @objc func unduckViewController() {
@ -191,7 +210,10 @@ extension DuckableContainerViewController: UIViewControllerTransitioningDelegate
@available(iOS 16.0, *) @available(iOS 16.0, *)
extension DuckableContainerViewController: UISheetPresentationControllerDelegate { extension DuckableContainerViewController: UISheetPresentationControllerDelegate {
public func presentationController(_ presentationController: UIPresentationController, willPresentWithAdaptiveStyle style: UIModalPresentationStyle, transitionCoordinator: UIViewControllerTransitionCoordinator?) { public func presentationController(_ presentationController: UIPresentationController, willPresentWithAdaptiveStyle style: UIModalPresentationStyle, transitionCoordinator: UIViewControllerTransitionCoordinator?) {
let snapshot = child.view.snapshotView(afterScreenUpdates: false)! guard let snapshot = child.view.snapshotView(afterScreenUpdates: false) else {
setOverrideTraitCollection(UITraitCollection(userInterfaceLevel: .elevated), forChild: child)
return
}
snapshot.translatesAutoresizingMaskIntoConstraints = false snapshot.translatesAutoresizingMaskIntoConstraints = false
self.view.addSubview(snapshot) self.view.addSubview(snapshot)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([

View File

@ -43,11 +43,11 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Wra
super.init(rootView: wrapper) super.init(rootView: wrapper)
self.uiState.delegate = self self.uiState.delegate = self
pasteConfiguration = UIPasteConfiguration(forAccepting: CompositionAttachment.self) pasteConfiguration = UIPasteConfiguration(forAccepting: CompositionAttachment.self)
userActivity = UserActivityManager.newPostActivity(accountID: mastodonController.accountInfo!.id) userActivity = UserActivityManager.newPostActivity(accountID: mastodonController.accountInfo!.id)
updateNavigationTitle(draft: uiState.draft)
self.uiState.$draft self.uiState.$draft
.flatMap(\.objectWillChange) .flatMap(\.objectWillChange)
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.global(qos: .utility)) .debounce(for: .milliseconds(250), scheduler: DispatchQueue.global(qos: .utility))
@ -55,12 +55,27 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Wra
DraftsManager.save() DraftsManager.save()
} }
.store(in: &cancellables) .store(in: &cancellables)
self.uiState.$draft
.sink { [unowned self] draft in
self.updateNavigationTitle(draft: draft)
}
.store(in: &cancellables)
} }
required init?(coder aDecoder: NSCoder) { required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
private func updateNavigationTitle(draft: Draft) {
if let id = draft.inReplyToID,
let status = mastodonController.persistentContainer.status(for: id) {
navigationItem.title = "Reply to @\(status.account.acct)"
} else {
navigationItem.title = "New Post"
}
}
override func viewWillDisappear(_ animated: Bool) { override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated) super.viewWillDisappear(animated)

View File

@ -107,7 +107,6 @@ struct ComposeView: View {
globalFrameOutsideList = frame globalFrameOutsideList = frame
} }
}) })
.navigationTitle(navTitle)
.sheet(isPresented: $uiState.isShowingDraftsList) { .sheet(isPresented: $uiState.isShowingDraftsList) {
DraftsView(currentDraft: draft, mastodonController: mastodonController) DraftsView(currentDraft: draft, mastodonController: mastodonController)
} }
@ -211,15 +210,6 @@ struct ComposeView: View {
}.frame(height: 50) }.frame(height: 50)
} }
private var navTitle: Text {
if let id = draft.inReplyToID,
let status = mastodonController.persistentContainer.status(for: id) {
return Text("Reply to @\(status.account.acct)")
} else {
return Text("New Post")
}
}
private var cancelButton: some View { private var cancelButton: some View {
Button(action: self.cancel) { Button(action: self.cancel) {
Text("Cancel") Text("Cancel")

View File

@ -12,10 +12,21 @@ import Duckable
@available(iOS 16.0, *) @available(iOS 16.0, *)
extension DuckableContainerViewController: TuskerRootViewController { extension DuckableContainerViewController: TuskerRootViewController {
func stateRestorationActivity() -> NSUserActivity? { func stateRestorationActivity() -> NSUserActivity? {
(child as? TuskerRootViewController)?.stateRestorationActivity() var activity = (child as? TuskerRootViewController)?.stateRestorationActivity()
if let compose = duckedViewController as? ComposeHostingController,
compose.draft.hasContent {
activity = UserActivityManager.addDuckedDraft(to: activity, draft: compose.draft)
}
return activity
} }
func restoreActivity(_ activity: NSUserActivity) { func restoreActivity(_ activity: NSUserActivity) {
if let draft = UserActivityManager.getDraft(from: activity),
let account = UserActivityManager.getAccount(from: activity) {
let mastodonController = MastodonController.getForAccount(account)
let compose = ComposeHostingController(draft: draft, mastodonController: mastodonController)
_ = presentDuckable(compose, animated: false, isDucked: true)
}
(child as? TuskerRootViewController)?.restoreActivity(activity) (child as? TuskerRootViewController)?.restoreActivity(activity)
} }

View File

@ -13,11 +13,14 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
weak var mastodonController: MastodonController! weak var mastodonController: MastodonController!
private var composePlaceholder: UIViewController! private var composePlaceholder: UIViewController!
private var fastAccountSwitcher: FastAccountSwitcherViewController!
private var fastAccountSwitcher: FastAccountSwitcherViewController!
private var fastSwitcherIndicator: FastAccountSwitcherIndicatorView! private var fastSwitcherIndicator: FastAccountSwitcherIndicatorView!
private var fastSwitcherConstraints: [NSLayoutConstraint] = [] private var fastSwitcherConstraints: [NSLayoutConstraint] = []
@available(iOS, obsoleted: 16.0)
private var draftToPresentOnAppear: Draft?
var selectedTab: Tab { var selectedTab: Tab {
return Tab(rawValue: selectedIndex)! return Tab(rawValue: selectedIndex)!
} }
@ -85,6 +88,11 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
override func viewDidAppear(_ animated: Bool) { override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated) super.viewDidAppear(animated)
stateRestorationLogger.info("MainTabBarViewController: viewDidAppear, selectedIndex=\(self.selectedIndex, privacy: .public)") stateRestorationLogger.info("MainTabBarViewController: viewDidAppear, selectedIndex=\(self.selectedIndex, privacy: .public)")
if let draftToPresentOnAppear {
self.draftToPresentOnAppear = nil
compose(editing: draftToPresentOnAppear, animated: true)
}
} }
override func viewDidLayoutSubviews() { override func viewDidLayoutSubviews() {
@ -235,15 +243,29 @@ extension MainTabBarViewController: TuskerNavigationDelegate {
extension MainTabBarViewController: TuskerRootViewController { extension MainTabBarViewController: TuskerRootViewController {
func stateRestorationActivity() -> NSUserActivity? { func stateRestorationActivity() -> NSUserActivity? {
let nav = viewController(for: .timelines) as! UINavigationController let nav = viewController(for: .timelines) as! UINavigationController
guard let timelinePages = nav.viewControllers.first as? TimelinesPageViewController, var activity: NSUserActivity?
let timelineVC = timelinePages.pageControllers[timelinePages.currentIndex] as? TimelineViewController else { if let timelinePages = nav.viewControllers.first as? TimelinesPageViewController,
stateRestorationLogger.fault("MainTabBarViewController: Unable to create state restoration actiivty, couldn't find timeline/page VC") let timelineVC = timelinePages.pageControllers[timelinePages.currentIndex] as? TimelineViewController {
return nil activity = timelineVC.stateRestorationActivity()
} else {
stateRestorationLogger.fault("MainTabBarViewController: Unable to create state restoration activity, couldn't find timeline/page VC")
} }
return timelineVC.stateRestorationActivity() if let presentedNav = presentedViewController as? UINavigationController,
let compose = presentedNav.viewControllers.first as? ComposeHostingController {
activity = UserActivityManager.addEditedDraft(to: activity, draft: compose.draft)
}
return activity
} }
func restoreActivity(_ activity: NSUserActivity) { func restoreActivity(_ activity: NSUserActivity) {
func restoreEditedDraft() {
// on iOS 16+, this is handled by the duckable container
if #unavailable(iOS 16.0),
let draft = UserActivityManager.getDraft(from: activity) {
draftToPresentOnAppear = draft
}
}
if activity.activityType == UserActivityType.showTimeline.rawValue { if activity.activityType == UserActivityType.showTimeline.rawValue {
let nav = viewController(for: .timelines) as! UINavigationController let nav = viewController(for: .timelines) as! UINavigationController
guard let timelinePages = nav.viewControllers.first as? TimelinesPageViewController, guard let timelinePages = nav.viewControllers.first as? TimelinesPageViewController,
@ -252,6 +274,10 @@ extension MainTabBarViewController: TuskerRootViewController {
return return
} }
timelineVC.restoreActivity(activity) timelineVC.restoreActivity(activity)
restoreEditedDraft()
} else if activity.activityType == UserActivityType.newPost.rawValue {
restoreEditedDraft()
return
} else { } else {
stateRestorationLogger.fault("MainTabBarViewController: Unable to restore activity of unexpected type \(activity.activityType, privacy: .public)") stateRestorationLogger.fault("MainTabBarViewController: Unable to restore activity of unexpected type \(activity.activityType, privacy: .public)")
} }

View File

@ -91,10 +91,37 @@ class UserActivityManager {
return activity return activity
} }
static func addDuckedDraft(to activity: NSUserActivity?, draft: Draft) -> NSUserActivity {
if let activity {
activity.addUserInfoEntries(from: [
"duckedDraftID": draft.id.uuidString
])
return activity
} else {
return editDraftActivity(id: draft.id, accountID: draft.accountID)
}
}
static func addEditedDraft(to activity: NSUserActivity?, draft: Draft) -> NSUserActivity {
if let activity {
activity.addUserInfoEntries(from: [
"editedDraftID": draft.id.uuidString
])
return activity
} else {
return editDraftActivity(id: draft.id, accountID: draft.accountID)
}
}
static func getDraft(from activity: NSUserActivity) -> Draft? { static func getDraft(from activity: NSUserActivity) -> Draft? {
guard activity.activityType == UserActivityType.newPost.rawValue, let idStr: String?
let str = activity.userInfo?["draftID"] as? String, if activity.activityType == UserActivityType.newPost.rawValue {
let uuid = UUID(uuidString: str) else { idStr = activity.userInfo?["draftID"] as? String
} else {
idStr = activity.userInfo?["duckedDraftID"] as? String ?? activity.userInfo?["editedDraftID"] as? String
}
guard let idStr,
let uuid = UUID(uuidString: idStr) else {
return nil return nil
} }
return DraftsManager.shared.getBy(id: uuid) return DraftsManager.shared.getBy(id: uuid)

View File

@ -88,7 +88,7 @@ extension TuskerNavigationDelegate {
show(conversation(mainStatusID: statusID, state: state), sender: self) show(conversation(mainStatusID: statusID, state: state), sender: self)
} }
func compose(editing draft: Draft) { func compose(editing draft: Draft, animated: Bool = true) {
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
let compose = UserActivityManager.editDraftActivity(id: draft.id, accountID: apiController.accountInfo!.id) let compose = UserActivityManager.editDraftActivity(id: draft.id, accountID: apiController.accountInfo!.id)
let options = UIWindowScene.ActivationRequestOptions() let options = UIWindowScene.ActivationRequestOptions()
@ -97,20 +97,20 @@ extension TuskerNavigationDelegate {
} else { } else {
let compose = ComposeHostingController(draft: draft, mastodonController: apiController) let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
if #available(iOS 16.0, *), if #available(iOS 16.0, *),
presentDuckable(compose) { presentDuckable(compose, animated: animated) {
return return
} else { } else {
let compose = ComposeHostingController(draft: draft, mastodonController: apiController) let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
let nav = UINavigationController(rootViewController: compose) let nav = UINavigationController(rootViewController: compose)
nav.presentationController?.delegate = compose nav.presentationController?.delegate = compose
present(nav, animated: true) present(nav, animated: animated)
} }
} }
} }
func compose(inReplyToID: String? = nil, mentioningAcct: String? = nil) { func compose(inReplyToID: String? = nil, mentioningAcct: String? = nil, animated: Bool = true) {
let draft = apiController.createDraft(inReplyToID: inReplyToID, mentioningAcct: mentioningAcct) let draft = apiController.createDraft(inReplyToID: inReplyToID, mentioningAcct: mentioningAcct)
compose(editing: draft) compose(editing: draft, animated: animated)
} }
func loadingLargeImage(url: URL, cache: ImageCache, description: String?, animatingFrom sourceView: UIImageView) -> LoadingLargeImageViewController { func loadingLargeImage(url: URL, cache: ImageCache, description: String?, animatingFrom sourceView: UIImageView) -> LoadingLargeImageViewController {