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:
2026-06-26 16:36:18 +08:00
parent 24a7339b68
commit a1c031c9b7
31 changed files with 838 additions and 517 deletions

View File

@ -4,7 +4,7 @@
Main 模块负责登录后的主界面 Tab 容器。
主界面由 `MainTabBarController` 承载,使用系统 `UITabBarController` 展示首页、订单、数据、我的四个 Tab。每个 Tab 内部包裹独立的 `TabNavigationController`导航路径由 `AppRouter` 单独保存
主界面由 `MainTabBarController` 承载,使用 **CYLTabBarController** 展示首页、订单、数据、我的四个 Tab,并在中间提供扫码核销按钮。每个 Tab 内部包裹独立的 `TabNavigationController`页面路径以 UIKit 导航栈为唯一事实源
## Tab 结构
@ -18,11 +18,11 @@ Main 模块负责登录后的主界面 Tab 容器。
## 导航流程
1. `MainTabBarController` 读取 `AppServices.appRouter` 作为选中 Tab 状态源
2. 用户点击 TabBar 或通过 `AppRouter.select` 切换 Tab 时,两边状态保持同步
1. `MainTabBarController` 在登录后注册为 `AppNavigator` 的导航宿主
2. 用户点击 TabBar 只改变 UIKit 选中态,并保留各 Tab 原有导航栈
3. 每个 Tab 由 `TabNavigationController` 创建独立 `UINavigationController` 栈。
4. Tab 内 push 子页面时,通过 `AppRoute.hidesTabBarWhenPushed` 控制是否隐藏系统 TabBar默认隐藏。
5. 跨 Tab 跳转通过 `AppRouter.select``AppRouter.selectOrders(entry:)` 处理
5. 跨 Tab、推送通知、UI Test 直达和全局扫码统一通过 `AppNavigator` 执行
## 订单角标
@ -32,15 +32,21 @@ Main 模块负责登录后的主界面 Tab 容器。
## 扫码核销
系统 TabBar 不展示中间扫码按钮;订单页内部的扫码核销入口仍然可用。如需全局扫码入口,可通过首页或订单页进入。
TabBar 中间通过 `MainScanPlusButton`CYLTabBarController PlusButton展示全局扫码入口点击后全屏打开 `OrderCodeScannerViewController`
1. 扫码成功 → `AppNavigator.openOrdersEntry(.verificationOrders, scannedCode:)` 切换至订单核销入口并传入扫码结果。
2. `OrdersViewController` 本地保存并消费扫码结果后进入确认核销流程。
3. 扫码失败 → 通过 `ToastCenter` 提示错误。
订单页内部的扫码核销入口仍然可用,与 TabBar 中间按钮为双入口。
## 后续迁移规则
迁移新页面时:
- 优先替换对应 Tab 的根 ViewController。
- 保持每个 Tab 自己的 `TabNavigationController` 导航栈。
- 跨 Tab 跳转通过 `AppRouter.select` 切换 Tab
- 需要从首页或全局入口进入订单核销时,优先使用 `AppRouter.selectOrders(entry:)``AppRouter.routeToOrderVerification(scannedCode:)`
- Tab 内跳转通过当前 Tab 的 `RouterPath.navigate` 或扩展后的 `AppRoute` 处理。
- 跨 Tab 跳转通过 `AppNavigator.selectTab` 或对应业务入口方法执行
- 需要从首页或全局入口进入订单核销时,优先使用 `AppNavigator.openOrdersEntry(_:scannedCode:)`
- Tab 内跳转通过 `AppNavigator.push(_:from:)` 或扩展后的 `AppRoute` 处理。
- 普通业务子页面默认隐藏 TabBar只有确有产品需求时才在 `AppRoute` 策略中单独放开。
- 真实业务页面接入后,应同步补充该模块文档和单元测试。

View File

@ -3,11 +3,12 @@
// suixinkan
//
import CYLTabBarController
import UIKit
@MainActor
/// Tab 使 `UITabBarController`
final class MainTabBarController: UITabBarController, UITabBarControllerDelegate {
/// Tab 使 CYLTabBarController
final class MainTabBarController: CYLTabBarController {
private let services: AppServices
private let badgeViewModel = MainTabBadgeViewModel()
@ -17,7 +18,11 @@ final class MainTabBarController: UITabBarController, UITabBarControllerDelegate
///
init(services: AppServices) {
self.services = services
MainScanPlusButton.register()
super.init(nibName: nil, bundle: nil)
MainScanPlusButton.onScanTap = { [weak self] in
self?.presentGlobalScanner()
}
delegate = self
}
@ -31,36 +36,72 @@ final class MainTabBarController: UITabBarController, UITabBarControllerDelegate
super.viewDidLoad()
configureTabBarAppearance()
configureTabs()
bindRouter()
services.appNavigator.attach(host: self)
bindBadgeViewModel()
bindAccountContext()
syncSelectedTabFromRouter(animated: false)
Task { await refreshOrderBadge() }
#if DEBUG
Task { await AppUITestRouteDriver.applyIfNeeded(services: services) }
#endif
}
/// TabBar
private func configureTabBarAppearance() {
tabBar.tintColor = AppDesign.primary
tabBar.backgroundColor = .white
}
/// Tab TabBarItem
private func configureTabs() {
viewControllers = AppTab.allCases.map { tab in
let navigationController = TabNavigationController(tab: tab, services: services)
navigationController.tabBarItem = tab.makeTabBarItem()
tabNavigationControllers[tab] = navigationController
return navigationController
///
deinit {
Task { @MainActor in
MainScanPlusButton.onScanTap = nil
}
}
/// Router Tab
private func bindRouter() {
services.appRouter.onChange = { [weak self] in
self?.handleRouterChange()
/// TabBar CustomMainTabBar
private func configureTabBarAppearance() {
tabBar.tintColor = AppDesign.primary
tabBar.backgroundColor = .white
tabBar.shadowImage = UIImage()
let normalAttributes: [NSAttributedString.Key: Any] = [
.foregroundColor: UIColor(hex: 0x7D8DA3),
.font: UIFont.systemFont(ofSize: 12, weight: .regular)
]
let selectedAttributes: [NSAttributedString.Key: Any] = [
.foregroundColor: AppDesign.primary,
.font: UIFont.systemFont(ofSize: 12, weight: .medium)
]
let appearance = UITabBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = .white
appearance.shadowColor = .clear
appearance.stackedLayoutAppearance.normal.titleTextAttributes = normalAttributes
appearance.stackedLayoutAppearance.selected.titleTextAttributes = selectedAttributes
tabBar.standardAppearance = appearance
tabBar.scrollEdgeAppearance = appearance
}
/// Tab CYLTabBarController Tab
private func configureTabs() {
let tabs = AppTab.allCases
tabBarItemsAttributes = tabs.map { makeTabBarItemAttributes(for: $0) }
viewControllers = tabs.map { tab in
let navigationController = TabNavigationController(tab: tab, services: services)
tabNavigationControllers[tab] = navigationController
return navigationController
}
applyTabAccessibilityIdentifiers()
}
/// CYLTabBarController TabBarItem
private func makeTabBarItemAttributes(for tab: AppTab) -> [String: Any] {
[
CYLTabBarItemTitle: tab.title,
CYLTabBarItemImage: tab.unselectedImageName,
CYLTabBarItemSelectedImage: tab.selectedImageName
]
}
/// CYL TabBarItem
private func applyTabAccessibilityIdentifiers() {
for tab in AppTab.allCases {
tabNavigationControllers[tab]?.tabBarItem.accessibilityIdentifier = "main.tab.\(tab.rawValue)"
}
}
@ -78,24 +119,6 @@ final class MainTabBarController: UITabBarController, UITabBarControllerDelegate
}
}
/// Router Tab `AppRouter`
private func handleRouterChange() {
syncSelectedTabFromRouter(animated: false)
if services.appRouter.selectedTab == .orders {
Task { await refreshOrderBadge() }
}
}
/// `AppRouter.selectedTab` TabBar
private func syncSelectedTabFromRouter(animated: Bool) {
guard let index = AppTab.allCases.firstIndex(of: services.appRouter.selectedTab) else { return }
guard selectedIndex != index else { return }
isSyncingTabSelection = true
selectedIndex = index
isSyncingTabSelection = false
}
/// Tab
private func updateOrderBadge() {
let badgeValue: String?
@ -116,14 +139,71 @@ final class MainTabBarController: UITabBarController, UITabBarControllerDelegate
)
}
/// TabBar `AppRouter`
func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
/// TabBar
private func presentGlobalScanner() {
let scanner = OrderCodeScannerViewController()
scanner.onScanResult = { [weak self, weak scanner] result in
scanner?.dismiss(animated: true)
guard let self else { return }
switch result {
case .success(let code):
self.services.appNavigator.openOrdersEntry(.verificationOrders, scannedCode: code)
case .failure(let error):
self.services.toastCenter.show(error.localizedDescription)
}
}
let navigationController = UINavigationController(rootViewController: scanner)
navigationController.modalPresentationStyle = .fullScreen
present(navigationController, animated: true)
}
/// TabBar
override func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
guard !isSyncingTabSelection,
let navigationController = viewController as? TabNavigationController else { return }
services.appRouter.select(navigationController.appTab)
if navigationController.appTab == .orders {
Task { await refreshOrderBadge() }
}
}
}
extension MainTabBarController: AppNavigationHost {
/// Tab
func selectTab(_ tab: AppTab) {
guard let index = AppTab.allCases.firstIndex(of: tab) else { return }
guard selectedIndex != index else { return }
isSyncingTabSelection = true
selectedIndex = index
isSyncingTabSelection = false
if tab == .orders {
Task { await refreshOrderBadge() }
}
}
/// Tab
func navigationController(for tab: AppTab) -> TabNavigationController? {
tabNavigationControllers[tab]
}
/// Tab
func selectedNavigationController() -> TabNavigationController? {
selectedViewController as? TabNavigationController
}
/// Tab
func resetAllStacks() {
for navigationController in tabNavigationControllers.values {
navigationController.popToRootViewController(animated: false)
}
selectTab(.home)
}
///
func applyOrdersEntry(_ entry: OrdersEntry, scannedCode: String?) {
guard let ordersController = tabNavigationControllers[.orders]?.viewControllers.first as? OrdersViewController else {
return
}
ordersController.applyOrdersEntry(entry, scannedCode: scannedCode)
}
}

View File

@ -0,0 +1,77 @@
//
// MainScanPlusButton.swift
// suixinkan
//
import CYLTabBarController
import UIKit
@MainActor
@objcMembers
/// TabBar CYLTabBarController PlusButton
final class MainScanPlusButton: CYLPlusButton, CYLPlusButtonSubclassing {
/// `MainTabBarController`
static var onScanTap: (() -> Void)?
/// CYLTabBarController 使
static func plusButton() -> Any {
let button = MainScanPlusButton(type: .custom)
button.frame = CGRect(x: 0, y: 0, width: 70, height: 64)
button.configureAppearance()
button.addTarget(button, action: #selector(handleScanTap), for: .touchUpInside)
return button
}
/// TabBar
static func indexOfPlusButtonInTabBar() -> UInt {
2
}
/// 使 TabBar
static func constantOfPlusButtonCenterYOffset(forTabBarHeight tabBarHeight: CGFloat) -> CGFloat {
-6
}
/// CustomMainTabBarCenterAction
private func configureAppearance() {
backgroundColor = .clear
let circleSize: CGFloat = 58
let circleView = UIView()
circleView.isUserInteractionEnabled = false
circleView.backgroundColor = AppDesign.primary
circleView.layer.cornerRadius = circleSize / 2
addSubview(circleView)
let symbolConfig = UIImage.SymbolConfiguration(pointSize: 30, weight: .bold)
let iconView = UIImageView(
image: UIImage(systemName: "qrcode.viewfinder", withConfiguration: symbolConfig)
)
iconView.tintColor = .white
iconView.contentMode = .scaleAspectFit
iconView.isUserInteractionEnabled = false
addSubview(iconView)
circleView.translatesAutoresizingMaskIntoConstraints = false
iconView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
circleView.centerXAnchor.constraint(equalTo: centerXAnchor),
circleView.centerYAnchor.constraint(equalTo: centerYAnchor),
circleView.widthAnchor.constraint(equalToConstant: circleSize),
circleView.heightAnchor.constraint(equalToConstant: circleSize),
iconView.centerXAnchor.constraint(equalTo: circleView.centerXAnchor),
iconView.centerYAnchor.constraint(equalTo: circleView.centerYAnchor),
iconView.widthAnchor.constraint(equalToConstant: 30),
iconView.heightAnchor.constraint(equalToConstant: 30)
])
accessibilityLabel = "扫码核销"
accessibilityIdentifier = "main.scan"
}
///
@objc private func handleScanTap() {
Self.onScanTap?()
}
}