From 1e950b5ccb87b8e8b968d15cf2669c1c7eac3774 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Mon, 28 Nov 2022 15:21:05 -0500 Subject: [PATCH] State restoration for presented and edited drafts Closes #270 --- Packages/Duckable/Sources/Duckable/API.swift | 4 +- .../DuckableContainerViewController.swift | 38 +++++++++++++++---- .../Compose/ComposeHostingController.swift | 19 +++++++++- Tusker/Screens/Compose/ComposeView.swift | 10 ----- Tusker/Screens/Main/Duckable+Root.swift | 13 ++++++- .../Main/MainTabBarViewController.swift | 38 ++++++++++++++++--- Tusker/Shortcuts/UserActivityManager.swift | 33 ++++++++++++++-- Tusker/TuskerNavigationDelegate.swift | 10 ++--- 8 files changed, 128 insertions(+), 37 deletions(-) diff --git a/Packages/Duckable/Sources/Duckable/API.swift b/Packages/Duckable/Sources/Duckable/API.swift index cddac680..b3d0ebca 100644 --- a/Packages/Duckable/Sources/Duckable/API.swift +++ b/Packages/Duckable/Sources/Duckable/API.swift @@ -29,11 +29,11 @@ public protocol DuckableViewControllerDelegate: AnyObject { extension UIViewController { @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 while let vc = cur { if let container = vc as? DuckableContainerViewController { - container.presentDuckable(viewController, animated: true, completion: nil) + container.presentDuckable(viewController, animated: animated, isDucked: isDucked, completion: nil) return true } else { cur = vc.parent diff --git a/Packages/Duckable/Sources/Duckable/DuckableContainerViewController.swift b/Packages/Duckable/Sources/Duckable/DuckableContainerViewController.swift index 45d192dd..32336b42 100644 --- a/Packages/Duckable/Sources/Duckable/DuckableContainerViewController.swift +++ b/Packages/Duckable/Sources/Duckable/DuckableContainerViewController.swift @@ -17,6 +17,14 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont private var bottomConstraint: NSLayoutConstraint! 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) { 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 { if animated, case .ducked(_, placeholder: let placeholder) = state { @@ -69,8 +77,13 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont } return } - state = .presentingDucked(viewController, isFirstPresentation: true) - doPresentDuckable(viewController, animated: animated, completion: completion) + if isDucked { + state = .ducked(viewController, placeholder: createPlaceholderForDuckedViewController(viewController)) + configureChildForDuckedPlaceholder() + } else { + state = .presentingDucked(viewController, isFirstPresentation: true) + doPresentDuckable(viewController, animated: animated, completion: completion) + } } private func doPresentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) { @@ -79,9 +92,7 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont nav.modalPresentationStyle = .custom nav.transitioningDelegate = self present(nav, animated: animated) { - self.bottomConstraint.isActive = false - self.bottomConstraint = self.child.view.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, constant: -detentHeight - 4) - self.bottomConstraint.isActive = true + self.configureChildForDuckedPlaceholder() completion?() } } @@ -127,10 +138,18 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont } let placeholder = createPlaceholderForDuckedViewController(viewController) 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.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] child.view.layer.masksToBounds = true - dismiss(animated: true) } @objc func unduckViewController() { @@ -191,7 +210,10 @@ extension DuckableContainerViewController: UIViewControllerTransitioningDelegate @available(iOS 16.0, *) extension DuckableContainerViewController: UISheetPresentationControllerDelegate { 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 self.view.addSubview(snapshot) NSLayoutConstraint.activate([ diff --git a/Tusker/Screens/Compose/ComposeHostingController.swift b/Tusker/Screens/Compose/ComposeHostingController.swift index 2238fc20..d2dd2509 100644 --- a/Tusker/Screens/Compose/ComposeHostingController.swift +++ b/Tusker/Screens/Compose/ComposeHostingController.swift @@ -43,11 +43,11 @@ class ComposeHostingController: UIHostingController 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) { + 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) } diff --git a/Tusker/Screens/Main/MainTabBarViewController.swift b/Tusker/Screens/Main/MainTabBarViewController.swift index b8163a6d..58b81f3c 100644 --- a/Tusker/Screens/Main/MainTabBarViewController.swift +++ b/Tusker/Screens/Main/MainTabBarViewController.swift @@ -13,11 +13,14 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate { weak var mastodonController: MastodonController! private var composePlaceholder: UIViewController! - private var fastAccountSwitcher: FastAccountSwitcherViewController! + private var fastAccountSwitcher: FastAccountSwitcherViewController! private var fastSwitcherIndicator: FastAccountSwitcherIndicatorView! private var fastSwitcherConstraints: [NSLayoutConstraint] = [] + @available(iOS, obsoleted: 16.0) + private var draftToPresentOnAppear: Draft? + var selectedTab: Tab { return Tab(rawValue: selectedIndex)! } @@ -85,6 +88,11 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) stateRestorationLogger.info("MainTabBarViewController: viewDidAppear, selectedIndex=\(self.selectedIndex, privacy: .public)") + + if let draftToPresentOnAppear { + self.draftToPresentOnAppear = nil + compose(editing: draftToPresentOnAppear, animated: true) + } } override func viewDidLayoutSubviews() { @@ -235,15 +243,29 @@ extension MainTabBarViewController: TuskerNavigationDelegate { extension MainTabBarViewController: TuskerRootViewController { func stateRestorationActivity() -> NSUserActivity? { let nav = viewController(for: .timelines) as! UINavigationController - guard let timelinePages = nav.viewControllers.first as? TimelinesPageViewController, - let timelineVC = timelinePages.pageControllers[timelinePages.currentIndex] as? TimelineViewController else { - stateRestorationLogger.fault("MainTabBarViewController: Unable to create state restoration actiivty, couldn't find timeline/page VC") - return nil + var activity: NSUserActivity? + if let timelinePages = nav.viewControllers.first as? TimelinesPageViewController, + let timelineVC = timelinePages.pageControllers[timelinePages.currentIndex] as? TimelineViewController { + 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 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 { let nav = viewController(for: .timelines) as! UINavigationController guard let timelinePages = nav.viewControllers.first as? TimelinesPageViewController, @@ -252,6 +274,10 @@ extension MainTabBarViewController: TuskerRootViewController { return } timelineVC.restoreActivity(activity) + restoreEditedDraft() + } else if activity.activityType == UserActivityType.newPost.rawValue { + restoreEditedDraft() + return } else { stateRestorationLogger.fault("MainTabBarViewController: Unable to restore activity of unexpected type \(activity.activityType, privacy: .public)") } diff --git a/Tusker/Shortcuts/UserActivityManager.swift b/Tusker/Shortcuts/UserActivityManager.swift index f178b064..c83eec92 100644 --- a/Tusker/Shortcuts/UserActivityManager.swift +++ b/Tusker/Shortcuts/UserActivityManager.swift @@ -91,10 +91,37 @@ class UserActivityManager { 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? { - guard activity.activityType == UserActivityType.newPost.rawValue, - let str = activity.userInfo?["draftID"] as? String, - let uuid = UUID(uuidString: str) else { + let idStr: String? + if activity.activityType == UserActivityType.newPost.rawValue { + 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 DraftsManager.shared.getBy(id: uuid) diff --git a/Tusker/TuskerNavigationDelegate.swift b/Tusker/TuskerNavigationDelegate.swift index e17d6e83..2416c952 100644 --- a/Tusker/TuskerNavigationDelegate.swift +++ b/Tusker/TuskerNavigationDelegate.swift @@ -88,7 +88,7 @@ extension TuskerNavigationDelegate { 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 { let compose = UserActivityManager.editDraftActivity(id: draft.id, accountID: apiController.accountInfo!.id) let options = UIWindowScene.ActivationRequestOptions() @@ -97,20 +97,20 @@ extension TuskerNavigationDelegate { } else { let compose = ComposeHostingController(draft: draft, mastodonController: apiController) if #available(iOS 16.0, *), - presentDuckable(compose) { + presentDuckable(compose, animated: animated) { return } else { let compose = ComposeHostingController(draft: draft, mastodonController: apiController) let nav = UINavigationController(rootViewController: 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) - compose(editing: draft) + compose(editing: draft, animated: animated) } func loadingLargeImage(url: URL, cache: ImageCache, description: String?, animatingFrom sourceView: UIImageView) -> LoadingLargeImageViewController {