Replace CYLTabBarController with native UITabBarController for main tabs.

Use a system scan tab slot with MainScanTabItem interception so scanner presentation no longer depends on CYL PlusButton, and remove the CYLTabBarController pod dependency.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 19:17:38 +08:00
parent 43179abf2c
commit 7469f92177
12 changed files with 217 additions and 208 deletions

View File

@ -5,7 +5,6 @@ target 'suixinkan_ios' do
pod 'SnapKit', '~> 5.7'
pod 'Kingfisher', '~> 8.0'
pod 'CYLTabBarController', '~> 1.99.38'
# 高德地图 SDK 仅支持真机;模拟器通过下方 post_install 剥离链接
pod 'AMap3DMap-NO-IDFA', '~> 11.1'
@ -20,7 +19,7 @@ end
# 模拟器构建时不链接高德(真机专用 framework仅保留通用 Pod 依赖
SIMULATOR_OTHER_LDFLAGS = <<~FLAGS.squish
-ObjC -l"c++" -l"swiftCoreGraphics" -l"z"
-framework "Accelerate" -framework "CFNetwork" -framework "CYLTabBarController"
-framework "Accelerate" -framework "CFNetwork"
-framework "Kingfisher" -framework "SnapKit"
-framework "CoreGraphics" -framework "CoreLocation" -framework "CoreTelephony"
-framework "CoreText" -framework "ExternalAccessory" -framework "GLKit"

View File

@ -6,9 +6,6 @@ PODS:
- AMapFoundation-NO-IDFA (>= 1.9.0)
- AMapSearch-NO-IDFA (9.8.0):
- AMapFoundation-NO-IDFA (>= 1.9.0)
- CYLTabBarController (1.99.38):
- CYLTabBarController/Core (= 1.99.38)
- CYLTabBarController/Core (1.99.38)
- Kingfisher (8.10.0)
- SnapKit (5.7.1)
@ -16,7 +13,6 @@ DEPENDENCIES:
- AMap3DMap-NO-IDFA (~> 11.1)
- AMapLocation-NO-IDFA (~> 2.11)
- AMapSearch-NO-IDFA (~> 9.7)
- CYLTabBarController (~> 1.99.38)
- Kingfisher (~> 8.0)
- SnapKit (~> 5.7)
@ -26,7 +22,6 @@ SPEC REPOS:
- AMapFoundation-NO-IDFA
- AMapLocation-NO-IDFA
- AMapSearch-NO-IDFA
- CYLTabBarController
- Kingfisher
- SnapKit
@ -35,10 +30,9 @@ SPEC CHECKSUMS:
AMapFoundation-NO-IDFA: a2e3c895398d7ee757278e1a0a8f9359da4b146e
AMapLocation-NO-IDFA: 7cd8fc837ea41edfbf4d937cd20572e277b77d18
AMapSearch-NO-IDFA: c0afd2a69a076d4228becda4401dbe4a279a03ef
CYLTabBarController: 8d46d501757c00e36499ea6b95e86e492fab3a61
Kingfisher: db468f911dd666c9134dcbeec4db8bed52e4132d
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
PODFILE CHECKSUM: 9659fc63d0173f0286a7da18725e548d86c24ca6
PODFILE CHECKSUM: 0755512570f68a2459c7ca54e87c58e5a09f215a
COCOAPODS: 1.16.2

View File

@ -66,6 +66,7 @@ enum AppTab: String, CaseIterable, Identifiable, Hashable {
image: unselectedImage,
selectedImage: selectedImage
)
item.accessibilityIdentifier = "main.tab.\(rawValue)"
return item
}

View File

@ -106,7 +106,7 @@ final class RootViewController: UIViewController {
}
}
/// `MainTabBarController` window CYLTabBarController
/// `MainTabBarController` window
private func showMainTabBarAsWindowRoot() {
removeCurrentAuthChild()

View File

@ -4,7 +4,7 @@
Main 模块负责登录后的主界面 Tab 容器。
主界面由 `MainTabBarController` 承载,使用 **CYLTabBarController** 展示首页、订单、数据、我的四个 Tab并在中间提供扫码核销按钮。每个 Tab 内部包裹独立的 `TabNavigationController`,页面路径以 UIKit 导航栈为唯一事实源。
主界面由 `MainTabBarController` 承载,使用系统 `UITabBarController` 展示首页、订单、扫码、数据、我的五个底部入口。首页、订单、数据、我的四个业务 Tab 内部包裹独立的 `TabNavigationController`,页面路径以 UIKit 导航栈为唯一事实源;中间扫码入口只触发全局扫码,不切换选中页面
## Tab 结构
@ -15,6 +15,7 @@ Main 模块负责登录后的主界面 Tab 容器。
- `profile`:我的
各 Tab 根页面已接入真实业务页面:`HomeViewController``OrdersViewController``StatisticsViewController``ProfileViewController`
系统 TabBar 中间额外插入扫码槽位,显示 `MainScanTabItem` 构建的扫码 item。
## 导航流程
@ -32,7 +33,7 @@ Main 模块负责登录后的主界面 Tab 容器。
## 扫码核销
TabBar 中间通过 `MainScanPlusButton`CYLTabBarController PlusButton展示全局扫码入口点击全屏打开 `OrderCodeScannerViewController`
TabBar 中间通过 `MainScanTabItem` 展示全局扫码入口,`MainTabBarController``shouldSelect` 中拦截点击全屏打开 `OrderCodeScannerViewController`,因此关闭扫码页后仍显示点击前的页面
1. 扫码成功 → `AppNavigator.openOrdersEntry(.verificationOrders, scannedCode:)` 切换至订单核销入口并传入扫码结果。
2. `OrdersViewController` 本地保存并消费扫码结果后进入确认核销流程。

View File

@ -3,26 +3,60 @@
// suixinkan
//
import CYLTabBarController
import UIKit
@MainActor
/// Tab 使 CYLTabBarController
final class MainTabBarController: CYLTabBarController {
/// Tab 使 TabBar
final class MainTabBarController: UITabBarController {
private let services: AppServices
private let badgeViewModel = MainTabBadgeViewModel()
private var tabNavigationControllers: [AppTab: TabNavigationController] = [:]
private var isSyncingTabSelection = false
///
var onScanTabSelected: (() -> Void)?
/// TabBar
private enum TabSlot: CaseIterable {
case home
case orders
case scan
case statistics
case profile
/// Tab
var appTab: AppTab? {
switch self {
case .home:
return .home
case .orders:
return .orders
case .scan:
return nil
case .statistics:
return .statistics
case .profile:
return .profile
}
}
/// Tab TabBar
static func index(for tab: AppTab) -> Int? {
allCases.firstIndex { $0.appTab == tab }
}
/// TabBar
static var scanIndex: Int? {
allCases.firstIndex(of: .scan)
}
}
///
init(services: AppServices) {
self.services = services
super.init(nibName: nil, bundle: nil)
MainScanPlusButton.onScanTap = { [weak self] in
self?.presentGlobalScanner()
}
// delegate = self
delegate = self
}
@available(*, unavailable)
@ -44,13 +78,6 @@ final class MainTabBarController: CYLTabBarController {
#endif
}
///
deinit {
Task { @MainActor in
MainScanPlusButton.onScanTap = nil
}
}
/// TabBar CustomMainTabBar
private func configureTabBarAppearance() {
tabBar.tintColor = AppDesign.primary
@ -76,24 +103,20 @@ final class MainTabBarController: CYLTabBarController {
tabBar.scrollEdgeAppearance = appearance
}
/// Tab CYLTabBarController Tab
/// Tab
private func configureTabs() {
let tabs = AppTab.allCases
tabBarItemsAttributes = tabs.map { makeTabBarItemAttributes(for: $0) }
viewControllers = tabs.map { tab in
tabNavigationControllers.removeAll()
viewControllers = TabSlot.allCases.map { slot in
if let tab = slot.appTab {
let navigationController = TabNavigationController(tab: tab, services: services)
navigationController.tabBarItem = tab.makeTabBarItem()
tabNavigationControllers[tab] = navigationController
return navigationController
}
let scannerPlaceholder = UIViewController()
scannerPlaceholder.tabBarItem = MainScanTabItem.makeTabBarItem()
return scannerPlaceholder
}
/// CYLTabBarController TabBarItem
private func makeTabBarItemAttributes(for tab: AppTab) -> [String: String] {
[
CYLTabBarItemTitle: tab.title,
CYLTabBarItemImage: tab.unselectedImageName,
CYLTabBarItemSelectedImage: tab.selectedImageName
]
}
/// ViewModel
@ -132,6 +155,8 @@ final class MainTabBarController: CYLTabBarController {
/// TabBar
private func presentGlobalScanner() {
guard presentedViewController == nil else { return }
let scanner = OrderCodeScannerViewController()
scanner.onScanResult = { [weak self, weak scanner] result in
scanner?.dismiss(animated: true)
@ -147,9 +172,25 @@ final class MainTabBarController: CYLTabBarController {
navigationController.modalPresentationStyle = .fullScreen
present(navigationController, animated: true)
}
}
extension MainTabBarController: UITabBarControllerDelegate {
/// TabBar 使
func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
guard viewControllers?.firstIndex(of: viewController) == TabSlot.scanIndex else {
return true
}
if let onScanTabSelected {
onScanTabSelected()
} else {
presentGlobalScanner()
}
return false
}
/// TabBar
override func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
guard !isSyncingTabSelection,
let navigationController = viewController as? TabNavigationController else { return }
@ -162,7 +203,7 @@ final class MainTabBarController: CYLTabBarController {
extension MainTabBarController: AppNavigationHost {
/// Tab
func selectTab(_ tab: AppTab) {
guard let index = AppTab.allCases.firstIndex(of: tab) else { return }
guard let index = TabSlot.index(for: tab) else { return }
guard selectedIndex != index else { return }
isSyncingTabSelection = true
selectedIndex = index

View File

@ -1,87 +0,0 @@
//
// 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()
button.setImage(UIImage(named: "icon_scan"), for: .normal)
button.titleLabel?.textAlignment = .center
button.titleLabel?.font = UIFont.systemFont(ofSize: 10)
button.setTitle("发布", for: .normal)
button.setTitleColor(UIColor.gray, for: .normal)
button.setTitle("选中", for: .selected)
button.setTitleColor(UIColor(red:255.0/255.0,green:102.0/255.0,blue:0,alpha:1.0), for: .selected)
button.adjustsImageWhenHighlighted = false
button.sizeToFit()
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(named: "icon_scan")
)
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?()
}
}

View File

@ -0,0 +1,21 @@
//
// MainScanTabItem.swift
// suixinkan
//
import UIKit
@MainActor
/// TabBar TabBarItem
enum MainScanTabItem {
/// TabBarItem `MainTabBarController`
static func makeTabBarItem() -> UITabBarItem {
let image = UIImage(named: "icon_scan")?.withRenderingMode(.alwaysOriginal)
let selectedImage = UIImage(named: "icon_scan")?.withRenderingMode(.alwaysOriginal)
let item = UITabBarItem(title: "扫码", image: image, selectedImage: selectedImage)
item.accessibilityLabel = "扫码核销"
item.accessibilityIdentifier = "main.scan"
return item
}
}

View File

@ -6,7 +6,6 @@
//
import UIKit
import CYLTabBarController
/// Scene
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
@ -24,8 +23,6 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
) {
guard let windowScene = scene as? UIWindowScene else { return }
MainScanPlusButton.register()
AppUITestLaunchState.resetIfNeeded()
let rootViewController = RootViewController()

View File

@ -1,73 +0,0 @@
//
// MainScanPlusButtonTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan_ios
/// TabBar CYL PlusButton
@MainActor
final class MainScanPlusButtonTests: XCTestCase {
/// Tab
func testIndexOfPlusButtonInTabBarIsBetweenOrdersAndStatistics() {
XCTAssertEqual(MainScanPlusButton.indexOfPlusButtonInTabBar(), 2)
}
/// accessibility
func testPlusButtonAccessibilityIdentifier() {
guard let button = MainScanPlusButton.plusButton() as? UIButton else {
XCTFail("plusButton 应返回 UIButton 实例")
return
}
XCTAssertEqual(button.accessibilityIdentifier, "main.scan")
XCTAssertEqual(button.accessibilityLabel, "扫码核销")
}
/// Tab CYL Tab
func testMainTabBarControllerLoadsWithMatchingCYLAttributes() {
let services = AppServices(apiClient: APIClient(session: MainTabRecordingURLSession()))
let controller = MainTabBarController(services: services)
controller.loadViewIfNeeded()
for tab in AppTab.allCases {
XCTAssertNotNil(controller.navigationController(for: tab))
XCTAssertEqual(
controller.navigationController(for: tab)?.tabBarItem.accessibilityIdentifier,
"main.tab.\(tab.rawValue)"
)
}
}
/// Tab tabBarItem
func testMainTabBarControllerTabBarItemsHaveImages() {
let services = AppServices(apiClient: APIClient(session: MainTabRecordingURLSession()))
let controller = MainTabBarController(services: services)
controller.loadViewIfNeeded()
for tab in AppTab.allCases {
let item = controller.navigationController(for: tab)?.tabBarItem
XCTAssertNotNil(item?.image, "tab \(tab.rawValue) image should not be nil")
XCTAssertNotNil(item?.selectedImage, "tab \(tab.rawValue) selectedImage should not be nil")
XCTAssertGreaterThan(item?.image?.size.width ?? 0, 0)
XCTAssertGreaterThan(item?.selectedImage?.size.width ?? 0, 0)
}
}
}
/// Tab 使
private final class MainTabRecordingURLSession: URLSessionProtocol {
///
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
let response = HTTPURLResponse(
url: request.url ?? URL(string: "https://example.com")!,
statusCode: 200,
httpVersion: nil,
headerFields: nil
)!
return (Data(), response)
}
}

View File

@ -0,0 +1,115 @@
//
// MainScanTabItemTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan_ios
/// TabBar TabBar
@MainActor
final class MainScanTabItemTests: XCTestCase {
/// Tab Tab
func testMainTabBarControllerLoadsFiveSystemTabItems() {
let services = AppServices(apiClient: APIClient(session: MainTabRecordingURLSession()))
let controller = MainTabBarController(services: services)
controller.loadViewIfNeeded()
XCTAssertEqual(controller.viewControllers?.count, 5)
for tab in AppTab.allCases {
XCTAssertNotNil(controller.navigationController(for: tab))
XCTAssertEqual(
controller.navigationController(for: tab)?.tabBarItem.accessibilityIdentifier,
"main.tab.\(tab.rawValue)"
)
}
}
///
func testScanTabItemIsCenteredBetweenOrdersAndStatistics() throws {
let services = AppServices(apiClient: APIClient(session: MainTabRecordingURLSession()))
let controller = MainTabBarController(services: services)
controller.loadViewIfNeeded()
let scanItem = try XCTUnwrap(controller.viewControllers?[2].tabBarItem)
XCTAssertEqual(scanItem.title, "扫码")
XCTAssertEqual(scanItem.accessibilityIdentifier, "main.scan")
XCTAssertEqual(scanItem.accessibilityLabel, "扫码核销")
XCTAssertNotNil(scanItem.image)
XCTAssertNotNil(scanItem.selectedImage)
}
///
func testSelectingScanTabPresentsScannerWithoutSelectingPlaceholder() throws {
let services = AppServices(apiClient: APIClient(session: MainTabRecordingURLSession()))
let controller = MainTabBarController(services: services)
var scanTapCount = 0
controller.onScanTabSelected = {
scanTapCount += 1
}
controller.loadViewIfNeeded()
controller.selectedIndex = 1
let scanController = try XCTUnwrap(controller.viewControllers?[2])
XCTAssertFalse(controller.tabBarController(controller, shouldSelect: scanController))
XCTAssertEqual(scanTapCount, 1)
XCTAssertEqual(controller.selectedIndex, 1)
}
/// Tab TabBar
func testSelectingBusinessTabIsAllowed() throws {
let services = AppServices(apiClient: APIClient(session: MainTabRecordingURLSession()))
let controller = MainTabBarController(services: services)
controller.loadViewIfNeeded()
let ordersController = try XCTUnwrap(controller.viewControllers?[1])
XCTAssertTrue(controller.tabBarController(controller, shouldSelect: ordersController))
}
/// Tab tabBarItem
func testMainTabBarControllerTabBarItemsHaveImages() {
let services = AppServices(apiClient: APIClient(session: MainTabRecordingURLSession()))
let controller = MainTabBarController(services: services)
controller.loadViewIfNeeded()
for tab in AppTab.allCases {
let item = controller.navigationController(for: tab)?.tabBarItem
XCTAssertNotNil(item?.image, "tab \(tab.rawValue) image should not be nil")
XCTAssertNotNil(item?.selectedImage, "tab \(tab.rawValue) selectedImage should not be nil")
XCTAssertGreaterThan(item?.image?.size.width ?? 0, 0)
XCTAssertGreaterThan(item?.selectedImage?.size.width ?? 0, 0)
}
}
/// TabBarItem
func testOrderBadgeUsesOrdersNavigationTabBarItem() throws {
let services = AppServices(apiClient: APIClient(session: MainTabRecordingURLSession()))
let controller = MainTabBarController(services: services)
controller.loadViewIfNeeded()
let ordersNavigationController = try XCTUnwrap(controller.navigationController(for: .orders))
ordersNavigationController.tabBarItem.badgeValue = "3"
XCTAssertEqual(controller.viewControllers?[1].tabBarItem.badgeValue, "3")
}
}
/// Tab 使
private final class MainTabRecordingURLSession: URLSessionProtocol {
///
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
let response = HTTPURLResponse(
url: request.url ?? URL(string: "https://example.com")!,
statusCode: 200,
httpVersion: nil,
headerFields: nil
)!
return (Data(), response)
}
}

View File

@ -29,7 +29,7 @@
| 状态 | 模块 | UIKit 实现 | 剩余事项 |
| --- | --- | --- | --- |
| 完成 | App 根结构 | `RootViewController` 监听登录阶段;登录后 `MainTabBarController` 作为 window 根控制器 | 持续随路由扩展 |
| 完成 | Tab 导航 | CYLTabBarController 四 Tab + 中间扫码按钮 + 独立 `TabNavigationController`push 子页隐藏 TabBar。 | 后续新增真实子页面时继续复用 `AppRoute`。 |
| 完成 | Tab 导航 | 系统 UITabBarController 五入口(首页、订单、扫码、数据、我的)+ 独立 `TabNavigationController`扫码入口拦截后 present 扫码页,push 子页隐藏 TabBar。 | 后续新增真实子页面时继续复用 `AppRoute`。 |
| 完成 | 全局状态 | `AppServices.shared` 注入 Session / Context / Router / Toast / Loading | 避免新增 monolith 状态 |
| 完成 | 登录态缓存 | Keychain token + UserDefaults 快照 / 偏好 + 冷启动恢复 | — |
| 完成 | 网络框架 | `APIClient` / `APIEnvelope` / 统一错误与 token | — |