Initial commit: suixinkan_ios UIKit rewrite project.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
59
suixinkan_ios/Features/Main/Main.md
Normal file
59
suixinkan_ios/Features/Main/Main.md
Normal file
@ -0,0 +1,59 @@
|
||||
# Main 模块业务逻辑
|
||||
|
||||
## 模块职责
|
||||
|
||||
Main 模块负责登录后的主界面 Tab 容器,以及当前尚未迁移页面的占位入口。
|
||||
|
||||
主界面由 `MainTabsView` 承载:
|
||||
- 根据 `MainTabBarConfiguration.activeStyle` 选择自定义 TabBar 或系统 `TabView`。
|
||||
- 默认使用自定义 TabBar;如需切回系统实现,将配置改为 `.system`。
|
||||
- 自定义和系统两套外壳都展示首页、订单、数据、我的四个 Tab。
|
||||
- 每个 Tab 内部都包裹独立的 `NavigationStack`,并通过 `TabNavigationStackHost` 复用同一套导航构建逻辑。
|
||||
- 每个 Tab 的导航路径由 `AppRouter` 单独保存。
|
||||
|
||||
## Tab 结构
|
||||
|
||||
当前 Tab 来自 `AppTab`:
|
||||
- `home`:首页
|
||||
- `orders`:订单
|
||||
- `statistics`:数据
|
||||
- `profile`:我的
|
||||
|
||||
`HomeRootView` 已接入真实的 `HomeView`,`OrdersRootView` 已接入真实的 `OrdersView`,`StatisticsRootView` 已接入真实的 `StatisticsView`,`ProfileRootView` 已接入真实的 `ProfileView`。
|
||||
|
||||
## 导航流程
|
||||
|
||||
1. `MainTabsView` 从 Environment 读取 `AppRouter`。
|
||||
2. `MainTabsView` 根据 `MainTabBarConfiguration.activeStyle` 选择 `CustomMainTabsView` 或 `SystemMainTabsView`。
|
||||
3. 两套外壳都使用 `appRouter.selectedTab` 作为选中状态。
|
||||
4. 每个 Tab 通过 `TabNavigationStackHost` 创建自己的 `NavigationStack(path:)`。
|
||||
5. 路径绑定来自 `appRouter.binding(for:)`。
|
||||
6. Tab 内页面需要进入尚未迁移的子页面时,通过当前 Tab 注入的 `RouterPath` push 一个 `AppRoute.placeholder`。
|
||||
7. `navigationDestination` 根据 `AppRoute` 展示详情页。
|
||||
8. 子页面展示时读取 `AppRoute.hidesTabBarWhenPushed`,默认隐藏底部 TabBar。
|
||||
|
||||
## 自定义 TabBar
|
||||
|
||||
自定义 TabBar 由 `CustomMainTabsView` 和 `CustomMainTabBar` 组成:
|
||||
- `CustomMainTabsView` 负责保留已访问 Tab 页面、刷新订单角标、展示扫码页。
|
||||
- `CustomTabNavigationStackHost` 会把 `CustomMainTabBar` 拼在每个 Tab 的根页面内容下方。
|
||||
- `CustomMainTabBar` 只负责展示 UI,不直接读取账号、订单 API 或业务上下文。
|
||||
- `MainTabBadgeViewModel` 通过 `OrdersAPI.writeOffList` 获取待核销数量,失败时静默清空角标。
|
||||
- 中间扫码按钮使用订单模块已有的 `OrderScannerPage`。
|
||||
- 扫码成功后调用 `AppRouter.routeToOrderVerification(scannedCode:)`,订单页再通过 `consumePendingOrderScanCode()` 一次性消费结果。
|
||||
- 自定义 TabBar 只属于 Tab 根页面内容;当前 Tab 的导航栈 push 到二级页面后,目标页面不包含 TabBar,也不会保留底部占位高度。
|
||||
- 承载根页面和 `CustomMainTabBar` 的容器需要忽略键盘底部安全区,避免订单搜索框等输入控件唤起键盘时把自定义 TabBar 顶起。
|
||||
|
||||
系统 `TabView` 外壳由 `SystemMainTabsView` 保留。系统模式不显示中间扫码按钮,但订单页内部的扫码核销入口仍然可用。
|
||||
|
||||
## 后续迁移规则
|
||||
|
||||
迁移新页面时:
|
||||
- 优先替换对应 Tab 的 RootView。
|
||||
- 保持每个 Tab 自己的 `NavigationStack`。
|
||||
- 跨 Tab 跳转通过 `AppRouter.select` 切换 Tab。
|
||||
- 需要从首页或全局入口进入订单核销时,优先使用 `AppRouter.selectOrders(entry:)` 或 `AppRouter.routeToOrderVerification(scannedCode:)`。
|
||||
- Tab 内跳转通过当前 Tab 的 `RouterPath.navigate` 或扩展后的 `AppRoute` 处理。
|
||||
- 普通业务子页面默认隐藏 TabBar;只有确有产品需求时,才在 `AppRoute` 策略中单独放开。
|
||||
- 不要把业务状态塞进自定义 TabBar 组件;TabBar 只接收绑定、文案和动作回调。
|
||||
- 真实业务页面接入后,应同步补充该模块文档和单元测试。
|
||||
@ -0,0 +1,351 @@
|
||||
//
|
||||
// MainTabBarController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
/// 主 Tab 容器,使用自定义底部栏展示四个一级入口和中间扫码按钮。
|
||||
final class MainTabBarController: UIViewController {
|
||||
|
||||
private let services: AppServices
|
||||
private let badgeViewModel = MainTabBadgeViewModel()
|
||||
private let contentContainer = UIView()
|
||||
private let customTabBar = CustomMainTabBarView()
|
||||
|
||||
private var tabNavigationControllers: [AppTab: TabNavigationController] = [:]
|
||||
private var loadedTabs: Set<AppTab> = [.home]
|
||||
|
||||
init(services: AppServices) {
|
||||
self.services = services
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .white
|
||||
configureLayout()
|
||||
bindRouter()
|
||||
bindBadgeViewModel()
|
||||
bindAccountContext()
|
||||
switchToTab(services.appRouter.selectedTab, animated: false)
|
||||
Task { await refreshOrderBadge() }
|
||||
#if DEBUG
|
||||
Task { await AppUITestRouteDriver.applyIfNeeded(services: services) }
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 控制自定义 TabBar 显隐,push 子页面时隐藏。
|
||||
func setCustomTabBarHidden(_ hidden: Bool, animated: Bool) {
|
||||
let updates = {
|
||||
self.customTabBar.alpha = hidden ? 0 : 1
|
||||
self.customTabBar.isUserInteractionEnabled = !hidden
|
||||
}
|
||||
|
||||
guard animated else {
|
||||
updates()
|
||||
return
|
||||
}
|
||||
|
||||
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseInOut], animations: updates)
|
||||
}
|
||||
|
||||
private func configureLayout() {
|
||||
view.addSubview(contentContainer)
|
||||
view.addSubview(customTabBar)
|
||||
|
||||
customTabBar.onTabSelected = { [weak self] tab in
|
||||
self?.services.appRouter.select(tab)
|
||||
}
|
||||
customTabBar.onScanTapped = { [weak self] in
|
||||
self?.presentScanner()
|
||||
}
|
||||
|
||||
contentContainer.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(customTabBar.snp.top)
|
||||
}
|
||||
|
||||
customTabBar.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide)
|
||||
make.height.equalTo(72)
|
||||
}
|
||||
}
|
||||
|
||||
private func bindRouter() {
|
||||
services.appRouter.onChange = { [weak self] in
|
||||
self?.handleRouterChange()
|
||||
}
|
||||
}
|
||||
|
||||
private func bindBadgeViewModel() {
|
||||
badgeViewModel.onChange = { [weak self] in
|
||||
self?.updateOrderBadge()
|
||||
}
|
||||
}
|
||||
|
||||
private func bindAccountContext() {
|
||||
services.accountContext.onChange = { [weak self] in
|
||||
Task { await self?.refreshOrderBadge() }
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRouterChange() {
|
||||
switchToTab(services.appRouter.selectedTab, animated: false)
|
||||
if services.appRouter.selectedTab == .orders {
|
||||
Task { await refreshOrderBadge() }
|
||||
}
|
||||
}
|
||||
|
||||
private func switchToTab(_ tab: AppTab, animated: Bool) {
|
||||
loadedTabs.insert(tab)
|
||||
customTabBar.selectedTab = tab
|
||||
|
||||
let navigationController = navigationController(for: tab)
|
||||
for child in children where child !== navigationController {
|
||||
child.willMove(toParent: nil)
|
||||
child.view.removeFromSuperview()
|
||||
child.removeFromParent()
|
||||
}
|
||||
|
||||
addChild(navigationController)
|
||||
contentContainer.addSubview(navigationController.view)
|
||||
navigationController.view.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
navigationController.didMove(toParent: self)
|
||||
|
||||
let isRoot = navigationController.viewControllers.count <= 1
|
||||
setCustomTabBarHidden(!isRoot, animated: animated)
|
||||
}
|
||||
|
||||
private func navigationController(for tab: AppTab) -> TabNavigationController {
|
||||
if let existing = tabNavigationControllers[tab] {
|
||||
return existing
|
||||
}
|
||||
|
||||
let navigationController = TabNavigationController(tab: tab, services: services)
|
||||
navigationController.tabBarHost = self
|
||||
tabNavigationControllers[tab] = navigationController
|
||||
return navigationController
|
||||
}
|
||||
|
||||
private func updateOrderBadge() {
|
||||
guard let count = badgeViewModel.pendingWriteOffCount, count > 0 else {
|
||||
customTabBar.orderBadgeText = nil
|
||||
return
|
||||
}
|
||||
customTabBar.orderBadgeText = "\(count)"
|
||||
}
|
||||
|
||||
private func refreshOrderBadge() async {
|
||||
await badgeViewModel.refreshPendingWriteOffCount(
|
||||
api: services.ordersAPI,
|
||||
scenicId: services.accountContext.currentScenic?.id,
|
||||
storeId: services.accountContext.currentStore?.id
|
||||
)
|
||||
}
|
||||
|
||||
private func presentScanner() {
|
||||
let scanner = OrderCodeScannerViewController()
|
||||
scanner.onScanResult = { [weak self] result in
|
||||
guard let self else { return }
|
||||
switch result {
|
||||
case .success(let rawCode):
|
||||
dismiss(animated: true) {
|
||||
self.services.appRouter.routeToOrderVerification(scannedCode: rawCode)
|
||||
}
|
||||
case .failure(let error):
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
let navigation = UINavigationController(rootViewController: scanner)
|
||||
navigation.modalPresentationStyle = .fullScreen
|
||||
present(navigation, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// 自定义主 TabBar,展示四个一级入口和中间扫码核销按钮。
|
||||
private final class CustomMainTabBarView: UIView {
|
||||
|
||||
var selectedTab: AppTab = .home {
|
||||
didSet { refreshSelection() }
|
||||
}
|
||||
|
||||
var orderBadgeText: String? {
|
||||
didSet { ordersBadgeLabel.text = orderBadgeText; ordersBadgeLabel.isHidden = orderBadgeText == nil }
|
||||
}
|
||||
|
||||
var onTabSelected: ((AppTab) -> Void)?
|
||||
var onScanTapped: (() -> Void)?
|
||||
|
||||
private struct TabItem {
|
||||
let tab: AppTab
|
||||
let title: String
|
||||
let selectedImage: String
|
||||
let unselectedImage: String
|
||||
}
|
||||
|
||||
private let items: [TabItem] = [
|
||||
.init(tab: .home, title: "首页", selectedImage: "TabHomeSelected", unselectedImage: "TabHomeUnselected"),
|
||||
.init(tab: .orders, title: "订单", selectedImage: "TabOrderSelected", unselectedImage: "TabOrderUnselected"),
|
||||
.init(tab: .statistics, title: "数据", selectedImage: "TabDataSelected", unselectedImage: "TabDataUnselected"),
|
||||
.init(tab: .profile, title: "我的", selectedImage: "TabProfileSelected", unselectedImage: "TabProfileUnselected")
|
||||
]
|
||||
|
||||
private var tabButtons: [AppTab: UIButton] = [:]
|
||||
private var tabTitleLabels: [AppTab: UILabel] = [:]
|
||||
private let ordersBadgeLabel = UILabel()
|
||||
private let topDivider = UIView()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white
|
||||
configureViews()
|
||||
refreshSelection()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
nil
|
||||
}
|
||||
|
||||
private func configureViews() {
|
||||
topDivider.backgroundColor = UIColor.black.withAlphaComponent(0.04)
|
||||
addSubview(topDivider)
|
||||
topDivider.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
make.height.equalTo(0.5)
|
||||
}
|
||||
|
||||
let leftStack = UIStackView()
|
||||
leftStack.axis = .horizontal
|
||||
leftStack.distribution = .fillEqually
|
||||
leftStack.spacing = 0
|
||||
|
||||
let rightStack = UIStackView()
|
||||
rightStack.axis = .horizontal
|
||||
rightStack.distribution = .fillEqually
|
||||
rightStack.spacing = 0
|
||||
|
||||
for (index, item) in items.enumerated() {
|
||||
let buttonContainer = makeTabButton(for: item)
|
||||
if index < 2 {
|
||||
leftStack.addArrangedSubview(buttonContainer)
|
||||
} else {
|
||||
rightStack.addArrangedSubview(buttonContainer)
|
||||
}
|
||||
}
|
||||
|
||||
let scanButton = UIButton(type: .system)
|
||||
scanButton.backgroundColor = AppDesign.primary
|
||||
scanButton.layer.cornerRadius = 29
|
||||
scanButton.tintColor = .white
|
||||
scanButton.setImage(
|
||||
UIImage(systemName: "qrcode.viewfinder")?.withConfiguration(
|
||||
UIImage.SymbolConfiguration(pointSize: 30, weight: .bold)
|
||||
),
|
||||
for: .normal
|
||||
)
|
||||
scanButton.accessibilityLabel = "扫码核销"
|
||||
scanButton.accessibilityIdentifier = "main.scan"
|
||||
scanButton.addTarget(self, action: #selector(scanTapped), for: .touchUpInside)
|
||||
|
||||
let row = UIStackView(arrangedSubviews: [leftStack, scanButton, rightStack])
|
||||
row.axis = .horizontal
|
||||
row.alignment = .center
|
||||
row.spacing = 0
|
||||
addSubview(row)
|
||||
|
||||
scanButton.snp.makeConstraints { make in
|
||||
make.width.equalTo(74)
|
||||
make.height.equalTo(64)
|
||||
}
|
||||
|
||||
row.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview().inset(22)
|
||||
make.top.bottom.equalToSuperview()
|
||||
}
|
||||
|
||||
leftStack.snp.makeConstraints { make in
|
||||
make.width.equalTo(rightStack)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeTabButton(for item: TabItem) -> UIView {
|
||||
let container = UIView()
|
||||
|
||||
let button = UIButton(type: .custom)
|
||||
button.accessibilityLabel = item.title
|
||||
button.accessibilityIdentifier = "main.tab.\(item.tab.rawValue)"
|
||||
button.tag = items.firstIndex(where: { $0.tab == item.tab }) ?? 0
|
||||
button.addTarget(self, action: #selector(tabTapped(_:)), for: .touchUpInside)
|
||||
tabButtons[item.tab] = button
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = item.title
|
||||
titleLabel.font = .systemFont(ofSize: 12)
|
||||
titleLabel.textAlignment = .center
|
||||
tabTitleLabels[item.tab] = titleLabel
|
||||
|
||||
container.addSubview(button)
|
||||
container.addSubview(titleLabel)
|
||||
|
||||
button.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(8)
|
||||
make.centerX.equalToSuperview()
|
||||
make.width.height.equalTo(24)
|
||||
}
|
||||
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(button.snp.bottom).offset(3)
|
||||
make.centerX.equalToSuperview()
|
||||
make.bottom.lessThanOrEqualToSuperview()
|
||||
}
|
||||
|
||||
if item.tab == .orders {
|
||||
ordersBadgeLabel.font = .systemFont(ofSize: 9, weight: .bold)
|
||||
ordersBadgeLabel.textColor = .white
|
||||
ordersBadgeLabel.backgroundColor = UIColor(hex: 0xEF4444)
|
||||
ordersBadgeLabel.textAlignment = .center
|
||||
ordersBadgeLabel.layer.cornerRadius = 7.5
|
||||
ordersBadgeLabel.clipsToBounds = true
|
||||
ordersBadgeLabel.isHidden = true
|
||||
container.addSubview(ordersBadgeLabel)
|
||||
ordersBadgeLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(button).offset(-4)
|
||||
make.leading.equalTo(button.snp.trailing).offset(-6)
|
||||
make.height.equalTo(15)
|
||||
make.width.greaterThanOrEqualTo(15)
|
||||
}
|
||||
}
|
||||
|
||||
return container
|
||||
}
|
||||
|
||||
private func refreshSelection() {
|
||||
for item in items {
|
||||
let isSelected = item.tab == selectedTab
|
||||
let imageName = isSelected ? item.selectedImage : item.unselectedImage
|
||||
tabButtons[item.tab]?.setImage(UIImage(named: imageName), for: .normal)
|
||||
tabTitleLabels[item.tab]?.textColor = isSelected ? AppDesign.primary : UIColor(hex: 0x7D8DA3)
|
||||
tabTitleLabels[item.tab]?.font = .systemFont(ofSize: 12, weight: isSelected ? .medium : .regular)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func tabTapped(_ sender: UIButton) {
|
||||
guard sender.tag >= 0, sender.tag < items.count else { return }
|
||||
onTabSelected?(items[sender.tag].tab)
|
||||
}
|
||||
|
||||
@objc private func scanTapped() {
|
||||
onScanTapped?()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
//
|
||||
// PlaceholderViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 未知或未实现路由的占位页。
|
||||
final class PlaceholderViewController: UIViewController {
|
||||
|
||||
private let pageTitle: String
|
||||
|
||||
init(title: String) {
|
||||
pageTitle = title
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
||||
title = pageTitle
|
||||
|
||||
let label = UILabel()
|
||||
label.text = pageTitle
|
||||
label.font = .systemFont(ofSize: AppMetrics.FontSize.title2, weight: .semibold)
|
||||
label.textColor = AppDesign.textPrimary
|
||||
label.textAlignment = .center
|
||||
label.numberOfLines = 0
|
||||
view.addSubview(label)
|
||||
label.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.large)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
//
|
||||
// MainTabBadgeViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 主 Tab 角标 ViewModel,负责获取订单 Tab 待核销数量。
|
||||
final class MainTabBadgeViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var pendingWriteOffCount: Int? { didSet { onChange?() } }
|
||||
|
||||
/// 刷新待核销订单数量,缺少景区或接口失败时清空角标。
|
||||
func refreshPendingWriteOffCount(api: OrderServing, scenicId: Int?, storeId: Int?) async {
|
||||
guard let scenicId, scenicId > 0 else {
|
||||
pendingWriteOffCount = nil
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let result = try await api.writeOffList(scenicId: scenicId, storeId: storeId, page: 1, pageSize: 1)
|
||||
pendingWriteOffCount = min(result.total, 99)
|
||||
} catch {
|
||||
pendingWriteOffCount = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user