Add custom main tab bar with global scan-to-verify flow.
Introduce configurable custom TabBar with order badge, central scanner routing to verification orders, and update the migration checklist for recently completed modules. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
155
suixinkan/Features/Main/Views/CustomMainTabBar.swift
Normal file
155
suixinkan/Features/Main/Views/CustomMainTabBar.swift
Normal file
@ -0,0 +1,155 @@
|
||||
//
|
||||
// CustomMainTabBar.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 自定义主 TabBar,展示四个一级入口和中间扫码核销按钮。
|
||||
struct CustomMainTabBar: View {
|
||||
@Binding var selectedTab: AppTab
|
||||
|
||||
let orderBadgeText: String?
|
||||
let onScanTap: () -> Void
|
||||
|
||||
private let items = CustomMainTabItem.defaultItems
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
CustomMainTabBarItem(
|
||||
item: items[0],
|
||||
selectedTab: $selectedTab,
|
||||
badgeText: nil
|
||||
)
|
||||
CustomMainTabBarItem(
|
||||
item: items[1],
|
||||
selectedTab: $selectedTab,
|
||||
badgeText: orderBadgeText
|
||||
)
|
||||
CustomMainTabBarCenterAction(onScanTap: onScanTap)
|
||||
.frame(width: 74)
|
||||
CustomMainTabBarItem(
|
||||
item: items[2],
|
||||
selectedTab: $selectedTab,
|
||||
badgeText: nil
|
||||
)
|
||||
CustomMainTabBarItem(
|
||||
item: items[3],
|
||||
selectedTab: $selectedTab,
|
||||
badgeText: nil
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 72)
|
||||
.padding(.horizontal, 22)
|
||||
.background(.white)
|
||||
.overlay(alignment: .top) {
|
||||
Rectangle()
|
||||
.fill(Color.black.opacity(0.04))
|
||||
.frame(height: 0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 自定义 TabBar 单个入口的静态配置。
|
||||
private struct CustomMainTabItem {
|
||||
let tab: AppTab
|
||||
let title: String
|
||||
let selectedImage: String
|
||||
let unselectedImage: String
|
||||
|
||||
static let defaultItems: [CustomMainTabItem] = [
|
||||
.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")
|
||||
]
|
||||
}
|
||||
|
||||
/// 自定义 TabBar 的普通入口按钮。
|
||||
private struct CustomMainTabBarItem: View {
|
||||
let item: CustomMainTabItem
|
||||
@Binding var selectedTab: AppTab
|
||||
let badgeText: String?
|
||||
|
||||
private var isSelected: Bool {
|
||||
selectedTab == item.tab
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
selectedTab = item.tab
|
||||
} label: {
|
||||
VStack(spacing: 3) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Image(isSelected ? item.selectedImage : item.unselectedImage)
|
||||
.renderingMode(.original)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 24, height: 24)
|
||||
|
||||
if let badgeText {
|
||||
CustomMainTabBadge(text: badgeText)
|
||||
}
|
||||
}
|
||||
Text(item.title)
|
||||
.font(.system(size: 12, weight: isSelected ? .medium : .regular))
|
||||
.foregroundStyle(isSelected ? AppDesign.primary : Color(hex: 0x7D8DA3))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 58)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(item.title)
|
||||
}
|
||||
}
|
||||
|
||||
/// 自定义 TabBar 中间的扫码核销按钮。
|
||||
private struct CustomMainTabBarCenterAction: View {
|
||||
let onScanTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onScanTap) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(AppDesign.primary)
|
||||
.frame(width: 58, height: 58)
|
||||
Image(systemName: "qrcode.viewfinder")
|
||||
.font(.system(size: 30, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
.frame(width: 70, height: 64)
|
||||
.contentShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("扫码核销")
|
||||
}
|
||||
}
|
||||
|
||||
/// 自定义 TabBar 的订单数量角标。
|
||||
private struct CustomMainTabBadge: View {
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(minWidth: 15, minHeight: 15)
|
||||
.padding(.horizontal, 2)
|
||||
.background(Color(hex: 0xEF4444), in: Capsule())
|
||||
.offset(x: 9, y: -7)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
CustomMainTabBar(
|
||||
selectedTab: .constant(.orders),
|
||||
orderBadgeText: "8",
|
||||
onScanTap: {}
|
||||
)
|
||||
}
|
||||
@ -7,8 +7,31 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 主 Tab 容器视图,为每个 Tab 创建独立的 NavigationStack。
|
||||
/// 主 TabBar 样式,决定登录后首页使用系统 TabView 还是自定义底部栏。
|
||||
enum MainTabBarStyle {
|
||||
case system
|
||||
case custom
|
||||
}
|
||||
|
||||
/// 主 TabBar 配置,后续需要切回系统 TabView 时只修改 activeStyle。
|
||||
enum MainTabBarConfiguration {
|
||||
static let activeStyle: MainTabBarStyle = .custom
|
||||
}
|
||||
|
||||
/// 主 Tab 容器视图,根据配置选择系统 TabView 或自定义 TabBar 外壳。
|
||||
struct MainTabsView: View {
|
||||
var body: some View {
|
||||
switch MainTabBarConfiguration.activeStyle {
|
||||
case .system:
|
||||
SystemMainTabsView()
|
||||
case .custom:
|
||||
CustomMainTabsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 系统 TabView 外壳,保留原生 TabBar 行为以便后续切换回系统实现。
|
||||
private struct SystemMainTabsView: View {
|
||||
@Environment(AppRouter.self) private var appRouter
|
||||
|
||||
var body: some View {
|
||||
@ -16,15 +39,7 @@ struct MainTabsView: View {
|
||||
|
||||
TabView(selection: $appRouter.selectedTab) {
|
||||
ForEach(AppTab.allCases) { tab in
|
||||
NavigationStack(path: appRouter.binding(for: tab)) {
|
||||
tab.rootView
|
||||
.navigationTitle(tab.title)
|
||||
.navigationDestination(for: AppRoute.self) { route in
|
||||
route.destinationView
|
||||
.toolbar(route.hidesTabBarWhenPushed ? .hidden : .visible, for: .tabBar)
|
||||
}
|
||||
}
|
||||
.environment(appRouter.router(for: tab))
|
||||
TabNavigationStackHost(tab: tab)
|
||||
.tabItem { tab.label }
|
||||
.tag(tab)
|
||||
}
|
||||
@ -32,7 +47,158 @@ struct MainTabsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 自定义 TabBar 外壳,复用每个 Tab 独立 NavigationStack 并保留已访问页面状态。
|
||||
private struct CustomMainTabsView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(AppRouter.self) private var appRouter
|
||||
@Environment(OrdersAPI.self) private var ordersAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
|
||||
@State private var badgeViewModel = MainTabBadgeViewModel()
|
||||
@State private var showScanner = false
|
||||
@State private var loadedTabs: Set<AppTab> = [.home]
|
||||
|
||||
private var orderBadgeText: String? {
|
||||
guard let count = badgeViewModel.pendingWriteOffCount, count > 0 else {
|
||||
return nil
|
||||
}
|
||||
return "\(count)"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@Bindable var appRouter = appRouter
|
||||
|
||||
ZStack {
|
||||
ForEach(AppTab.allCases) { tab in
|
||||
PreservedTabPage(
|
||||
tab: tab,
|
||||
selectedTab: appRouter.selectedTab,
|
||||
isLoaded: loadedTabs.contains(tab)
|
||||
) {
|
||||
CustomTabNavigationStackHost(
|
||||
tab: tab,
|
||||
selectedTab: $appRouter.selectedTab,
|
||||
orderBadgeText: orderBadgeText,
|
||||
onScanTap: { showScanner = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea(.keyboard, edges: .bottom)
|
||||
.fullScreenCover(isPresented: $showScanner) {
|
||||
OrderScannerPage(
|
||||
onClose: { showScanner = false },
|
||||
onSuccess: { rawCode in
|
||||
showScanner = false
|
||||
appRouter.routeToOrderVerification(scannedCode: rawCode)
|
||||
},
|
||||
onFailure: { error in
|
||||
showScanner = false
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
)
|
||||
}
|
||||
.task {
|
||||
loadedTabs.insert(appRouter.selectedTab)
|
||||
await refreshOrderBadge()
|
||||
}
|
||||
.onChange(of: appRouter.selectedTab) { _, selectedTab in
|
||||
loadedTabs.insert(selectedTab)
|
||||
guard selectedTab == .orders else { return }
|
||||
Task { await refreshOrderBadge() }
|
||||
}
|
||||
.onChange(of: accountContext.currentScenic?.id) { _, _ in
|
||||
Task { await refreshOrderBadge() }
|
||||
}
|
||||
.onChange(of: accountContext.currentStore?.id) { _, _ in
|
||||
Task { await refreshOrderBadge() }
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新订单 Tab 待核销角标数量。
|
||||
private func refreshOrderBadge() async {
|
||||
await badgeViewModel.refreshPendingWriteOffCount(
|
||||
api: ordersAPI,
|
||||
scenicId: accountContext.currentScenic?.id,
|
||||
storeId: accountContext.currentStore?.id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 自定义 TabBar 模式下的单个 Tab 导航栈,TabBar 只属于根页面内容。
|
||||
private struct CustomTabNavigationStackHost: View {
|
||||
@Environment(AppRouter.self) private var appRouter
|
||||
|
||||
let tab: AppTab
|
||||
@Binding var selectedTab: AppTab
|
||||
let orderBadgeText: String?
|
||||
let onScanTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: appRouter.binding(for: tab)) {
|
||||
VStack(spacing: 0) {
|
||||
tab.rootView
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
CustomMainTabBar(
|
||||
selectedTab: $selectedTab,
|
||||
orderBadgeText: orderBadgeText,
|
||||
onScanTap: onScanTap
|
||||
)
|
||||
}
|
||||
.ignoresSafeArea(.keyboard, edges: .bottom)
|
||||
.navigationTitle(tab.title)
|
||||
.navigationDestination(for: AppRoute.self) { route in
|
||||
route.destinationView
|
||||
.toolbar(route.hidesTabBarWhenPushed ? .hidden : .visible, for: .tabBar)
|
||||
}
|
||||
}
|
||||
.environment(appRouter.router(for: tab))
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个 Tab 对应的导航栈,供系统和自定义 TabBar 外壳共同复用。
|
||||
private struct TabNavigationStackHost: View {
|
||||
@Environment(AppRouter.self) private var appRouter
|
||||
|
||||
let tab: AppTab
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: appRouter.binding(for: tab)) {
|
||||
tab.rootView
|
||||
.navigationTitle(tab.title)
|
||||
.navigationDestination(for: AppRoute.self) { route in
|
||||
route.destinationView
|
||||
.toolbar(route.hidesTabBarWhenPushed ? .hidden : .visible, for: .tabBar)
|
||||
}
|
||||
}
|
||||
.environment(appRouter.router(for: tab))
|
||||
}
|
||||
}
|
||||
|
||||
/// 自定义 TabBar 中保持已加载 Tab 页面存活的容器。
|
||||
private struct PreservedTabPage<Content: View>: View {
|
||||
let tab: AppTab
|
||||
let selectedTab: AppTab
|
||||
let isLoaded: Bool
|
||||
@ViewBuilder let content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
if isLoaded {
|
||||
content()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.opacity(selectedTab == tab ? 1 : 0)
|
||||
.zIndex(selectedTab == tab ? 1 : 0)
|
||||
.allowsHitTesting(selectedTab == tab)
|
||||
.accessibilityHidden(selectedTab != tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
MainTabsView()
|
||||
.environment(AppRouter())
|
||||
.environment(AccountContext())
|
||||
.environment(OrdersAPI(client: APIClient()))
|
||||
.environment(ToastCenter())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user