Integrate CYLTabBar center scan button and unify UIKit navigation.
Add CYLTabBarController with a global scan entry, promote MainTabBarController to the window root after login, replace RouterPath-based navigation with AppNavigator, and fix Profile diffable cell reconfiguration crashes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -7,19 +7,19 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航
|
||||
该模块不直接处理具体页面业务,主要承担 App 级基础设施编排:
|
||||
- 根据登录状态展示登录页、恢复态或主 Tab。
|
||||
- 创建并注入全局状态和共享服务。
|
||||
- 管理每个 Tab 独立的导航栈与 `AppRouter` 路径。
|
||||
- 管理每个 Tab 独立的 UIKit 导航栈,并通过 `AppNavigator` 统一执行全局跳转。
|
||||
- 协调登录成功、退出登录、冷启动恢复和本地缓存同步。
|
||||
|
||||
## 核心对象
|
||||
|
||||
- `AppDelegate` / `SceneDelegate`:UIKit 应用入口,挂载 `RootViewController`。
|
||||
- `AppDelegate` / `SceneDelegate`:UIKit 应用入口;`SceneDelegate` 挂载 `RootViewController` 并持有其强引用。
|
||||
- `AppServices`:全局依赖容器(Composition Root),等价于参考工程 SwiftUI `RootView` 中的 Environment 注入;内部按 Session / Context / Network / UI / Runtime 分组,对外仍保留扁平属性访问。
|
||||
- `RootViewController`:根据 `AppSession.phase` 切换登录页、恢复占位或 `MainTabBarController`,并挂载全局 Toast / Loading。
|
||||
- `RootViewController`:监听 `AppSession.phase`,未登录时在自身上展示登录/恢复页,登录后将 `MainTabBarController` 设为 window 根控制器,并挂载全局 Toast / Loading。
|
||||
- `AppSession`:保存认证阶段和正式 token,只负责登录态,不承载业务资料。
|
||||
- `AccountContext`:保存当前账号资料、景区作用域和门店作用域。
|
||||
- `PermissionContext`:保存角色权限、当前角色和扁平化权限 URI。
|
||||
- `ScenicSpotContext`:保存当前景区下的景点/打卡点列表和加载状态。
|
||||
- `AppRouter`:保存当前 Tab 和每个 Tab 自己的导航路径。
|
||||
- `AppNavigator`:应用级导航器,持有主 Tab 宿主弱引用,并以 UIKit 导航栈作为唯一导航事实源。
|
||||
- `ToastCenter`:管理当前全局 Toast 文案和自动隐藏任务。
|
||||
- `AuthSessionCoordinator`:统一处理登录完成、退出登录、偏好读取和账号快照刷新。
|
||||
- `SessionBootstrapper`:冷启动时读取本地 token 和账号快照,并向服务端校验登录态。
|
||||
@ -44,7 +44,7 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航
|
||||
|
||||
普通业务 `ViewController`、模块基类(`ModuleTableViewController` / `ModuleCollectionViewController`)通过 `UIViewController.appServices` 扩展访问,**不必**再声明 `private let services` 或新增 `init(services:)`。
|
||||
|
||||
导航辅助(`UIKitAppNavigation`、`HomeMenuRouting`)在全局 push 场景可直接使用 `AppServices.shared`。
|
||||
导航辅助(如 `HomeMenuRouting`)在全局 push 场景通过 `AppServices.shared.appNavigator` 执行跳转。
|
||||
|
||||
### ViewModel 层边界
|
||||
|
||||
@ -69,7 +69,7 @@ ViewModel **不持有** `AppServices`。View 从容器取出具体依赖,在 `
|
||||
5. 无 token 时保持 `loggedOut`,展示 `LoginViewController`。
|
||||
6. 有 token 时进入 `restoring`,先恢复本地账号快照。
|
||||
7. `AccountContextLoader` 并行请求用户资料和角色权限,再补全景区与门店。
|
||||
8. 校验成功后进入 `loggedIn`,展示 `MainTabBarController`。
|
||||
8. 校验成功后进入 `loggedIn`,将 `MainTabBarController` 设为 window 根控制器。
|
||||
9. `ScenicSpotContext` 按当前景区懒加载景点/打卡点。
|
||||
10. 明确 token 失效时清空 token 和账号快照,回到登录页。
|
||||
11. 普通网络失败时保留本地登录态,使用账号快照进入主界面。
|
||||
@ -96,20 +96,20 @@ ViewModel **不持有** `AppServices`。View 从容器取出具体依赖,在 `
|
||||
退出登录由 `AuthSessionCoordinator.logout` 统一处理:
|
||||
- 清空 Keychain token。
|
||||
- 清空账号快照。
|
||||
- 重置账号上下文、权限上下文、景点上下文、导航路径和 Toast。
|
||||
- 重置账号上下文、权限上下文、景点上下文、全部 UIKit 导航栈和 Toast。
|
||||
- `AppSession` 切换为 `loggedOut`。
|
||||
- 保留上次手机号、协议状态等非敏感偏好。
|
||||
|
||||
## Toast
|
||||
|
||||
全局 Toast 由 `RootViewController` 在 `viewDidAppear` 时挂载到 window,业务页面通过 `showToast(...)` 或 `appServices.toastCenter.show(...)` 发出提示命令。
|
||||
全局 Toast 由 `RootViewController` 在切换 window 根控制器后挂载到 window,业务页面通过 `showToast(...)` 或 `appServices.toastCenter.show(...)` 发出提示命令。
|
||||
|
||||
Toast 展示为顶部全宽横幅,背景使用不透明主色并延伸到屏幕顶部、左边和右边;文案居中展示,不提供关闭按钮,默认 2.2 秒后自动消失。连续展示新 Toast 时会覆盖旧文案并重新计时,旧的自动隐藏任务不会影响新的 Toast。
|
||||
|
||||
## 导航规则
|
||||
|
||||
主界面使用 `MainTabBarController`,每个 Tab 内部由独立的 `TabNavigationController`(`UINavigationController`)包裹。`AppRouter` 为每个 `AppTab` 持有独立 `RouterPath`,切换 Tab 不会丢失该 Tab 的内部导航路径。
|
||||
主界面使用 `MainTabBarController` 作为 window 根控制器(登录后),每个 Tab 内部由独立的 `TabNavigationController`(`UINavigationController`)包裹。`UINavigationController.viewControllers` 是页面路径的唯一事实源,普通 Tab 切换保留该 Tab 的原有栈。
|
||||
|
||||
Tab 根页面显示底部 TabBar。通过 `AppRoute` push 到子页面时,会根据 `AppRoute.hidesTabBarWhenPushed` 统一隐藏 TabBar。
|
||||
|
||||
跨 Tab 跳转通过 `AppRouter.select` / `navigateHome` / `navigateProfile` 处理;Tab 内 push 通过 `UIKitAppNavigation` 或当前 Nav 栈完成。新增页面时优先扩展 `AppRoute` / `HomeRoute`,在 `AppRouteViewControllerFactory` 注册对应 ViewController。
|
||||
跨 Tab、推送通知、UI Test 直达和全局扫码都通过 `AppNavigator` 处理。`AppNavigationPolicy.currentStack` 沿当前栈 push,`.tabRoot` 切到目标 Tab 并先回到 root,`.preserveTab` 只切换 Tab 并保留原栈。新增页面时优先扩展 `AppRoute` / `HomeRoute` / `ProfileRoute` / `OrdersRoute`,并在 `AppRouteViewControllerFactory` 注册对应 ViewController。
|
||||
|
||||
@ -17,7 +17,7 @@ final class AppServices {
|
||||
let network: NetworkBundle
|
||||
let ui: UIFeedbackBundle
|
||||
let runtime: AppRuntimeBundle
|
||||
let appRouter = AppRouter()
|
||||
let appNavigator = AppNavigator()
|
||||
|
||||
private let tokenStore: SessionTokenStore
|
||||
private let snapshotStore: AccountSnapshotStore
|
||||
@ -151,7 +151,7 @@ final class AppServices {
|
||||
PushNotificationManager.shared.configure(
|
||||
api: pushAPI,
|
||||
session: appSession,
|
||||
router: appRouter
|
||||
navigator: appNavigator
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -39,19 +39,19 @@ enum AppUITestRouteDriver {
|
||||
return
|
||||
}
|
||||
|
||||
services.appRouter.reset()
|
||||
services.appNavigator.resetAllStacks()
|
||||
let resolved = HomeMenuRouter.resolve(uri: item.uri, title: item.title)
|
||||
switch resolved {
|
||||
case .tab(let tab):
|
||||
services.appRouter.select(tab)
|
||||
services.appNavigator.selectTab(tab)
|
||||
case .orders(let entry):
|
||||
services.appRouter.selectOrders(entry: entry)
|
||||
services.appNavigator.openOrdersEntry(entry)
|
||||
case .destination(let homeRoute):
|
||||
services.appRouter.navigateHome(homeRoute, animated: false)
|
||||
services.appNavigator.openHome(homeRoute, policy: .tabRoot(.home), animated: false)
|
||||
case .unsupported(let uri, let title, _):
|
||||
services.appRouter.navigateHome(.modulePlaceholder(uri: uri, title: title), animated: false)
|
||||
services.appNavigator.openHome(.modulePlaceholder(uri: uri, title: title), policy: .tabRoot(.home), animated: false)
|
||||
case .placeholder(let uri, let title):
|
||||
services.appRouter.navigateHome(.modulePlaceholder(uri: uri, title: title), animated: false)
|
||||
services.appNavigator.openHome(.modulePlaceholder(uri: uri, title: title), policy: .tabRoot(.home), animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
@ -78,8 +78,8 @@ enum AppUITestRouteDriver {
|
||||
}
|
||||
|
||||
guard let route else { return }
|
||||
services.appRouter.reset()
|
||||
services.appRouter.navigateProfile(route, animated: false)
|
||||
services.appNavigator.resetAllStacks()
|
||||
services.appNavigator.openProfile(route, policy: .tabRoot(.profile), animated: false)
|
||||
}
|
||||
|
||||
/// 将 UI Test 用例菜单名映射为调试目录标题。
|
||||
|
||||
@ -172,12 +172,3 @@ enum AppRouteViewControllerFactory {
|
||||
return String(describing: route)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 首页路由 ViewController 工厂,供 `UIKitAppNavigation` 复用。
|
||||
enum HomeRouteViewControllerFactory {
|
||||
/// 创建实例。
|
||||
static func make(for route: HomeRoute) -> UIViewController {
|
||||
AppRouteViewControllerFactory.makeViewController(for: route, services: AppServices.shared)
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// 应用内可导航目标,承载各 Tab 内部的路由目的地。
|
||||
enum AppRoute: Hashable {
|
||||
@ -21,90 +22,140 @@ enum AppRoute: Hashable {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 单个导航栈的路径容器,用于保存某个 Tab 的导航历史。
|
||||
final class RouterPath {
|
||||
var onChange: (() -> Void)?
|
||||
var path: [AppRoute] = [] { didSet { onChange?() } }
|
||||
|
||||
/// 将指定路由压入当前 Tab 的导航栈。
|
||||
func navigate(to route: AppRoute) {
|
||||
path.append(route)
|
||||
}
|
||||
|
||||
/// 从当前 Tab 的导航栈弹出最后一级路由。
|
||||
func navigateBack() {
|
||||
guard !path.isEmpty else { return }
|
||||
path.removeLast()
|
||||
}
|
||||
|
||||
/// 清空当前 Tab 的导航栈。
|
||||
func reset() {
|
||||
path = []
|
||||
}
|
||||
/// UIKit 路由执行策略,描述全局入口应如何选择导航栈。
|
||||
enum AppNavigationPolicy: Equatable {
|
||||
case currentStack
|
||||
case tabRoot(AppTab)
|
||||
case preserveTab(AppTab)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 主导航状态中心,管理当前 Tab 和每个 Tab 独立的导航路径。
|
||||
final class AppRouter {
|
||||
var onChange: (() -> Void)?
|
||||
var selectedTab: AppTab = .home { didSet { onChange?() } }
|
||||
var selectedOrdersEntry: OrdersEntry = .storeOrders { didSet { onChange?() } }
|
||||
private(set) var pendingOrderScanCode: String? { didSet { onChange?() } }
|
||||
/// 主 Tab 导航宿主协议,由 `MainTabBarController` 提供 UIKit 栈能力。
|
||||
protocol AppNavigationHost: AnyObject {
|
||||
/// 选中指定主 Tab。
|
||||
func selectTab(_ tab: AppTab)
|
||||
|
||||
private let routers: [AppTab: RouterPath]
|
||||
/// 返回指定 Tab 的导航控制器。
|
||||
func navigationController(for tab: AppTab) -> TabNavigationController?
|
||||
|
||||
/// 初始化实例。
|
||||
init() {
|
||||
var builtRouters: [AppTab: RouterPath] = [:]
|
||||
for tab in AppTab.allCases {
|
||||
builtRouters[tab] = RouterPath()
|
||||
/// 返回当前选中 Tab 的导航控制器。
|
||||
func selectedNavigationController() -> TabNavigationController?
|
||||
|
||||
/// 重置全部 Tab 的导航栈。
|
||||
func resetAllStacks()
|
||||
|
||||
/// 将指定订单入口应用到订单根页面。
|
||||
func applyOrdersEntry(_ entry: OrdersEntry, scannedCode: String?)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// UIKit 主导航器,以 `UINavigationController.viewControllers` 作为唯一导航事实源。
|
||||
final class AppNavigator {
|
||||
private weak var host: AppNavigationHost?
|
||||
private var pendingActions: [(@MainActor (AppNavigationHost) -> Void)] = []
|
||||
|
||||
/// 绑定主 Tab 宿主,并按 FIFO 执行挂载前暂存的全局导航请求。
|
||||
func attach(host: AppNavigationHost) {
|
||||
self.host = host
|
||||
let actions = pendingActions
|
||||
pendingActions.removeAll()
|
||||
actions.forEach { $0(host) }
|
||||
}
|
||||
|
||||
/// 解绑主 Tab 宿主并清理尚未执行的全局导航请求。
|
||||
func detach(host: AppNavigationHost? = nil) {
|
||||
if let host, self.host !== host {
|
||||
return
|
||||
}
|
||||
routers = builtRouters
|
||||
routers.values.forEach { router in
|
||||
router.onChange = { [weak self] in
|
||||
self?.onChange?()
|
||||
self.host = nil
|
||||
pendingActions.removeAll()
|
||||
}
|
||||
|
||||
/// 切换到指定主 Tab,保留该 Tab 已有导航栈。
|
||||
func selectTab(_ tab: AppTab) {
|
||||
performOrEnqueue { host in
|
||||
host.selectTab(tab)
|
||||
}
|
||||
}
|
||||
|
||||
/// 在当前或指定来源的导航栈中 push 应用路由。
|
||||
func push(_ route: AppRoute, from source: UIViewController?, animated: Bool = true) {
|
||||
if let navigationController = source?.navigationController as? TabNavigationController {
|
||||
navigationController.push(route: route, animated: animated)
|
||||
return
|
||||
}
|
||||
|
||||
performOrEnqueue { host in
|
||||
guard let navigationController = source?.navigationController as? TabNavigationController
|
||||
?? host.selectedNavigationController()
|
||||
else { return }
|
||||
navigationController.push(route: route, animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
/// 打开首页路由。
|
||||
func openHome(_ route: HomeRoute, policy: AppNavigationPolicy = .currentStack, animated: Bool = true) {
|
||||
open(.home(route), defaultTab: .home, policy: policy, animated: animated)
|
||||
}
|
||||
|
||||
/// 打开个人中心路由。
|
||||
func openProfile(_ route: ProfileRoute, policy: AppNavigationPolicy = .currentStack, animated: Bool = true) {
|
||||
open(.profile(route), defaultTab: .profile, policy: policy, animated: animated)
|
||||
}
|
||||
|
||||
/// 切换到订单 Tab 并应用订单内部入口,可携带全局扫码结果。
|
||||
func openOrdersEntry(_ entry: OrdersEntry, scannedCode: String? = nil) {
|
||||
performOrEnqueue { host in
|
||||
host.selectTab(.orders)
|
||||
host.navigationController(for: .orders)?.popToRootViewController(animated: false)
|
||||
host.applyOrdersEntry(entry, scannedCode: scannedCode)
|
||||
}
|
||||
}
|
||||
|
||||
/// 重置全部 Tab 导航栈并清空暂存请求。
|
||||
func resetAllStacks() {
|
||||
pendingActions.removeAll()
|
||||
host?.resetAllStacks()
|
||||
}
|
||||
|
||||
/// 执行通用路由打开逻辑。
|
||||
private func open(
|
||||
_ route: AppRoute,
|
||||
defaultTab: AppTab,
|
||||
policy: AppNavigationPolicy,
|
||||
animated: Bool
|
||||
) {
|
||||
performOrEnqueue { host in
|
||||
let targetTab: AppTab
|
||||
switch policy {
|
||||
case .currentStack:
|
||||
guard let navigationController = host.selectedNavigationController() else { return }
|
||||
navigationController.push(route: route, animated: animated)
|
||||
return
|
||||
case .tabRoot(let tab):
|
||||
targetTab = tab
|
||||
host.selectTab(tab)
|
||||
guard let navigationController = host.navigationController(for: tab) else { return }
|
||||
navigationController.popToRootViewController(animated: false)
|
||||
navigationController.push(route: route, animated: animated)
|
||||
case .preserveTab(let tab):
|
||||
targetTab = tab
|
||||
host.selectTab(tab)
|
||||
host.navigationController(for: tab)?.push(route: route, animated: animated)
|
||||
}
|
||||
|
||||
if targetTab != defaultTab {
|
||||
assertionFailure("Route \(route) opened on unexpected tab \(targetTab)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取指定 Tab 对应的路由路径容器。
|
||||
func router(for tab: AppTab) -> RouterPath {
|
||||
guard let router = routers[tab] else {
|
||||
assertionFailure("Missing router path for tab: \(tab)")
|
||||
return RouterPath()
|
||||
/// 主 Tab 已挂载时立即执行,否则暂存至登录后执行。
|
||||
private func performOrEnqueue(_ action: @escaping @MainActor (AppNavigationHost) -> Void) {
|
||||
if let host {
|
||||
action(host)
|
||||
} else {
|
||||
pendingActions.append(action)
|
||||
}
|
||||
return router
|
||||
}
|
||||
|
||||
/// 切换到指定主 Tab。
|
||||
func select(_ tab: AppTab) {
|
||||
selectedTab = tab
|
||||
}
|
||||
|
||||
/// 切换到订单 Tab,并指定订单内部子入口。
|
||||
func selectOrders(entry: OrdersEntry) {
|
||||
selectedOrdersEntry = entry
|
||||
selectedTab = .orders
|
||||
}
|
||||
|
||||
/// 切换到核销订单入口,并暂存全局扫码结果等待订单页消费。
|
||||
func routeToOrderVerification(scannedCode: String) {
|
||||
pendingOrderScanCode = scannedCode
|
||||
selectOrders(entry: .verificationOrders)
|
||||
}
|
||||
|
||||
/// 读取并清空待处理的全局扫码结果,确保同一次扫码只被处理一次。
|
||||
func consumePendingOrderScanCode() -> String? {
|
||||
let code = pendingOrderScanCode
|
||||
pendingOrderScanCode = nil
|
||||
return code
|
||||
}
|
||||
|
||||
/// 重置主 Tab 选择和所有 Tab 的导航历史。
|
||||
func reset() {
|
||||
selectedTab = .home
|
||||
selectedOrdersEntry = .storeOrders
|
||||
pendingOrderScanCode = nil
|
||||
routers.values.forEach { $0.reset() }
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,25 +6,19 @@
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
/// 单个 Tab 的导航栈,与 `AppRouter` 路径同步并在 push 时隐藏底部 TabBar。
|
||||
final class TabNavigationController: UINavigationController, UINavigationControllerDelegate {
|
||||
/// 单个 Tab 的 UIKit 导航栈,负责承载该 Tab 内部页面层级。
|
||||
final class TabNavigationController: UINavigationController {
|
||||
|
||||
let appTab: AppTab
|
||||
private let services: AppServices
|
||||
private var isSyncingStack = false
|
||||
|
||||
/// 初始化实例。
|
||||
init(tab: AppTab, services: AppServices) {
|
||||
self.appTab = tab
|
||||
self.services = services
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
delegate = self
|
||||
navigationBar.prefersLargeTitles = false
|
||||
setViewControllers([tab.makeRootViewController(services: services)], animated: false)
|
||||
registerNavigationBridge()
|
||||
services.appRouter.router(for: appTab).onChange = { [weak self] in
|
||||
self?.syncFromRouterPath()
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
@ -32,76 +26,10 @@ final class TabNavigationController: UINavigationController, UINavigationControl
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// Push 指定应用路由,并同步写入 RouterPath。
|
||||
func pushRoute(_ route: AppRoute, animated: Bool = true) {
|
||||
/// Push 指定应用路由,并统一设置底部 TabBar 隐藏策略。
|
||||
func push(route: AppRoute, animated: Bool = true) {
|
||||
let viewController = AppRouteViewControllerFactory.makeViewController(for: route, services: services)
|
||||
viewController.hidesBottomBarWhenPushed = route.hidesTabBarWhenPushed
|
||||
isSyncingStack = true
|
||||
services.appRouter.router(for: appTab).navigate(to: route)
|
||||
isSyncingStack = false
|
||||
pushViewController(viewController, animated: animated)
|
||||
}
|
||||
|
||||
/// navigationController相关逻辑。
|
||||
func navigationController(
|
||||
_ navigationController: UINavigationController,
|
||||
didShow viewController: UIViewController,
|
||||
animated: Bool
|
||||
) {
|
||||
syncRouterPathFromStack()
|
||||
}
|
||||
|
||||
/// 注册NavigationBridge。
|
||||
private func registerNavigationBridge() {
|
||||
switch appTab {
|
||||
case .home:
|
||||
UIKitAppNavigation.homeNavigationController = self
|
||||
case .profile:
|
||||
UIKitAppNavigation.profileNavigationController = self
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步FromRouterPath状态。
|
||||
private func syncFromRouterPath() {
|
||||
guard !isSyncingStack else { return }
|
||||
|
||||
let routerPath = services.appRouter.router(for: appTab)
|
||||
let targetDepth = routerPath.path.count
|
||||
let currentDepth = max(viewControllers.count - 1, 0)
|
||||
|
||||
if targetDepth > currentDepth {
|
||||
// Router 路径变深:按路径依次 push 缺失页面
|
||||
for index in currentDepth..<targetDepth {
|
||||
let route = routerPath.path[index]
|
||||
let viewController = AppRouteViewControllerFactory.makeViewController(for: route, services: services)
|
||||
viewController.hidesBottomBarWhenPushed = route.hidesTabBarWhenPushed
|
||||
pushViewController(viewController, animated: true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if targetDepth < currentDepth {
|
||||
// Router 路径变浅:pop 至目标深度
|
||||
if targetDepth == 0 {
|
||||
popToRootViewController(animated: true)
|
||||
} else if targetDepth < viewControllers.count {
|
||||
popToViewController(viewControllers[targetDepth], animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步RouterPathFromStack状态。
|
||||
private func syncRouterPathFromStack() {
|
||||
guard !isSyncingStack else { return }
|
||||
|
||||
let routerPath = services.appRouter.router(for: appTab)
|
||||
let depth = max(viewControllers.count - 1, 0)
|
||||
if depth < routerPath.path.count {
|
||||
isSyncingStack = true
|
||||
routerPath.path = Array(routerPath.path.prefix(depth))
|
||||
isSyncingStack = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,46 +0,0 @@
|
||||
//
|
||||
// UIKitAppNavigation.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
/// UIKit 导航协调器,供 AppRouter、推送和 UI Test 复用。
|
||||
enum UIKitAppNavigation {
|
||||
weak static var homeNavigationController: UINavigationController?
|
||||
weak static var profileNavigationController: UINavigationController?
|
||||
|
||||
/// Push 首页子模块页面。
|
||||
static func pushHomeRoute(_ route: HomeRoute, animated: Bool = true) {
|
||||
let viewController = AppRouteViewControllerFactory.makeViewController(for: route, services: AppServices.shared)
|
||||
homeNavigationController?.pushViewController(viewController, animated: animated)
|
||||
}
|
||||
|
||||
/// Push 个人中心子页面。
|
||||
static func pushProfileRoute(_ route: ProfileRoute, animated: Bool = true) {
|
||||
let viewController = AppRouteViewControllerFactory.makeViewController(
|
||||
for: .profile(route),
|
||||
services: AppServices.shared
|
||||
)
|
||||
profileNavigationController?.pushViewController(viewController, animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
extension AppRouter {
|
||||
/// 同步路由栈并执行 UIKit push。
|
||||
func navigateHome(_ route: HomeRoute, animated: Bool = true) {
|
||||
select(.home)
|
||||
router(for: .home).navigate(to: .home(route))
|
||||
UIKitAppNavigation.pushHomeRoute(route, animated: animated)
|
||||
}
|
||||
|
||||
/// 同步路由栈并执行 UIKit push。
|
||||
func navigateProfile(_ route: ProfileRoute, animated: Bool = true) {
|
||||
select(.profile)
|
||||
router(for: .profile).navigate(to: .profile(route))
|
||||
UIKitAppNavigation.pushProfileRoute(route, animated: animated)
|
||||
}
|
||||
}
|
||||
@ -124,7 +124,7 @@ final class AuthSessionCoordinator {
|
||||
accountContext: AccountContext,
|
||||
permissionContext: PermissionContext,
|
||||
scenicSpotContext: ScenicSpotContext,
|
||||
appRouter: AppRouter,
|
||||
appNavigator: AppNavigator,
|
||||
toastCenter: ToastCenter
|
||||
) {
|
||||
try? tokenStore.clear()
|
||||
@ -132,7 +132,8 @@ final class AuthSessionCoordinator {
|
||||
accountContext.reset()
|
||||
permissionContext.reset()
|
||||
scenicSpotContext.reset()
|
||||
appRouter.reset()
|
||||
appNavigator.resetAllStacks()
|
||||
appNavigator.detach()
|
||||
toastCenter.dismiss()
|
||||
appSession.logout()
|
||||
}
|
||||
|
||||
@ -7,12 +7,11 @@ import SnapKit
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
/// 应用根 ViewController,根据登录阶段切换子页面并挂载全局 Overlay。
|
||||
/// 应用根协调器,监听登录阶段并在 window 根层级切换登录页与 `MainTabBarController`。
|
||||
final class RootViewController: UIViewController {
|
||||
|
||||
private let services: AppServices
|
||||
private var currentChild: UIViewController?
|
||||
private let restoringView = UIView()
|
||||
private var currentAuthChild: UIViewController?
|
||||
|
||||
/// 初始化实例。
|
||||
init(services: AppServices = .shared) {
|
||||
@ -29,21 +28,14 @@ final class RootViewController: UIViewController {
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
restoringView.backgroundColor = .systemBackground
|
||||
|
||||
services.appSession.onChange = { [weak self] in
|
||||
self?.handleSessionPhaseChange()
|
||||
}
|
||||
|
||||
mountChild(for: services.appSession.phase)
|
||||
applyRoot(for: services.appSession.phase)
|
||||
bootstrapSession()
|
||||
configurePushNotifications()
|
||||
}
|
||||
|
||||
/// 窗口展示后将 Toast 与全局 Loading 挂载到 window 层级。
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
attachGlobalOverlays()
|
||||
services.configurePushNotifications()
|
||||
}
|
||||
|
||||
/// 冷启动时恢复本地会话,并在已登录时申请推送权限。
|
||||
@ -66,21 +58,9 @@ final class RootViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 配置推送管理器与路由跳转回调。
|
||||
private func configurePushNotifications() {
|
||||
services.configurePushNotifications()
|
||||
}
|
||||
|
||||
/// 将全局 Toast 与 Loading 视图附加到当前 window。
|
||||
private func attachGlobalOverlays() {
|
||||
guard let window = view.window else { return }
|
||||
services.toastCenter.attachToWindow(window)
|
||||
services.globalLoading.attachToWindow(window)
|
||||
}
|
||||
|
||||
/// 登录阶段变化时切换子页面,并在登出时清理各上下文状态。
|
||||
/// 登录阶段变化时切换 window 根控制器,并在登出时清理各上下文状态。
|
||||
private func handleSessionPhaseChange() {
|
||||
mountChild(for: services.appSession.phase)
|
||||
applyRoot(for: services.appSession.phase)
|
||||
|
||||
switch services.appSession.phase {
|
||||
case .loggedIn:
|
||||
@ -93,7 +73,8 @@ final class RootViewController: UIViewController {
|
||||
services.accountContext.reset()
|
||||
services.permissionContext.reset()
|
||||
services.scenicSpotContext.reset()
|
||||
services.appRouter.reset()
|
||||
services.appNavigator.resetAllStacks()
|
||||
services.appNavigator.detach()
|
||||
services.toastCenter.dismiss()
|
||||
services.scenicQueueRuntime.stop()
|
||||
case .restoring:
|
||||
@ -113,31 +94,67 @@ final class RootViewController: UIViewController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 按登录阶段挂载对应子控制器:登录页、恢复占位或主 Tab。
|
||||
private func mountChild(for phase: AuthPhase) {
|
||||
let nextChild: UIViewController
|
||||
/// 按登录阶段切换 window 根控制器。
|
||||
private func applyRoot(for phase: AuthPhase) {
|
||||
switch phase {
|
||||
case .loggedOut:
|
||||
nextChild = LoginViewController(services: services)
|
||||
case .restoring:
|
||||
nextChild = restoringViewController()
|
||||
case .loggedIn:
|
||||
nextChild = MainTabBarController(services: services)
|
||||
showMainTabBarAsWindowRoot()
|
||||
case .loggedOut:
|
||||
showAuthRoot(with: LoginViewController(services: services))
|
||||
case .restoring:
|
||||
showAuthRoot(with: restoringViewController())
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录后将 `MainTabBarController` 设为 window 根控制器,满足 CYLTabBarController 挂载要求。
|
||||
private func showMainTabBarAsWindowRoot() {
|
||||
removeCurrentAuthChild()
|
||||
|
||||
guard let window = activeWindow else { return }
|
||||
|
||||
let mainTabBarController = MainTabBarController(services: services)
|
||||
window.rootViewController = mainTabBarController
|
||||
attachGlobalOverlays(to: window)
|
||||
}
|
||||
|
||||
/// 未登录或恢复阶段展示登录/占位页,由当前协调器承载。
|
||||
private func showAuthRoot(with child: UIViewController) {
|
||||
guard let window = activeWindow else {
|
||||
mountAuthChild(child)
|
||||
return
|
||||
}
|
||||
|
||||
guard currentChild !== nextChild else { return }
|
||||
if window.rootViewController !== self {
|
||||
window.rootViewController = self
|
||||
}
|
||||
|
||||
currentChild?.willMove(toParent: nil)
|
||||
currentChild?.view.removeFromSuperview()
|
||||
currentChild?.removeFromParent()
|
||||
mountAuthChild(child)
|
||||
attachGlobalOverlays(to: window)
|
||||
}
|
||||
|
||||
addChild(nextChild)
|
||||
view.addSubview(nextChild.view)
|
||||
nextChild.view.snp.makeConstraints { make in
|
||||
/// 将登录页或恢复占位页挂载为当前协调器的子控制器。
|
||||
private func mountAuthChild(_ child: UIViewController) {
|
||||
guard currentAuthChild !== child else { return }
|
||||
|
||||
currentAuthChild?.willMove(toParent: nil)
|
||||
currentAuthChild?.view.removeFromSuperview()
|
||||
currentAuthChild?.removeFromParent()
|
||||
|
||||
addChild(child)
|
||||
view.addSubview(child.view)
|
||||
child.view.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
nextChild.didMove(toParent: self)
|
||||
currentChild = nextChild
|
||||
child.didMove(toParent: self)
|
||||
currentAuthChild = child
|
||||
}
|
||||
|
||||
/// 移除当前认证阶段子控制器。
|
||||
private func removeCurrentAuthChild() {
|
||||
currentAuthChild?.willMove(toParent: nil)
|
||||
currentAuthChild?.view.removeFromSuperview()
|
||||
currentAuthChild?.removeFromParent()
|
||||
currentAuthChild = nil
|
||||
}
|
||||
|
||||
/// 会话恢复阶段的空白占位页,避免闪烁登录页。
|
||||
@ -146,4 +163,18 @@ final class RootViewController: UIViewController {
|
||||
controller.view.backgroundColor = .systemBackground
|
||||
return controller
|
||||
}
|
||||
|
||||
/// 将全局 Toast 与 Loading 视图附加到指定 window。
|
||||
private func attachGlobalOverlays(to window: UIWindow) {
|
||||
services.toastCenter.attachToWindow(window)
|
||||
services.globalLoading.attachToWindow(window)
|
||||
}
|
||||
|
||||
/// 获取当前 key window。
|
||||
private var activeWindow: UIWindow? {
|
||||
view.window ?? UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.flatMap(\.windows)
|
||||
.first(where: \.isKeyWindow)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user