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 {
@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

View File

@ -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([

View File

@ -43,11 +43,11 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Wra
super.init(rootView: wrapper)
self.uiState.delegate = self
pasteConfiguration = UIPasteConfiguration(forAccepting: CompositionAttachment.self)
userActivity = UserActivityManager.newPostActivity(accountID: mastodonController.accountInfo!.id)
updateNavigationTitle(draft: uiState.draft)
self.uiState.$draft
.flatMap(\.objectWillChange)
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.global(qos: .utility))
@ -55,12 +55,27 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Wra
DraftsManager.save()
}
.store(in: &cancellables)
self.uiState.$draft
.sink { [unowned self] draft in
self.updateNavigationTitle(draft: draft)
}
.store(in: &cancellables)
}
required init?(coder aDecoder: NSCoder) {
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) {
super.viewWillDisappear(animated)

View File

@ -107,7 +107,6 @@ struct ComposeView: View {
globalFrameOutsideList = frame
}
})
.navigationTitle(navTitle)
.sheet(isPresented: $uiState.isShowingDraftsList) {
DraftsView(currentDraft: draft, mastodonController: mastodonController)
}
@ -211,15 +210,6 @@ struct ComposeView: View {
}.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 {
Button(action: self.cancel) {
Text("Cancel")

View File

@ -12,10 +12,21 @@ import Duckable
@available(iOS 16.0, *)
extension DuckableContainerViewController: TuskerRootViewController {
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) {
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)
}

View File

@ -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)")
}

View File

@ -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)

View File

@ -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 {