集成 MJRefresh 并重构主 TabBar 为系统 TabView 样式。

新增 SwiftUI 桥接层与单元测试,更新 Tab 图标资源命名和多倍图,同步调整 UI Test 的 Tab/扫码定位逻辑。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 10:22:53 +08:00
parent 79e735f628
commit 8997d1ba1c
140 changed files with 6574 additions and 877 deletions

View File

@ -42,6 +42,34 @@ enum AppTab: String, CaseIterable, Identifiable, Hashable {
}
}
/// Tab 使
var selectedImageName: String {
switch self {
case .home:
"tab_home_selected"
case .orders:
"tab_order_selected"
case .statistics:
"tab_data_selected"
case .profile:
"tab_profile_selected"
}
}
/// Tab 使
var unselectedImageName: String {
switch self {
case .home:
"tab_home_unselected"
case .orders:
"tab_order_unselected"
case .statistics:
"tab_data_unselected"
case .profile:
"tab_profile_unselected"
}
}
@ViewBuilder
var rootView: some View {
switch self {

View File

@ -14,6 +14,7 @@ struct suixinkanApp: App {
init() {
AppUITestLaunchState.resetIfNeeded()
MJRefreshSupport.configure()
}
var body: some Scene {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "icon_scan.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "icon_scan@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "icon_scan@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1022 B

View File

@ -6,10 +6,12 @@
"scale" : "1x"
},
{
"filename" : "tab_data_selected@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "tab_data_selected@3x.png",
"idiom" : "universal",
"scale" : "3x"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 908 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -6,10 +6,12 @@
"scale" : "1x"
},
{
"filename" : "tab_data_unselected@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "tab_data_unselected@3x.png",
"idiom" : "universal",
"scale" : "3x"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -6,10 +6,12 @@
"scale" : "1x"
},
{
"filename" : "tab_home_selected@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "tab_home_selected@3x.png",
"idiom" : "universal",
"scale" : "3x"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 867 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -6,10 +6,12 @@
"scale" : "1x"
},
{
"filename" : "tab_home_unselected@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "tab_home_unselected@3x.png",
"idiom" : "universal",
"scale" : "3x"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -6,10 +6,12 @@
"scale" : "1x"
},
{
"filename" : "tab_order_selected@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "tab_order_selected@3x.png",
"idiom" : "universal",
"scale" : "3x"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -6,10 +6,12 @@
"scale" : "1x"
},
{
"filename" : "tab_order_unselected@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "tab_order_unselected@3x.png",
"idiom" : "universal",
"scale" : "3x"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -6,10 +6,12 @@
"scale" : "1x"
},
{
"filename" : "tab_profile_selected@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "tab_profile_selected@3x.png",
"idiom" : "universal",
"scale" : "3x"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -6,10 +6,12 @@
"scale" : "1x"
},
{
"filename" : "tab_profile_unselected@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "tab_profile_unselected@3x.png",
"idiom" : "universal",
"scale" : "3x"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 944 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -91,3 +91,17 @@ Toast UI 是顶部全宽横幅,背景使用不透明主色并延伸到顶部
- 上传进度只用于当前页面展示。
网络图片统一使用 `RemoteImage` / `RemoteAvatarImage`,内部由 Kingfisher 负责下载和缓存。业务页面不要再直接使用 `AsyncImage` 加载网络图片。
### MJRefresh
项目通过 CocoaPods 集成 `MJRefresh`,并在 `Core/Design/MJRefreshSupport.swift` 提供 SwiftUI 桥接能力。App 启动时会调用 `MJRefreshSupport.configure()` 设置默认中文文案。
SwiftUI 列表如需使用 MJRefresh 风格的下拉刷新或上拉加载,可在 `ScrollView` / `List` 上使用:
- `.mjRefresh(onRefresh:onLoadMore:hasMore:)`
- `.mjRefreshHeader { ... }`
- `.mjRefreshFooter(hasMore:action:)`
桥接层会自动定位底层 `UIScrollView` 并绑定 header/footer。页面仍应把真正的数据加载逻辑放在 ViewModel 中modifier 只负责触发刷新和结束 MJRefresh 动画。
若页面已经使用 SwiftUI 原生 `.refreshable`,不要重复叠加 MJRefresh 下拉刷新,避免双刷新冲突。

View File

@ -0,0 +1,205 @@
//
// MJRefreshSupport.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import MJRefresh
import SwiftUI
import UIKit
/// MJRefresh
enum MJRefreshSupport {
/// MJRefresh
static func configure() {
MJRefreshConfig.default.languageCode = "zh-Hans"
}
}
/// MJRefresh
struct MJRefreshBindingConfiguration {
var onRefresh: (() async -> Void)?
var onLoadMore: (() async -> Void)?
var hasMore = false
///
var isEmpty: Bool {
onRefresh == nil && onLoadMore == nil
}
}
extension View {
/// SwiftUI `ScrollView` / `List` MJRefresh
func mjRefresh(
onRefresh: (() async -> Void)? = nil,
onLoadMore: (() async -> Void)? = nil,
hasMore: Bool = false
) -> some View {
modifier(
MJRefreshModifier(
configuration: MJRefreshBindingConfiguration(
onRefresh: onRefresh,
onLoadMore: onLoadMore,
hasMore: hasMore
)
)
)
}
/// SwiftUI MJRefresh
func mjRefreshHeader(_ action: @escaping () async -> Void) -> some View {
mjRefresh(onRefresh: action)
}
/// SwiftUI MJRefresh
func mjRefreshFooter(hasMore: Bool, action: @escaping () async -> Void) -> some View {
mjRefresh(onLoadMore: action, hasMore: hasMore)
}
}
/// SwiftUI MJRefresh `UIScrollView`
private struct MJRefreshModifier: ViewModifier {
let configuration: MJRefreshBindingConfiguration
func body(content: Content) -> some View {
content.background {
MJRefreshScrollAnchor(configuration: configuration)
.frame(width: 0, height: 0)
}
}
}
/// SwiftUI MJRefresh
private struct MJRefreshScrollAnchor: UIViewRepresentable {
let configuration: MJRefreshBindingConfiguration
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeUIView(context: Context) -> UIView {
let view = UIView(frame: .zero)
view.isUserInteractionEnabled = false
view.backgroundColor = .clear
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
context.coordinator.scheduleApply(configuration: configuration, from: uiView)
}
static func dismantleUIView(_ uiView: UIView, coordinator: Coordinator) {
coordinator.unbind(from: uiView)
}
/// MJRefresh header/footer
final class Coordinator {
private weak var scrollView: UIScrollView?
private var configuration = MJRefreshBindingConfiguration()
private var pendingApply: DispatchWorkItem?
/// runloop SwiftUI
func scheduleApply(configuration: MJRefreshBindingConfiguration, from anchorView: UIView) {
pendingApply?.cancel()
let workItem = DispatchWorkItem { [weak self, weak anchorView] in
guard let self, let anchorView else { return }
guard let scrollView = anchorView.enclosingScrollView else { return }
self.apply(configuration: configuration, to: scrollView)
}
pendingApply = workItem
DispatchQueue.main.async(execute: workItem)
}
///
private func apply(configuration: MJRefreshBindingConfiguration, to scrollView: UIScrollView) {
self.scrollView = scrollView
self.configuration = configuration
if configuration.isEmpty {
scrollView.mj_header = nil
scrollView.mj_footer = nil
return
}
if let onRefresh = configuration.onRefresh {
if scrollView.mj_header == nil {
scrollView.mj_header = MJRefreshNormalHeader { [weak self] in
self?.performHeaderRefresh(onRefresh)
}
}
} else {
scrollView.mj_header = nil
}
if let onLoadMore = configuration.onLoadMore {
if scrollView.mj_footer == nil {
scrollView.mj_footer = MJRefreshAutoNormalFooter { [weak self] in
self?.performFooterLoadMore(onLoadMore)
}
}
updateFooterState(hasMore: configuration.hasMore)
} else {
scrollView.mj_footer = nil
}
}
/// MJRefresh
private func performHeaderRefresh(_ action: @escaping () async -> Void) {
Task { @MainActor in
await action()
await scrollView?.mj_header?.endRefreshing()
}
}
/// MJRefresh
private func performFooterLoadMore(_ action: @escaping () async -> Void) {
Task { @MainActor in
await action()
guard let scrollView else { return }
if configuration.hasMore {
await scrollView.mj_footer?.endRefreshing()
} else {
await scrollView.mj_footer?.endRefreshingWithNoMoreData()
}
}
}
/// footer
private func updateFooterState(hasMore: Bool) {
guard let footer = scrollView?.mj_footer else { return }
if hasMore {
footer.resetNoMoreData()
footer.isHidden = false
} else {
Task { @MainActor in
await footer.endRefreshingWithNoMoreData()
}
}
}
/// MJRefresh
func unbind(from anchorView: UIView) {
pendingApply?.cancel()
pendingApply = nil
guard let scrollView = anchorView.enclosingScrollView, scrollView === self.scrollView else { return }
scrollView.mj_header = nil
scrollView.mj_footer = nil
self.scrollView = nil
}
}
}
extension UIView {
/// `UIScrollView` SwiftUI MJRefresh
var enclosingScrollView: UIScrollView? {
var current: UIView? = self
while let view = current {
if let scrollView = view as? UIScrollView {
return scrollView
}
current = view.superview
}
return nil
}
}

View File

@ -5,11 +5,12 @@
Main 模块负责登录后的主界面 Tab 容器,以及当前尚未迁移页面的占位入口。
主界面由 `MainTabsView` 承载:
- 根据 `MainTabBarConfiguration.activeStyle` 选择自定义 TabBar 或系统 `TabView`
- 默认使用自定义 TabBar如需切回系统实现将配置改为 `.system`
- 自定义和系统两套外壳都展示首页、订单、数据、我的四个 Tab
- 使用系统 `TabView` 展示登录后的底部导航
- 底部按顺序展示首页、订单、扫码、数据、我的五个入口
- 扫码入口只负责打开扫码页,不属于 `AppTab`,也不持有独立业务导航栈
- 每个 Tab 内部都包裹独立的 `NavigationStack`,并通过 `TabNavigationStackHost` 复用同一套导航构建逻辑。
- 每个 Tab 的导航路径由 `AppRouter` 单独保存。
- 订单 Tab 角标由 `MainTabBadgeViewModel` 提供,数量为 0 或获取失败时不显示。
## Tab 结构
@ -24,27 +25,21 @@ Main 模块负责登录后的主界面 Tab 容器,以及当前尚未迁移页
## 导航流程
1. `MainTabsView` 从 Environment 读取 `AppRouter`
2. `MainTabsView` 根据 `MainTabBarConfiguration.activeStyle` 选择 `CustomMainTabsView``SystemMainTabsView`
3. 两套外壳都使`appRouter.selectedTab` 作为选中状态
4. 每个 Tab 通过 `TabNavigationStackHost` 创建自己的 `NavigationStack(path:)`
2. `TabView` 使用内部 `MainTabSelection` 作为选择值,普通 Tab 映射到 `AppTab`,扫码映射到 `.scanner`
3. 普通 Tab 切换时调`AppRouter.select(_:)` 更新当前业务 Tab
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` 中的中间入口,只显示 `qrcode.viewfinder` 图标,并通过无障碍标签标记为“扫码核销”。
系统 `TabView` 外壳由 `SystemMainTabsView` 保留。系统模式不显示中间扫码按钮,但订单页内部的扫码核销入口仍然可用
点击扫码入口时,`MainTabsView` 拦截 `.scanner` 选择并展示订单模块已有的 `OrderScannerPage`,不会修改 `AppRouter.selectedTab`。因此用户关闭扫码页后,仍停留在打开扫码前的 Tab
扫码成功后调用 `AppRouter.routeToOrderVerification(scannedCode:)`,订单页再通过 `consumePendingOrderScanCode()` 一次性消费结果。扫码失败时关闭扫码页并展示错误提示。
## 后续迁移规则
@ -55,5 +50,5 @@ Main 模块负责登录后的主界面 Tab 容器,以及当前尚未迁移页
- 需要从首页或全局入口进入订单核销时,优先使用 `AppRouter.selectOrders(entry:)``AppRouter.routeToOrderVerification(scannedCode:)`
- Tab 内跳转通过当前 Tab 的 `RouterPath.navigate` 或扩展后的 `AppRoute` 处理。
- 普通业务子页面默认隐藏 TabBar只有确有产品需求时才在 `AppRoute` 策略中单独放开。
- 不要把业务状态塞进自定义 TabBar 组件TabBar 只接收绑定、文案和动作回调
- 不要把扫码入口加入 `AppTab`;它不是业务 Tab只是主界面的全局动作入口
- 真实业务页面接入后,应同步补充该模块文档和单元测试。

View File

@ -1,157 +0,0 @@
//
// 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)
.accessibilityIdentifier("main.tab.\(item.tab.rawValue)")
}
}
/// 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("扫码核销")
.accessibilityIdentifier("main.scan")
}
}
/// 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: {}
)
}

View File

@ -7,46 +7,11 @@
import SwiftUI
/// TabBar 使 TabView
enum MainTabBarStyle {
case system
case custom
}
/// TabBar TabView activeStyle
enum MainTabBarConfiguration {
static let activeStyle: MainTabBarStyle = .custom
}
/// Tab TabView TabBar
/// Tab 使 TabView
struct MainTabsView: View {
var body: some View {
switch MainTabBarConfiguration.activeStyle {
case .system:
SystemMainTabsView()
case .custom:
CustomMainTabsView()
}
}
}
private static let leadingTabs: [AppTab] = [.home, .orders]
private static let trailingTabs: [AppTab] = [.statistics, .profile]
/// TabView TabBar 便
private struct SystemMainTabsView: View {
@EnvironmentObject private var appRouter: AppRouter
var body: some View {
TabView(selection: $appRouter.selectedTab) {
ForEach(AppTab.allCases) { tab in
TabNavigationStackHost(tab: tab)
.tabItem { tab.label }
.tag(tab)
}
}
}
}
/// TabBar Tab NavigationStack 访
private struct CustomMainTabsView: View {
@EnvironmentObject private var accountContext: AccountContext
@EnvironmentObject private var appRouter: AppRouter
@Environment(\.ordersAPI) private var ordersAPI
@ -54,33 +19,34 @@ private struct CustomMainTabsView: View {
@StateObject 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)"
}
@State private var tabSelection: MainTabSelection = .tab(.home)
@State private var selectedBusinessTab: AppTab = .home
var body: some View {
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 }
)
TabView(selection: $tabSelection) {
ForEach(Self.leadingTabs) { tab in
TabNavigationStackHost(tab: tab)
.tabItem { tabItem(for: tab) }
.tag(MainTabSelection.tab(tab))
.badge(tab == .orders ? badgeViewModel.pendingWriteOffCount ?? 0 : 0)
}
ScannerTabPlaceholder()
.accessibilityIdentifier("main.scan")
.tabItem {
Image("icon_scan")
.accessibilityIdentifier("main.scan")
.accessibilityLabel("扫码核销")
}
.tag(MainTabSelection.scanner)
ForEach(Self.trailingTabs) { tab in
TabNavigationStackHost(tab: tab)
.tabItem { tabItem(for: tab) }
.tag(MainTabSelection.tab(tab))
.badge(tab == .orders ? badgeViewModel.pendingWriteOffCount ?? 0 : 0)
}
}
.ignoresSafeArea(.keyboard, edges: .bottom)
.fullScreenCover(isPresented: $showScanner) {
OrderScannerPage(
onClose: { showScanner = false },
@ -94,12 +60,17 @@ private struct CustomMainTabsView: View {
}
)
}
.onAppear {
syncSelection(with: appRouter.selectedTab)
}
.onChange(of: tabSelection) { selection in
handleTabSelection(selection)
}
.task {
loadedTabs.insert(appRouter.selectedTab)
await refreshOrderBadge()
}
.onChange(of: appRouter.selectedTab) { selectedTab in
loadedTabs.insert(selectedTab)
syncSelection(with: selectedTab)
guard selectedTab == .orders else { return }
Task { await refreshOrderBadge() }
}
@ -119,41 +90,51 @@ private struct CustomMainTabsView: View {
storeId: accountContext.currentStore?.id
)
}
}
/// TabBar Tab TabBar
private struct CustomTabNavigationStackHost: View {
@EnvironmentObject private var appRouter: 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)
}
/// TabBar Tab
private func handleTabSelection(_ selection: MainTabSelection) {
switch selection {
case .tab(let tab):
selectedBusinessTab = tab
guard appRouter.selectedTab != tab else { return }
appRouter.select(tab)
case .scanner:
showScanner = true
restoreBusinessTabSelection()
}
.environmentObject(appRouter.router(for: tab))
}
/// TabView
private func syncSelection(with tab: AppTab) {
selectedBusinessTab = tab
guard tabSelection != .tab(tab) else { return }
tabSelection = .tab(tab)
}
/// 线 TabBar
private func restoreBusinessTabSelection() {
let tab = selectedBusinessTab
DispatchQueue.main.async {
tabSelection = .tab(tab)
}
}
/// Tab
@ViewBuilder
private func tabItem(for tab: AppTab) -> some View {
Image(appRouter.selectedTab == tab ? tab.selectedImageName : tab.unselectedImageName)
.renderingMode(.original)
Text(tab.title)
}
}
/// Tab TabBar
/// TabView AppTab
private enum MainTabSelection: Hashable {
case tab(AppTab)
case scanner
}
/// Tab
private struct TabNavigationStackHost: View {
@EnvironmentObject private var appRouter: AppRouter
@ -172,22 +153,10 @@ private struct TabNavigationStackHost: View {
}
}
/// TabBar Tab
private struct PreservedTabPage<Content: View>: View {
let tab: AppTab
let selectedTab: AppTab
let isLoaded: Bool
@ViewBuilder let content: () -> Content
/// Tab TabView selection
private struct ScannerTabPlaceholder: View {
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)
}
Color.clear
}
}

View File

@ -327,7 +327,9 @@ private struct OperatingAreaAMapRepresentable: UIViewRepresentable {
CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude)
}
guard coordinates.count >= 3 else { continue }
let polygon = MAPolygon(coordinates: &coordinates, count: UInt(coordinates.count))
guard let polygon = MAPolygon(coordinates: &coordinates, count: UInt(coordinates.count)) else {
continue
}
context.coordinator.ringsByOverlay[ObjectIdentifier(polygon)] = ring
mapView.add(polygon)
allCoordinates.append(contentsOf: coordinates)