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:
@ -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` 策略中单独放开。
|
||||
- 真实业务页面接入后,应同步补充该模块文档和单元测试。
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
77
suixinkan_ios/Features/Main/Views/MainScanPlusButton.swift
Normal file
77
suixinkan_ios/Features/Main/Views/MainScanPlusButton.swift
Normal 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?()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user