添加登录流程、会话路由和对齐参考 UI 的主 Tab 框架
This commit is contained in:
64
suixinkan/UI/MainTab/AppTab.swift
Normal file
64
suixinkan/UI/MainTab/AppTab.swift
Normal file
@ -0,0 +1,64 @@
|
||||
//
|
||||
// AppTab.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 主 Tab 实体,描述底部导航中的一级入口。
|
||||
enum AppTab: String, CaseIterable, Hashable {
|
||||
case home
|
||||
case orders
|
||||
case statistics
|
||||
case profile
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .home: "首页"
|
||||
case .orders: "订单"
|
||||
case .statistics: "数据"
|
||||
case .profile: "我的"
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建系统 TabBarItem。
|
||||
func makeTabBarItem() -> UITabBarItem {
|
||||
let unselectedImage = UIImage(named: unselectedImageName)?.withRenderingMode(.alwaysOriginal)
|
||||
let selectedImage = UIImage(named: selectedImageName)?.withRenderingMode(.alwaysOriginal)
|
||||
let item = UITabBarItem(title: title, image: unselectedImage, selectedImage: selectedImage)
|
||||
item.accessibilityIdentifier = "main.tab.\(rawValue)"
|
||||
return item
|
||||
}
|
||||
|
||||
/// 构建 Tab 根页面。
|
||||
func makeRootViewController() -> UIViewController {
|
||||
switch self {
|
||||
case .home:
|
||||
return HomeViewController()
|
||||
case .orders:
|
||||
return OrdersViewController()
|
||||
case .statistics:
|
||||
return StatisticsViewController()
|
||||
case .profile:
|
||||
return ProfileViewController()
|
||||
}
|
||||
}
|
||||
}
|
||||
19
suixinkan/UI/MainTab/MainScanTabItem.swift
Normal file
19
suixinkan/UI/MainTab/MainScanTabItem.swift
Normal file
@ -0,0 +1,19 @@
|
||||
//
|
||||
// MainScanTabItem.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 主 TabBar 中间扫码核销入口。
|
||||
enum MainScanTabItem {
|
||||
|
||||
/// 构建扫码 TabBarItem,点击行为由 `MainTabBarController` 拦截。
|
||||
static func makeTabBarItem() -> UITabBarItem {
|
||||
let image = UIImage(named: "icon_scan")?.withRenderingMode(.alwaysOriginal)
|
||||
let item = UITabBarItem(title: nil, image: image, selectedImage: image)
|
||||
item.accessibilityLabel = "扫码核销"
|
||||
item.accessibilityIdentifier = "main.scan"
|
||||
return item
|
||||
}
|
||||
}
|
||||
21
suixinkan/UI/MainTab/MainTabBadgeViewModel.swift
Normal file
21
suixinkan/UI/MainTab/MainTabBadgeViewModel.swift
Normal file
@ -0,0 +1,21 @@
|
||||
//
|
||||
// MainTabBadgeViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 主 Tab 角标 ViewModel,负责订单 Tab 待核销数量。
|
||||
final class MainTabBadgeViewModel {
|
||||
|
||||
var onChange: (() -> Void)?
|
||||
|
||||
private(set) var pendingWriteOffCount: Int? {
|
||||
didSet { onChange?() }
|
||||
}
|
||||
|
||||
/// 刷新待核销数量;接口未接入前保持为空。
|
||||
func refreshPendingWriteOffCount() {
|
||||
pendingWriteOffCount = nil
|
||||
}
|
||||
}
|
||||
154
suixinkan/UI/MainTab/MainTabBarController.swift
Normal file
154
suixinkan/UI/MainTab/MainTabBarController.swift
Normal file
@ -0,0 +1,154 @@
|
||||
//
|
||||
// MainTabBarController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 主 Tab 容器,展示四个一级入口及中间扫码按钮。
|
||||
final class MainTabBarController: UITabBarController {
|
||||
|
||||
private let badgeViewModel = MainTabBadgeViewModel()
|
||||
private var tabNavigationControllers: [AppTab: TabNavigationController] = [:]
|
||||
private var isSyncingTabSelection = false
|
||||
|
||||
/// 测试时可注入扫码点击处理。
|
||||
var onScanTabSelected: (() -> Void)?
|
||||
|
||||
private enum TabSlot: CaseIterable {
|
||||
case home
|
||||
case orders
|
||||
case scan
|
||||
case statistics
|
||||
case profile
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
static func index(for tab: AppTab) -> Int? {
|
||||
allCases.firstIndex { $0.appTab == tab }
|
||||
}
|
||||
|
||||
static var scanIndex: Int? {
|
||||
allCases.firstIndex(of: .scan)
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
delegate = self
|
||||
configureTabBarAppearance()
|
||||
configureTabs()
|
||||
bindBadgeViewModel()
|
||||
badgeViewModel.refreshPendingWriteOffCount()
|
||||
}
|
||||
|
||||
/// 选中指定主 Tab。
|
||||
func selectTab(_ tab: AppTab) {
|
||||
guard let index = TabSlot.index(for: tab) else { return }
|
||||
guard selectedIndex != index else { return }
|
||||
isSyncingTabSelection = true
|
||||
selectedIndex = index
|
||||
isSyncingTabSelection = false
|
||||
if tab == .orders {
|
||||
badgeViewModel.refreshPendingWriteOffCount()
|
||||
}
|
||||
}
|
||||
|
||||
private func configureTabBarAppearance() {
|
||||
tabBar.tintColor = AppColor.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: AppColor.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
|
||||
}
|
||||
|
||||
private func configureTabs() {
|
||||
tabNavigationControllers.removeAll()
|
||||
viewControllers = TabSlot.allCases.map { slot in
|
||||
if let tab = slot.appTab {
|
||||
let navigationController = TabNavigationController(tab: tab)
|
||||
navigationController.tabBarItem = tab.makeTabBarItem()
|
||||
tabNavigationControllers[tab] = navigationController
|
||||
return navigationController
|
||||
}
|
||||
|
||||
let scannerPlaceholder = UIViewController()
|
||||
scannerPlaceholder.tabBarItem = MainScanTabItem.makeTabBarItem()
|
||||
return scannerPlaceholder
|
||||
}
|
||||
}
|
||||
|
||||
private func bindBadgeViewModel() {
|
||||
badgeViewModel.onChange = { [weak self] in
|
||||
self?.updateOrderBadge()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateOrderBadge() {
|
||||
let badgeValue: String?
|
||||
if let count = badgeViewModel.pendingWriteOffCount, count > 0 {
|
||||
badgeValue = "\(count)"
|
||||
} else {
|
||||
badgeValue = nil
|
||||
}
|
||||
tabNavigationControllers[.orders]?.tabBarItem.badgeValue = badgeValue
|
||||
}
|
||||
|
||||
private func presentGlobalScanner() {
|
||||
guard presentedViewController == nil else { return }
|
||||
|
||||
let scanner = ScanPlaceholderViewController()
|
||||
let navigationController = UINavigationController(rootViewController: scanner)
|
||||
navigationController.modalPresentationStyle = .fullScreen
|
||||
present(navigationController, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension MainTabBarController: UITabBarControllerDelegate {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
|
||||
guard !isSyncingTabSelection,
|
||||
let navigationController = viewController as? TabNavigationController else { return }
|
||||
|
||||
if navigationController.appTab == .orders {
|
||||
badgeViewModel.refreshPendingWriteOffCount()
|
||||
}
|
||||
}
|
||||
}
|
||||
73
suixinkan/UI/MainTab/TabNavigationController.swift
Normal file
73
suixinkan/UI/MainTab/TabNavigationController.swift
Normal file
@ -0,0 +1,73 @@
|
||||
//
|
||||
// TabNavigationController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 单个 Tab 的导航栈。
|
||||
final class TabNavigationController: UINavigationController {
|
||||
|
||||
let appTab: AppTab
|
||||
|
||||
init(tab: AppTab) {
|
||||
self.appTab = tab
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
delegate = self
|
||||
navigationBar.prefersLargeTitles = false
|
||||
setViewControllers([tab.makeRootViewController()], animated: false)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func pushViewController(_ viewController: UIViewController, animated: Bool) {
|
||||
viewController.hidesBottomBarWhenPushed = viewControllers.count > 0
|
||||
updateNavigationBarVisibility(for: viewController, animated: animated)
|
||||
super.pushViewController(viewController, animated: animated)
|
||||
}
|
||||
|
||||
override func setViewControllers(_ viewControllers: [UIViewController], animated: Bool) {
|
||||
if let topViewController = viewControllers.last {
|
||||
updateNavigationBarVisibility(for: topViewController, animated: animated)
|
||||
}
|
||||
super.setViewControllers(viewControllers, animated: animated)
|
||||
}
|
||||
|
||||
override func popViewController(animated: Bool) -> UIViewController? {
|
||||
let poppedViewController = super.popViewController(animated: animated)
|
||||
if !animated, let topViewController {
|
||||
updateNavigationBarVisibility(for: topViewController, animated: false)
|
||||
}
|
||||
return poppedViewController
|
||||
}
|
||||
|
||||
override func popToRootViewController(animated: Bool) -> [UIViewController]? {
|
||||
let poppedViewControllers = super.popToRootViewController(animated: animated)
|
||||
if !animated, let topViewController {
|
||||
updateNavigationBarVisibility(for: topViewController, animated: false)
|
||||
}
|
||||
return poppedViewControllers
|
||||
}
|
||||
|
||||
private func shouldHideNavigationBar(for viewController: UIViewController) -> Bool {
|
||||
viewController is HomeViewController
|
||||
}
|
||||
|
||||
private func updateNavigationBarVisibility(for viewController: UIViewController, animated: Bool) {
|
||||
setNavigationBarHidden(shouldHideNavigationBar(for: viewController), animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
extension TabNavigationController: UINavigationControllerDelegate {
|
||||
|
||||
func navigationController(
|
||||
_ navigationController: UINavigationController,
|
||||
willShow viewController: UIViewController,
|
||||
animated: Bool
|
||||
) {
|
||||
updateNavigationBarVisibility(for: viewController, animated: animated)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user