Compare commits

..

3 Commits

Author SHA1 Message Date
Shadowfacts cd8f0e7926 Use navigation sequencing for user activity handling 2024-08-22 14:49:27 -04:00
Shadowfacts 960ba84683 New way of sequencing navigation operations
Better fix for #484
2024-08-22 14:34:05 -04:00
Shadowfacts 2eead1f9de Revert "Fix crash when opening push notification while VC modally presented"
This reverts commit 0f2a85b108.

This fixes state restoration happening asynchronously and causing the
new tab bar animation to run.
2024-08-22 14:17:04 -04:00
10 changed files with 211 additions and 127 deletions

View File

@ -292,12 +292,15 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
let rootViewController = delegate.rootViewController {
let mastodonController = MastodonController.getForAccount(account)
// if the scene is already active, then we animate the account switching if necessary
delegate.activateAccount(account, animated: scene.activationState == .foregroundActive)
// if the scene is already active, then we animate things
let animated = scene.activationState == .foregroundActive
rootViewController.select(route: .notifications, animated: false) {
delegate.activateAccount(account, animated: animated)
rootViewController.runNavigation(animated: animated) { navigation in
navigation.select(route: .notifications)
let vc = NotificationLoadingViewController(notificationID: notificationID, mastodonController: mastodonController)
rootViewController.getNavigationController().pushViewController(vc, animated: false)
navigation.push(viewController: vc)
}
} else {
let activity = UserActivityManager.showNotificationActivity(id: notificationID, accountID: accountID)

View File

@ -83,9 +83,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
} else {
context = ActiveAccountUserActivityHandlingContext(isHandoff: true, root: rootViewController!)
}
Task(priority: .userInitiated) {
_ = await userActivity.handleResume(manager: UserActivityManager(scene: scene as! UIWindowScene, context: context))
}
_ = userActivity.handleResume(manager: UserActivityManager(scene: scene as! UIWindowScene, context: context))
}
func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
@ -193,11 +191,9 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
if let activity = launchActivity {
func doRestoreActivity(context: UserActivityHandlingContext) {
Task(priority: .userInitiated) {
_ = await activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!, context: context))
_ = activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!, context: context))
context.finalize(activity: activity)
}
}
if activity.isStateRestorationActivity {
doRestoreActivity(context: StateRestorationUserActivityHandlingContext(root: rootViewController!))
} else if activity.activityType != UserActivityType.mainScene.rawValue {

View File

@ -79,13 +79,11 @@ class AccountSwitchingContainerViewController: UIViewController {
stateRestorationLogger.debug("AccountSwitchingContainer: reusing existing VC for \(account.id, privacy: .public)")
} else {
newRoot = newRootProvider()
Task(priority: .userInitiated) {
stateRestorationLogger.debug("AccountSwitchingContainer: restoring \(activity.activityType, privacy: .public) for \(account.id, privacy: .public)")
let context = StateRestorationUserActivityHandlingContext(root: newRoot)
_ = await activity.handleResume(manager: UserActivityManager(scene: view.window!.windowScene!, context: context))
_ = activity.handleResume(manager: UserActivityManager(scene: view.window!.windowScene!, context: context))
context.finalize(activity: activity)
}
}
} else {
newRoot = newRootProvider()
}

View File

@ -333,14 +333,14 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
// Transfer the navigation stack, dropping the search VC, to keep anything the user has opened
transferNavigationStack(from: .tab(.explore), to: exploreNav, dropFirst: true, append: true)
tabBarViewController.select(tab: .explore, dismissPresented: false)
tabBarViewController.select(tab: .explore, dismissPresented: false, animated: false)
case let .tab(tab):
// sidebar items that map 1 <-> 1 can be transferred directly
tabBarViewController.select(tab: tab, dismissPresented: false)
tabBarViewController.select(tab: tab, dismissPresented: false, animated: false)
case .bookmarks, .favorites, .list(_), .savedHashtag(_), .savedInstance(_):
tabBarViewController.select(tab: .explore, dismissPresented: false)
tabBarViewController.select(tab: .explore, dismissPresented: false, animated: false)
// Make sure the Explore VC doesn't show its search bar when it appears, in case the user was previously
// in compact mode and performing a search.
let exploreNav = tabBarViewController.viewController(for: .explore) as! UINavigationController

View File

@ -54,19 +54,22 @@ class MainTabBarViewController: BaseMainTabBarViewController {
view.backgroundColor = .appBackground
}
func select(tab: Tab, dismissPresented: Bool) {
func select(tab: Tab, dismissPresented: Bool, animated: Bool, completion: (() -> Void)? = nil) {
if tab == .compose {
compose(editing: nil)
compose(editing: nil, completion: completion)
} else {
// when switching tabs, dismiss the currently presented VC
// otherwise the selected tab changes behind the presented VC
if presentedViewController != nil && dismissPresented {
dismiss(animated: true) {
dismiss(animated: animated) {
stateRestorationLogger.info("MainTabBarViewController: selecting \(String(describing: tab), privacy: .public)")
self.selectedIndex = tab.rawValue
completion?()
}
} else {
stateRestorationLogger.info("MainTabBarViewController: selecting \(String(describing: tab), privacy: .public)")
selectedIndex = tab.rawValue
completion?()
}
}
}
@ -151,25 +154,24 @@ extension MainTabBarViewController: TuskerRootViewController {
func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?) {
switch route {
case .timelines:
select(tab: .timelines, dismissPresented: true)
select(tab: .timelines, dismissPresented: true, animated: animated, completion: completion)
case .notifications:
select(tab: .notifications, dismissPresented: true)
select(tab: .notifications, dismissPresented: true, animated: animated, completion: completion)
case .myProfile:
select(tab: .myProfile, dismissPresented: true)
select(tab: .myProfile, dismissPresented: true, animated: animated, completion: completion)
case .explore:
select(tab: .explore, dismissPresented: true)
select(tab: .explore, dismissPresented: true, animated: animated, completion: completion)
case .bookmarks:
select(tab: .explore, dismissPresented: true)
select(tab: .explore, dismissPresented: true, animated: animated, completion: completion)
getNavigationController().pushViewController(BookmarksViewController(mastodonController: mastodonController), animated: animated)
case .list(id: let id):
select(tab: .explore, dismissPresented: true)
select(tab: .explore, dismissPresented: true, animated: animated, completion: completion)
if let list = mastodonController.getCachedList(id: id) {
let nav = getNavigationController()
_ = nav.popToRootViewController(animated: animated)
nav.pushViewController(ListTimelineViewController(for: list, mastodonController: mastodonController), animated: animated)
}
}
completion?()
}
func getNavigationDelegate() -> TuskerNavigationDelegate? {
@ -186,7 +188,7 @@ extension MainTabBarViewController: TuskerRootViewController {
return
}
select(tab: .explore, dismissPresented: true)
select(tab: .explore, dismissPresented: true, animated: false)
exploreNavController.popToRootViewController(animated: false)
// setting searchController.isActive directly doesn't work until the view has loaded/appeared for the first time

View File

@ -20,6 +20,14 @@ protocol TuskerRootViewController: UIViewController, StateRestorableViewControll
func presentPreferences(completion: (() -> Void)?) -> PreferencesNavigationController?
}
extension TuskerRootViewController {
func runNavigation(animated: Bool, _ builder: (_ navigation: TuskerNavigationSequence) -> Void) {
let sequence = TuskerNavigationSequence(root: self, animated: animated)
builder(sequence)
sequence.run()
}
}
enum TuskerRoute {
case timelines
case notifications
@ -29,6 +37,67 @@ enum TuskerRoute {
case list(id: String)
}
/// A class that manages running a sequence of navigation operations on a ``TuskerRootViewController``.
///
/// Use this type, rather than calling multiple methods on the root VC in a row, because it manages waiting until each previous step finishes.
@MainActor
final class TuskerNavigationSequence {
private let root: any TuskerRootViewController
private let animated: Bool
private var operations = [() -> Void]()
init(root: any TuskerRootViewController, animated: Bool) {
self.root = root
self.animated = animated
}
func select(route: TuskerRoute) {
operations.append {
self.root.select(route: route, animated: self.animated, completion: self.run)
}
}
func push(viewController: UIViewController) {
operations.append {
let nav = self.root.getNavigationController()
nav.pushViewController(viewController, animated: self.animated)
self.run()
}
}
func popToRoot() {
operations.append {
let nav = self.root.getNavigationController()
nav.popToRootViewController(animated: self.animated)
self.run()
}
}
func present(viewController: UIViewController) {
operations.append {
self.root.present(viewController, animated: self.animated, completion: self.run)
}
}
func withTopViewController(_ block: @escaping (_ topViewController: UIViewController?, _ completion: @escaping @MainActor () -> Void) -> Void) {
operations.append {
block(self.root.getNavigationController().topViewController, self.run)
}
}
func addOperation(_ operation: @escaping (_ completion: @escaping () -> Void) -> Void) {
operations.append {
operation(self.run)
}
}
func run() {
if !operations.isEmpty {
operations.removeFirst()()
}
}
}
@MainActor
protocol NavigationControllerProtocol: UIViewController {
var viewControllers: [UIViewController] { get set }

View File

@ -43,9 +43,9 @@ extension NSUserActivity {
}
@MainActor
func handleResume(manager: UserActivityManager) async -> Bool {
func handleResume(manager: UserActivityManager) -> Bool {
guard let type = UserActivityType(rawValue: activityType) else { return false }
await type.handle(manager)(self)
type.handle(manager)(self)
return true
}

View File

@ -16,93 +16,94 @@ import ComposeUI
protocol UserActivityHandlingContext {
var isHandoff: Bool { get }
func select(route: TuskerRoute) async
func select(route: TuskerRoute, completion: (() -> Void)?)
func present(_ vc: UIViewController)
var topViewController: UIViewController? { get }
func select(route: TuskerRoute)
func popToRoot()
func push(_ vc: UIViewController)
func withTopViewController(_ block: @escaping (_ topViewController: UIViewController?, _ completion: @escaping @MainActor () -> Void) -> Void)
func present(_ vc: UIViewController)
func compose(editing draft: Draft)
func finalize(activity: NSUserActivity)
}
extension UserActivityHandlingContext {
func select(route: TuskerRoute) async {
await withCheckedContinuation { continuation in
select(route: route) {
continuation.resume()
}
}
}
}
struct ActiveAccountUserActivityHandlingContext: UserActivityHandlingContext {
let isHandoff: Bool
let root: TuskerRootViewController
var navigationDelegate: TuskerNavigationDelegate {
root.getNavigationDelegate()!
private let root: TuskerRootViewController
private let navigation: TuskerNavigationSequence
init(isHandoff: Bool, root: TuskerRootViewController) {
self.isHandoff = isHandoff
self.root = root
self.navigation = TuskerNavigationSequence(root: root, animated: true)
}
func select(route: TuskerRoute, completion: (() -> Void)?) {
root.select(route: route, animated: true, completion: completion)
func select(route: TuskerRoute) {
navigation.select(route: route)
}
func present(_ vc: UIViewController) {
navigationDelegate.present(vc, animated: true)
navigation.present(viewController: vc)
}
var topViewController: UIViewController? { root.getNavigationController().topViewController }
func popToRoot() {
_ = root.getNavigationController().popToRootViewController(animated: true)
navigation.popToRoot()
}
func push(_ vc: UIViewController) {
navigationDelegate.show(vc, sender: nil)
navigation.push(viewController: vc)
}
func withTopViewController(_ block: @escaping (_ topViewController: UIViewController?, _ completion: @escaping @MainActor () -> Void) -> Void) {
navigation.withTopViewController(block)
}
func compose(editing draft: Draft) {
navigationDelegate.compose(editing: draft, animated: true, isDucked: true)
navigation.addOperation { completion in
root.compose(editing: draft, animated: true, isDucked: true, completion: completion)
}
}
func finalize(activity: NSUserActivity) {
navigation.run()
}
}
class StateRestorationUserActivityHandlingContext: UserActivityHandlingContext {
private var state = State.initial
let root: TuskerRootViewController
private let root: TuskerRootViewController
private let navigation: TuskerNavigationSequence
init(root: TuskerRootViewController) {
self.root = root
self.navigation = TuskerNavigationSequence(root: root, animated: false)
}
var isHandoff: Bool { false }
func select(route: TuskerRoute, completion: (() -> Void)?) {
root.select(route: route, animated: false) {
self.state = .selectedRoute
completion?()
}
var isHandoff: Bool {
false
}
var topViewController: UIViewController? { root.getNavigationController().topViewController }
func select(route: TuskerRoute) {
navigation.select(route: route)
state = .selectedRoute
}
func popToRoot() {
// unnecessary during state restoration
navigation.popToRoot()
}
func push(_ vc: UIViewController) {
precondition(state >= .selectedRoute)
root.getNavigationController().pushViewController(vc, animated: false)
navigation.push(viewController: vc)
state = .pushed
}
func withTopViewController(_ block: @escaping (_ topViewController: UIViewController?, _ completion: @escaping @MainActor () -> Void) -> Void) {
navigation.withTopViewController(block)
}
func present(_ vc: UIViewController) {
root.present(vc, animated: false)
navigation.present(viewController: vc)
state = .presented
}
@ -120,6 +121,7 @@ class StateRestorationUserActivityHandlingContext: UserActivityHandlingContext {
func finalize(activity: NSUserActivity) {
precondition(state > .initial)
navigation.run()
#if !os(visionOS)
if #available(iOS 16.0, *),
let duckedDraft = UserActivityManager.getDuckedDraft(from: activity) {

View File

@ -133,13 +133,16 @@ class UserActivityManager {
return activity
}
func handleCheckNotifications(activity: NSUserActivity) async {
await context.select(route: .notifications)
func handleCheckNotifications(activity: NSUserActivity) {
context.select(route: .notifications)
context.popToRoot()
if let notificationsPageController = context.topViewController as? NotificationsPageViewController {
context.withTopViewController { topViewController, completion in
if let notificationsPageController = topViewController as? NotificationsPageViewController {
notificationsPageController.loadViewIfNeeded()
notificationsPageController.selectMode(Self.getNotificationsMode(from: activity) ?? Preferences.shared.defaultNotificationsMode)
}
completion()
}
}
static func getNotificationsMode(from activity: NSUserActivity) -> NotificationsMode? {
@ -204,32 +207,41 @@ class UserActivityManager {
return (timeline, positionInfo)
}
func handleShowTimeline(activity: NSUserActivity) async {
func handleShowTimeline(activity: NSUserActivity) {
guard let (timeline, positionInfo) = Self.getTimeline(from: activity) else { return }
var timelineVC: TimelineViewController?
if let pinned = PinnedTimeline(timeline: timeline),
mastodonController.accountPreferences.pinnedTimelines.contains(pinned) {
await context.select(route: .timelines)
context.select(route: .timelines)
context.popToRoot()
let pageController = context.topViewController as! TimelinesPageViewController
context.withTopViewController { topViewController, completion in
let pageController = topViewController as! TimelinesPageViewController
pageController.selectTimeline(pinned, animated: false)
timelineVC = pageController.currentViewController as? TimelineViewController
}
} else if case .list(let id) = timeline {
await context.select(route: .list(id: id))
timelineVC = context.topViewController as? TimelineViewController
context.select(route: .list(id: id))
} else {
await context.select(route: .explore)
context.select(route: .explore)
context.popToRoot()
timelineVC = TimelineViewController(for: timeline, mastodonController: mastodonController)
context.push(timelineVC!)
let timelineVC = TimelineViewController(for: timeline, mastodonController: mastodonController)
context.push(timelineVC)
}
if let timelineVC,
let positionInfo,
if let positionInfo,
context.isHandoff {
context.withTopViewController { topViewController, completion in
let timelineVC: TimelineViewController
if let topViewController = topViewController as? TimelineViewController {
timelineVC = topViewController
} else if let topViewController = topViewController as? TimelinesPageViewController {
timelineVC = topViewController.currentViewController as! TimelineViewController
} else {
return
}
Task {
await timelineVC.restoreStateFromHandoff(statusIDs: positionInfo.statusIDs, centerStatusID: positionInfo.centerStatusID)
completion()
}
}
}
}
@ -249,11 +261,11 @@ class UserActivityManager {
return activity.userInfo?["mainStatusID"] as? String
}
func handleShowConversation(activity: NSUserActivity) async {
func handleShowConversation(activity: NSUserActivity) {
guard let mainStatusID = Self.getConversationStatus(from: activity) else {
return
}
await context.select(route: .timelines)
context.select(route: .timelines)
context.push(ConversationViewController(for: mainStatusID, state: .unknown, mastodonController: mastodonController))
}
@ -274,18 +286,19 @@ class UserActivityManager {
return activity.userInfo?["query"] as? String
}
func handleSearch(activity: NSUserActivity) async {
await context.select(route: .explore)
func handleSearch(activity: NSUserActivity) {
context.select(route: .explore)
context.popToRoot()
context.withTopViewController { topViewController, completion in
let searchController: UISearchController
let resultsController: SearchResultsViewController
if let explore = context.topViewController as? ExploreViewController {
if let explore = topViewController as? ExploreViewController {
explore.loadViewIfNeeded()
explore.searchControllerStatusOnAppearance = true
searchController = explore.searchController
resultsController = explore.resultsController
} else if let inlineTrends = context.topViewController as? InlineTrendsViewController {
} else if let inlineTrends = topViewController as? InlineTrendsViewController {
inlineTrends.loadViewIfNeeded()
inlineTrends.searchControllerStatusOnAppearance = true
searchController = inlineTrends.searchController
@ -302,6 +315,7 @@ class UserActivityManager {
searchController.searchBar.becomeFirstResponder()
}
}
}
static func bookmarksActivity(accountID: String) -> NSUserActivity {
let activity = NSUserActivity(type: .bookmarks, accountID: accountID)
@ -311,8 +325,8 @@ class UserActivityManager {
return activity
}
func handleBookmarks(activity: NSUserActivity) async {
await context.select(route: .bookmarks)
func handleBookmarks(activity: NSUserActivity) {
context.select(route: .bookmarks)
}
// MARK: - My Profile
@ -325,8 +339,8 @@ class UserActivityManager {
return activity
}
func handleMyProfile(activity: NSUserActivity) async {
await context.select(route: .myProfile)
func handleMyProfile(activity: NSUserActivity) {
context.select(route: .myProfile)
}
// MARK: - Show Profile
@ -344,11 +358,11 @@ class UserActivityManager {
return activity.userInfo?["profileID"] as? String
}
func handleShowProfile(activity: NSUserActivity) async {
func handleShowProfile(activity: NSUserActivity) {
guard let accountID = Self.getProfile(from: activity) else {
return
}
await context.select(route: .timelines)
context.select(route: .timelines)
context.push(ProfileViewController(accountID: accountID, mastodonController: mastodonController))
}
@ -361,11 +375,11 @@ class UserActivityManager {
return activity
}
func handleShowNotification(activity: NSUserActivity) async {
func handleShowNotification(activity: NSUserActivity) {
guard let notificationID = activity.userInfo?["notificationID"] as? String else {
return
}
await context.select(route: .notifications)
context.select(route: .notifications)
context.push(NotificationLoadingViewController(notificationID: notificationID, mastodonController: mastodonController))
}

View File

@ -23,7 +23,7 @@ enum UserActivityType: String {
extension UserActivityType {
@MainActor
var handle: (UserActivityManager) -> @MainActor (NSUserActivity) async -> Void {
var handle: (UserActivityManager) -> @MainActor (NSUserActivity) -> Void {
switch self {
case .mainScene:
fatalError("cannot handle main scene activity")