Advance UIKit rewrite with AMap integration and core UI modules.
Integrate高德 SDK with simulator-safe build flags, add map views for operating area and punch points, refactor main tabs and key feature screens to UIKit with Diffable lists, and document Swift concurrency defaults in AGENTS.md. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -50,6 +50,7 @@ final class AppContentUnavailableView: UIView {
|
||||
actionsContainer.isHidden = actions.isEmpty
|
||||
}
|
||||
|
||||
/// 配置Views展示内容。
|
||||
private func configureViews(
|
||||
title: String,
|
||||
systemImage: String,
|
||||
|
||||
@ -9,6 +9,7 @@ import CoreGraphics
|
||||
|
||||
/// App 内通用尺寸定义。这里只放跨页面复用的字号和间距,具体业务页面的特殊尺寸仍留在页面本地。
|
||||
final class AppMetrics {
|
||||
/// 初始化实例。
|
||||
private init() {}
|
||||
|
||||
/// 字体尺寸实体,统一维护 App 内常用字号。
|
||||
|
||||
@ -119,6 +119,7 @@ fileprivate final class GlobalLoadingOverlayView: UIView {
|
||||
nil
|
||||
}
|
||||
|
||||
/// 配置Views展示内容。
|
||||
private func configureViews() {
|
||||
isUserInteractionEnabled = true
|
||||
accessibilityLabel = "加载中"
|
||||
@ -171,6 +172,7 @@ fileprivate final class GlobalLoadingOverlayView: UIView {
|
||||
])
|
||||
}
|
||||
|
||||
/// 刷新Presentation展示。
|
||||
private func refreshPresentation(animated: Bool) {
|
||||
let shouldShow = state.isVisible
|
||||
let updates = {
|
||||
@ -237,6 +239,7 @@ fileprivate final class GlobalLoadingOverlayView: UIView {
|
||||
|
||||
/// 从主包中加载 loading 动画资源,兼容 Resources 子目录和根目录。
|
||||
#if canImport(Lottie)
|
||||
/// 加载Animation数据。
|
||||
private static func loadAnimation() -> LottieAnimation? {
|
||||
if let animation = LottieAnimation.named("loading") {
|
||||
return animation
|
||||
@ -250,6 +253,7 @@ fileprivate final class GlobalLoadingOverlayView: UIView {
|
||||
return nil
|
||||
}
|
||||
#else
|
||||
/// 加载Animation数据。
|
||||
private static func loadAnimation() -> Any? {
|
||||
nil
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@ final class ForegroundLocationProvider: NSObject {
|
||||
private let geocoder = CLGeocoder()
|
||||
private var continuation: CheckedContinuation<ForegroundLocationResult, Error>?
|
||||
|
||||
/// 初始化实例。
|
||||
override init() {
|
||||
super.init()
|
||||
manager.delegate = self
|
||||
|
||||
16
suixinkan_ios/Core/Map/Map.md
Normal file
16
suixinkan_ios/Core/Map/Map.md
Normal file
@ -0,0 +1,16 @@
|
||||
# Core/Map 模块
|
||||
|
||||
## 职责
|
||||
|
||||
封装高德地图 UIKit 组件,供运营区域围栏展示与打卡点编辑选点使用。
|
||||
|
||||
## 组件
|
||||
|
||||
- `OperatingAreaMapView`:绘制运营围栏 polygon;模拟器降级为坐标摘要。
|
||||
- `PunchPointMapPickerView`:打卡点编辑页地图点选;模拟器提示使用「当前位置」。
|
||||
|
||||
## 构建说明
|
||||
|
||||
- 真机构建:`AMAP_ENABLED` + CocoaPods 高德 SDK。
|
||||
- 模拟器:不链接 AMap,自动展示文字/坐标兜底 UI。
|
||||
- 在 `Info.plist` 配置 `AMapAPIKey` 为高德控制台 Key(需与 Bundle ID 绑定)。
|
||||
199
suixinkan_ios/Core/Map/OperatingAreaMapView.swift
Normal file
199
suixinkan_ios/Core/Map/OperatingAreaMapView.swift
Normal file
@ -0,0 +1,199 @@
|
||||
//
|
||||
// OperatingAreaMapView.swift
|
||||
// suixinkan_ios
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 运营区域围栏地图:真机启用高德 polygon;模拟器展示坐标摘要。
|
||||
final class OperatingAreaMapView: UIView {
|
||||
var rings: [OperatingFenceRing] = [] {
|
||||
didSet { updateContent() }
|
||||
}
|
||||
|
||||
private let contentContainer = UIView()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
contentContainer.clipsToBounds = true
|
||||
contentContainer.layer.cornerRadius = AppMetrics.CornerRadius.card
|
||||
addSubview(contentContainer)
|
||||
contentContainer.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
updateContent()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func updateContent() {
|
||||
contentContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||
#if AMAP_ENABLED
|
||||
let mapView = OperatingAreaAMapView(rings: rings)
|
||||
contentContainer.addSubview(mapView)
|
||||
mapView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
#else
|
||||
let fallback = OperatingAreaMapFallbackView(rings: rings)
|
||||
contentContainer.addSubview(fallback)
|
||||
fallback.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#if AMAP_ENABLED
|
||||
import MAMapKit
|
||||
|
||||
private final class OperatingAreaAMapView: UIView, MAMapViewDelegate {
|
||||
private let mapView = MAMapView()
|
||||
private var ringsByOverlay: [ObjectIdentifier: OperatingFenceRing] = [:]
|
||||
private var rings: [OperatingFenceRing]
|
||||
|
||||
init(rings: [OperatingFenceRing]) {
|
||||
self.rings = rings
|
||||
super.init(frame: .zero)
|
||||
mapView.delegate = self
|
||||
mapView.showsCompass = false
|
||||
mapView.showsScale = false
|
||||
mapView.isRotateEnabled = false
|
||||
addSubview(mapView)
|
||||
mapView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
applyRings()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func applyRings() {
|
||||
ringsByOverlay.removeAll()
|
||||
mapView.removeOverlays(mapView.overlays)
|
||||
mapView.removeAnnotations(mapView.annotations)
|
||||
|
||||
var allCoordinates: [CLLocationCoordinate2D] = []
|
||||
for ring in rings {
|
||||
var coordinates = ring.points.map {
|
||||
CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude)
|
||||
}
|
||||
guard coordinates.count >= 3 else { continue }
|
||||
guard let polygon = MAPolygon(coordinates: &coordinates, count: UInt(coordinates.count)) else {
|
||||
continue
|
||||
}
|
||||
ringsByOverlay[ObjectIdentifier(polygon)] = ring
|
||||
mapView.add(polygon)
|
||||
allCoordinates.append(contentsOf: coordinates)
|
||||
|
||||
if let center = ring.centerCoordinate {
|
||||
let annotation = MAPointAnnotation()
|
||||
annotation.coordinate = center
|
||||
annotation.title = ring.regionName
|
||||
mapView.addAnnotation(annotation)
|
||||
}
|
||||
}
|
||||
|
||||
if let region = Self.region(for: allCoordinates) {
|
||||
mapView.setRegion(region, animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
func mapView(_ mapView: MAMapView!, rendererFor overlay: MAOverlay!) -> MAOverlayRenderer! {
|
||||
guard let polygon = overlay as? MAPolygon else { return nil }
|
||||
let ring = ringsByOverlay[ObjectIdentifier(polygon)]
|
||||
let renderer = MAPolygonRenderer(polygon: polygon)
|
||||
let color = ring?.isCurrentStore == true ? UIColor.systemRed : UIColor.systemBlue
|
||||
renderer?.strokeColor = color
|
||||
renderer?.fillColor = color.withAlphaComponent(0.18)
|
||||
renderer?.lineWidth = 3
|
||||
return renderer
|
||||
}
|
||||
|
||||
private static func region(for coordinates: [CLLocationCoordinate2D]) -> MACoordinateRegion? {
|
||||
guard !coordinates.isEmpty else { return nil }
|
||||
let minLat = coordinates.map(\.latitude).min() ?? 0
|
||||
let maxLat = coordinates.map(\.latitude).max() ?? 0
|
||||
let minLng = coordinates.map(\.longitude).min() ?? 0
|
||||
let maxLng = coordinates.map(\.longitude).max() ?? 0
|
||||
let center = CLLocationCoordinate2D(
|
||||
latitude: (minLat + maxLat) / 2,
|
||||
longitude: (minLng + maxLng) / 2
|
||||
)
|
||||
let span = MACoordinateSpan(
|
||||
latitudeDelta: max((maxLat - minLat) * 1.4, 0.01),
|
||||
longitudeDelta: max((maxLng - minLng) * 1.4, 0.01)
|
||||
)
|
||||
return MACoordinateRegion(center: center, span: span)
|
||||
}
|
||||
}
|
||||
|
||||
private extension OperatingFenceRing {
|
||||
var centerCoordinate: CLLocationCoordinate2D? {
|
||||
guard !points.isEmpty else { return nil }
|
||||
let latitude = points.reduce(0) { $0 + $1.latitude } / Double(points.count)
|
||||
let longitude = points.reduce(0) { $0 + $1.longitude } / Double(points.count)
|
||||
return CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private final class OperatingAreaMapFallbackView: UIView {
|
||||
init(rings: [OperatingFenceRing]) {
|
||||
super.init(frame: .zero)
|
||||
backgroundColor = UIColor(hex: 0xEEF2F7)
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = "模拟器未启用高德地图,以下为围栏坐标摘要"
|
||||
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .medium)
|
||||
titleLabel.textColor = AppDesign.textSecondary
|
||||
titleLabel.numberOfLines = 0
|
||||
|
||||
let stack = UIStackView()
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 8
|
||||
|
||||
addSubview(titleLabel)
|
||||
addSubview(stack)
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.medium)
|
||||
}
|
||||
stack.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(AppMetrics.Spacing.small)
|
||||
make.leading.trailing.bottom.equalToSuperview().inset(AppMetrics.Spacing.medium)
|
||||
}
|
||||
|
||||
for ring in rings.prefix(6) {
|
||||
let label = UILabel()
|
||||
label.numberOfLines = 0
|
||||
label.font = .systemFont(ofSize: AppMetrics.FontSize.caption)
|
||||
label.textColor = AppDesign.textSecondary
|
||||
let name = ring.regionName.isEmpty ? "未命名区域" : ring.regionName
|
||||
let coords = ring.points.prefix(4)
|
||||
.map { String(format: "%.6f, %.6f", $0.latitude, $0.longitude) }
|
||||
.joined(separator: " ")
|
||||
label.text = "\(name)\n\(coords)"
|
||||
stack.addArrangedSubview(label)
|
||||
}
|
||||
|
||||
if rings.isEmpty {
|
||||
let empty = UILabel()
|
||||
empty.text = "暂无围栏数据"
|
||||
empty.textColor = AppDesign.placeholder
|
||||
stack.addArrangedSubview(empty)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
139
suixinkan_ios/Core/Map/PunchPointMapPickerView.swift
Normal file
139
suixinkan_ios/Core/Map/PunchPointMapPickerView.swift
Normal file
@ -0,0 +1,139 @@
|
||||
//
|
||||
// PunchPointMapPickerView.swift
|
||||
// suixinkan_ios
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 打卡点地图选点:真机高德地图点选 + 逆地理;模拟器展示提示。
|
||||
@MainActor
|
||||
final class PunchPointMapPickerView: UIView {
|
||||
var onLocationPicked: ((Double, Double, String) -> Void)?
|
||||
|
||||
var coordinate: CLLocationCoordinate2D? {
|
||||
didSet { updatePin() }
|
||||
}
|
||||
|
||||
private let hintLabel = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
clipsToBounds = true
|
||||
layer.cornerRadius = AppMetrics.CornerRadius.card
|
||||
backgroundColor = UIColor(hex: 0xEEF2F7)
|
||||
|
||||
hintLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption)
|
||||
hintLabel.textColor = AppDesign.textSecondary
|
||||
hintLabel.numberOfLines = 0
|
||||
hintLabel.textAlignment = .center
|
||||
|
||||
#if AMAP_ENABLED
|
||||
hintLabel.text = "点击地图选择打卡位置"
|
||||
let mapView = PunchPointAMapPickerView { [weak self] lat, lng, address in
|
||||
self?.onLocationPicked?(lat, lng, address)
|
||||
}
|
||||
addSubview(mapView)
|
||||
addSubview(hintLabel)
|
||||
mapView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
hintLabel.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview().inset(8)
|
||||
}
|
||||
#else
|
||||
hintLabel.text = "模拟器未启用高德地图,请使用「当前位置」按钮选点"
|
||||
addSubview(hintLabel)
|
||||
hintLabel.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(AppMetrics.Spacing.medium)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func updatePin() {
|
||||
// Pin updates handled inside AMap subview when coordinate is set externally.
|
||||
}
|
||||
|
||||
func setCoordinate(latitude: Double, longitude: Double) {
|
||||
coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
|
||||
}
|
||||
}
|
||||
|
||||
#if AMAP_ENABLED
|
||||
import AMapSearchKit
|
||||
import MAMapKit
|
||||
|
||||
private final class PunchPointAMapPickerView: UIView, MAMapViewDelegate, AMapSearchDelegate {
|
||||
private let mapView = MAMapView()
|
||||
private let searchAPI = AMapSearchAPI()
|
||||
private var pin: MAPointAnnotation?
|
||||
private var pendingRegeo: CheckedContinuation<String, Never>?
|
||||
private let onPick: (Double, Double, String) -> Void
|
||||
|
||||
init(onPick: @escaping (Double, Double, String) -> Void) {
|
||||
self.onPick = onPick
|
||||
super.init(frame: .zero)
|
||||
searchAPI?.delegate = self
|
||||
mapView.delegate = self
|
||||
mapView.showsUserLocation = true
|
||||
mapView.userTrackingMode = .none
|
||||
mapView.zoomLevel = 16
|
||||
addSubview(mapView)
|
||||
mapView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func mapView(_ mapView: MAMapView!, didSingleTappedAt coordinate: CLLocationCoordinate2D) {
|
||||
Task { await handlePick(coordinate) }
|
||||
}
|
||||
|
||||
private func handlePick(_ coordinate: CLLocationCoordinate2D) async {
|
||||
if let pin {
|
||||
mapView.removeAnnotation(pin)
|
||||
}
|
||||
let annotation = MAPointAnnotation()
|
||||
annotation.coordinate = coordinate
|
||||
pin = annotation
|
||||
mapView.addAnnotation(annotation)
|
||||
|
||||
let address = await reverseGeocode(coordinate)
|
||||
onPick(coordinate.latitude, coordinate.longitude, address)
|
||||
}
|
||||
|
||||
private func reverseGeocode(_ coordinate: CLLocationCoordinate2D) async -> String {
|
||||
await withCheckedContinuation { continuation in
|
||||
pendingRegeo = continuation
|
||||
let request = AMapReGeocodeSearchRequest()
|
||||
request.location = AMapGeoPoint.location(
|
||||
withLatitude: CGFloat(coordinate.latitude),
|
||||
longitude: CGFloat(coordinate.longitude)
|
||||
)
|
||||
request.requireExtension = true
|
||||
searchAPI?.aMapReGoecodeSearch(request)
|
||||
}
|
||||
}
|
||||
|
||||
func onReGeocodeSearchDone(_ request: AMapReGeocodeSearchRequest!, response: AMapReGeocodeSearchResponse!) {
|
||||
let address = response.regeocode.formattedAddress ?? "已选位置"
|
||||
pendingRegeo?.resume(returning: address)
|
||||
pendingRegeo = nil
|
||||
}
|
||||
|
||||
func aMapSearchRequest(_ request: Any!, didFailWithError error: Error!) {
|
||||
pendingRegeo?.resume(returning: "已选位置")
|
||||
pendingRegeo = nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@ -21,6 +21,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate
|
||||
private let tokenDefaultsKey = "apns_device_token"
|
||||
private let uploadedTokenDefaultsKey = "apns_uploaded_token"
|
||||
|
||||
/// 初始化实例。
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
@ -99,6 +100,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate
|
||||
route(from: payload)
|
||||
}
|
||||
|
||||
/// user通知Center相关逻辑。
|
||||
nonisolated func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
@ -111,6 +113,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate
|
||||
completionHandler([.banner, .list, .sound, .badge])
|
||||
}
|
||||
|
||||
/// user通知Center相关逻辑。
|
||||
nonisolated func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse,
|
||||
@ -123,6 +126,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据执行路由跳转。
|
||||
private func route(from payload: PushPayload) {
|
||||
guard let router else { return }
|
||||
|
||||
@ -142,6 +146,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate
|
||||
}
|
||||
}
|
||||
|
||||
/// 导航至HomeRoute页面。
|
||||
private func navigateHomeRoute(_ route: HomeRoute) {
|
||||
guard let router else { return }
|
||||
router.select(.home)
|
||||
|
||||
@ -9,6 +9,7 @@ import Foundation
|
||||
|
||||
/// APNs token 工具,把系统返回的 Data 转成后端可接收的小写十六进制字符串。
|
||||
enum APNsDeviceToken {
|
||||
/// 将二进制数据转为十六进制字符串。
|
||||
static func hexString(from data: Data) -> String {
|
||||
data.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
@ -16,6 +17,7 @@ enum APNsDeviceToken {
|
||||
|
||||
/// 推送 payload 解析器,兼容顶层字段和第三方通道常见 extras/data 嵌套结构。
|
||||
struct PushPayload: Sendable {
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum Route: Sendable, Equatable {
|
||||
case payment
|
||||
case order
|
||||
@ -64,6 +66,7 @@ struct PushPayload: Sendable {
|
||||
return .messageCenter
|
||||
}
|
||||
|
||||
/// mergeJSON相关逻辑。
|
||||
nonisolated private static func mergeJSON(_ value: Any, into result: inout [String: String]) {
|
||||
if let dict = value as? [String: Any] {
|
||||
for (key, value) in dict {
|
||||
@ -84,6 +87,7 @@ struct PushPayload: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全地将任意值转为字符串。
|
||||
nonisolated private static func stringValue(_ value: Any?) -> String {
|
||||
switch value {
|
||||
case let string as String:
|
||||
|
||||
@ -9,6 +9,7 @@ import Foundation
|
||||
|
||||
/// 排队语音播报内容。
|
||||
struct ScenicQueueAnnouncement: Equatable {
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum Kind: Equatable {
|
||||
case newTickets(count: Int)
|
||||
case calledTicket(id: Int64)
|
||||
|
||||
@ -69,6 +69,7 @@ final class ScenicQueueRuntime {
|
||||
self.scenePhase = scenePhase
|
||||
|
||||
guard !suspendedByQueueScreen else {
|
||||
// 排队页自行监听时,避免双通道轮询
|
||||
stop()
|
||||
return
|
||||
}
|
||||
@ -86,6 +87,7 @@ final class ScenicQueueRuntime {
|
||||
return
|
||||
}
|
||||
|
||||
// 景区或打卡点切换时重置播报状态,避免跨点误报
|
||||
if lastScenicId != scenicId || lastSpotId != spotId {
|
||||
resetSnapshot()
|
||||
lastScenicId = scenicId
|
||||
@ -93,6 +95,7 @@ final class ScenicQueueRuntime {
|
||||
}
|
||||
|
||||
if scenePhase == .background, !backgroundPollingEnabled {
|
||||
// 用户关闭后台轮询时,进入后台即停止
|
||||
stop()
|
||||
return
|
||||
}
|
||||
@ -126,6 +129,7 @@ final class ScenicQueueRuntime {
|
||||
Task { await pollOnce() }
|
||||
}
|
||||
|
||||
/// 启动IfNeeded流程。
|
||||
private func startIfNeeded() {
|
||||
guard pollTask == nil else { return }
|
||||
isMonitoring = true
|
||||
@ -137,6 +141,7 @@ final class ScenicQueueRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行Loop循环或任务。
|
||||
private func runLoop() async {
|
||||
while !Task.isCancelled {
|
||||
await pollOnce()
|
||||
@ -145,6 +150,7 @@ final class ScenicQueueRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/// 轮询Once数据。
|
||||
private func pollOnce() async {
|
||||
guard let api, let scenicId else { return }
|
||||
let spotId = selectedSpotId()
|
||||
@ -168,6 +174,7 @@ final class ScenicQueueRuntime {
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
)
|
||||
// 并行拉取统计与排队列表,减少单次轮询耗时
|
||||
let (stats, home) = try await (statsData, homeData)
|
||||
handle(stats: stats, tickets: home.list?.list ?? [])
|
||||
lastError = nil
|
||||
@ -176,6 +183,7 @@ final class ScenicQueueRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理相关事件。
|
||||
private func handle(stats: ScenicQueueStatsData, tickets: [ScenicQueueTicket]) {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm:ss"
|
||||
@ -190,6 +198,7 @@ final class ScenicQueueRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/// 轮询IntervalSeconds数据。
|
||||
private func pollIntervalSeconds() -> UInt64 {
|
||||
switch scenePhase {
|
||||
case .active:
|
||||
@ -203,11 +212,13 @@ final class ScenicQueueRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取用户当前选中的打卡点 ID。
|
||||
private func selectedSpotId() -> Int {
|
||||
ScenicQueueSettingsStore.selectedSpotId(userId: userId, scenicId: scenicId)
|
||||
?? UserDefaults.standard.integer(forKey: ScenicQueueLocalSettings.selectedSpotIdKey)
|
||||
}
|
||||
|
||||
/// 重置Snapshot状态。
|
||||
private func resetSnapshot() {
|
||||
announcementState.reset()
|
||||
lastQueueCount = 0
|
||||
@ -215,6 +226,7 @@ final class ScenicQueueRuntime {
|
||||
lastSpokenText = ""
|
||||
}
|
||||
|
||||
/// 申请后台任务,保证进入后台时仍可短时轮询。
|
||||
private func beginBackgroundTask() {
|
||||
guard backgroundTaskId == .invalid else { return }
|
||||
backgroundTaskId = UIApplication.shared.beginBackgroundTask(withName: "scenic.queue.poll") { [weak self] in
|
||||
@ -225,6 +237,7 @@ final class ScenicQueueRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/// 结束后台任务。
|
||||
private func endBackgroundTask() {
|
||||
guard backgroundTaskId != .invalid else { return }
|
||||
UIApplication.shared.endBackgroundTask(backgroundTaskId)
|
||||
@ -238,6 +251,7 @@ final class ScenicQueueSpeechService: NSObject, AVSpeechSynthesizerDelegate {
|
||||
private let synthesizer = AVSpeechSynthesizer()
|
||||
private var pendingContinuations: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
/// 初始化实例。
|
||||
override init() {
|
||||
super.init()
|
||||
synthesizer.delegate = self
|
||||
@ -265,6 +279,7 @@ final class ScenicQueueSpeechService: NSObject, AVSpeechSynthesizerDelegate {
|
||||
resumePending()
|
||||
}
|
||||
|
||||
/// 入队等待处理。
|
||||
private func enqueue(_ normalized: String, continuation: CheckedContinuation<Void, Never>?, replacePending: Bool) {
|
||||
if replacePending {
|
||||
synthesizer.stopSpeaking(at: .immediate)
|
||||
@ -279,14 +294,17 @@ final class ScenicQueueSpeechService: NSObject, AVSpeechSynthesizerDelegate {
|
||||
synthesizer.speak(utterance)
|
||||
}
|
||||
|
||||
/// speechSynthesizer相关逻辑。
|
||||
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
||||
Task { @MainActor in self.resumePending() }
|
||||
}
|
||||
|
||||
/// speechSynthesizer相关逻辑。
|
||||
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
|
||||
Task { @MainActor in self.resumePending() }
|
||||
}
|
||||
|
||||
/// 恢复Pending流程。
|
||||
private func resumePending() {
|
||||
let continuations = pendingContinuations
|
||||
pendingContinuations.removeAll()
|
||||
|
||||
@ -74,6 +74,7 @@ final class ScenicQueueSocketClient {
|
||||
webSocketTask = nil
|
||||
}
|
||||
|
||||
/// 持续接收 WebSocket 消息并分发处理。
|
||||
private func receiveLoop(
|
||||
task: URLSessionWebSocketTask,
|
||||
onMessage: @escaping @MainActor (ScenicQueueSocketMessage) -> Void
|
||||
@ -130,6 +131,7 @@ final class ScenicQueueSocketClient {
|
||||
return ScenicQueueSocketMessage(code: code, data: socketData)
|
||||
}
|
||||
|
||||
/// 安全地将任意值转为字符串。
|
||||
private static func stringValue(_ value: Any?) -> String {
|
||||
switch value {
|
||||
case let value as String:
|
||||
@ -143,6 +145,7 @@ final class ScenicQueueSocketClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// int值相关逻辑。
|
||||
private static func intValue(_ value: Any?) -> Int? {
|
||||
if let value = value as? Int { return value }
|
||||
if let value = value as? NSNumber { return value.intValue }
|
||||
@ -150,6 +153,7 @@ final class ScenicQueueSocketClient {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// int64值相关逻辑。
|
||||
private static func int64Value(_ value: Any?) -> Int64? {
|
||||
if let value = value as? Int64 { return value }
|
||||
if let value = value as? Int { return Int64(value) }
|
||||
|
||||
@ -12,6 +12,7 @@ final class FeaturePlaceholderViewController: UIViewController {
|
||||
private let pageTitle: String
|
||||
private let uri: String
|
||||
|
||||
/// 初始化实例。
|
||||
init(title: String, uri: String) {
|
||||
self.pageTitle = title
|
||||
self.uri = uri
|
||||
@ -23,6 +24,7 @@ final class FeaturePlaceholderViewController: UIViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
import Kingfisher
|
||||
import UIKit
|
||||
|
||||
/// `UIImageView` 远程图片加载扩展,统一走 Kingfisher 缓存与占位策略。
|
||||
extension UIImageView {
|
||||
/// 使用 Kingfisher 加载远程图片,并提供占位图与失败回退。
|
||||
func loadRemoteImage(
|
||||
@ -68,11 +69,13 @@ extension UIImageView {
|
||||
kf.cancelDownloadTask()
|
||||
}
|
||||
|
||||
/// 规范化dURLString格式。
|
||||
private func normalizedURLString(from urlString: String?) -> String? {
|
||||
let text = urlString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
|
||||
/// avatarPlaceholder相关逻辑。
|
||||
private static func avatarPlaceholder(
|
||||
systemImageName: String,
|
||||
iconSize: CGFloat,
|
||||
@ -98,6 +101,7 @@ extension UIImageView {
|
||||
}
|
||||
}
|
||||
|
||||
/// `UIImageView` 头像圆角辅助扩展。
|
||||
extension UIImageView {
|
||||
/// 在布局变化后刷新圆形头像圆角。
|
||||
func refreshRemoteAvatarCornerRadius() {
|
||||
|
||||
@ -23,6 +23,7 @@ private final class ViewModelBindingToken {
|
||||
private weak var owner: AnyObject?
|
||||
private let handler: () -> Void
|
||||
|
||||
/// 初始化实例。
|
||||
init(owner: AnyObject, handler: @escaping () -> Void) {
|
||||
self.owner = owner
|
||||
self.handler = handler
|
||||
@ -33,17 +34,22 @@ private final class ViewModelBindingToken {
|
||||
}
|
||||
}
|
||||
|
||||
/// `UIViewController` 通用 UI 与全局反馈扩展。
|
||||
extension UIViewController {
|
||||
/// 全局依赖容器快捷访问。
|
||||
var appServices: AppServices { AppServices.shared }
|
||||
|
||||
/// 展示全局 Toast 提示。
|
||||
func showToast(_ message: String) {
|
||||
appServices.toastCenter.show(message)
|
||||
}
|
||||
|
||||
/// 展示全局 Loading 遮罩。
|
||||
func showGlobalLoading(_ message: String = "") {
|
||||
appServices.globalLoading.show(message: message)
|
||||
}
|
||||
|
||||
/// 隐藏GlobalLoading。
|
||||
func hideGlobalLoading() {
|
||||
appServices.globalLoading.hide()
|
||||
}
|
||||
|
||||
173
suixinkan_ios/Core/UIKit/ListDiffableSupport.swift
Normal file
173
suixinkan_ios/Core/UIKit/ListDiffableSupport.swift
Normal file
@ -0,0 +1,173 @@
|
||||
//
|
||||
// ListDiffableSupport.swift
|
||||
// suixinkan
|
||||
//
|
||||
// 列表 Diffable Data Source 通用类型与布局辅助。
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
// MARK: - Module Table(简单单列 / 多 section 列表)
|
||||
|
||||
/// 模块列表 section 标识,使用 section 索引。
|
||||
typealias ModuleTableSection = Int
|
||||
|
||||
/// 模块列表行标识,格式为 `section-row`。
|
||||
typealias ModuleTableRow = String
|
||||
|
||||
/// 构建模块列表行标识。
|
||||
func moduleTableRowID(section: Int, row: Int) -> ModuleTableRow {
|
||||
"\(section)-\(row)"
|
||||
}
|
||||
|
||||
/// 从行标识解析 indexPath。
|
||||
func moduleTableIndexPath(from rowID: ModuleTableRow) -> IndexPath? {
|
||||
let parts = rowID.split(separator: "-", omittingEmptySubsequences: false)
|
||||
guard parts.count == 2, let section = Int(parts[0]), let row = Int(parts[1]) else { return nil }
|
||||
return IndexPath(row: row, section: section)
|
||||
}
|
||||
|
||||
// MARK: - Simple Table(独立页面的固定行列表)
|
||||
|
||||
/// 简单列表 section 标识。
|
||||
typealias SimpleTableSection = Int
|
||||
|
||||
/// 简单列表行标识。
|
||||
typealias SimpleTableRow = String
|
||||
|
||||
// MARK: - Collection Layout
|
||||
|
||||
/// UICollectionView 布局工厂,提供全宽卡片与网格 section 的 Compositional Layout。
|
||||
enum CollectionDiffableLayout {
|
||||
/// 全宽单行 section,高度由 estimated 或 absolute 决定。
|
||||
static func fullWidthSection(estimatedHeight: CGFloat = 100) -> NSCollectionLayoutSection {
|
||||
let item = NSCollectionLayoutItem(
|
||||
layoutSize: NSCollectionLayoutSize(
|
||||
widthDimension: .fractionalWidth(1),
|
||||
heightDimension: .estimated(estimatedHeight)
|
||||
)
|
||||
)
|
||||
let group = NSCollectionLayoutGroup.vertical(
|
||||
layoutSize: NSCollectionLayoutSize(
|
||||
widthDimension: .fractionalWidth(1),
|
||||
heightDimension: .estimated(estimatedHeight)
|
||||
),
|
||||
subitems: [item]
|
||||
)
|
||||
let section = NSCollectionLayoutSection(group: group)
|
||||
section.contentInsets = NSDirectionalEdgeInsets(
|
||||
top: AppMetrics.Spacing.xxSmall,
|
||||
leading: AppMetrics.Spacing.pageHorizontal,
|
||||
bottom: AppMetrics.Spacing.xxSmall,
|
||||
trailing: AppMetrics.Spacing.pageHorizontal
|
||||
)
|
||||
return section
|
||||
}
|
||||
|
||||
/// 固定高度全宽 section。
|
||||
static func fullWidthSection(height: CGFloat) -> NSCollectionLayoutSection {
|
||||
let item = NSCollectionLayoutItem(
|
||||
layoutSize: NSCollectionLayoutSize(
|
||||
widthDimension: .fractionalWidth(1),
|
||||
heightDimension: .absolute(height)
|
||||
)
|
||||
)
|
||||
let group = NSCollectionLayoutGroup.vertical(
|
||||
layoutSize: NSCollectionLayoutSize(
|
||||
widthDimension: .fractionalWidth(1),
|
||||
heightDimension: .absolute(height)
|
||||
),
|
||||
subitems: [item]
|
||||
)
|
||||
let section = NSCollectionLayoutSection(group: group)
|
||||
section.contentInsets = NSDirectionalEdgeInsets(
|
||||
top: AppMetrics.Spacing.xxSmall,
|
||||
leading: AppMetrics.Spacing.pageHorizontal,
|
||||
bottom: AppMetrics.Spacing.xxSmall,
|
||||
trailing: AppMetrics.Spacing.pageHorizontal
|
||||
)
|
||||
return section
|
||||
}
|
||||
|
||||
/// 三列网格 section,用于首页 / 更多功能菜单。
|
||||
static func gridSection(
|
||||
columns: Int = 3,
|
||||
itemHeight: CGFloat = 102,
|
||||
interItemSpacing: CGFloat = 15,
|
||||
lineSpacing: CGFloat = 15,
|
||||
contentInsets: NSDirectionalEdgeInsets? = nil
|
||||
) -> NSCollectionLayoutSection {
|
||||
let item = NSCollectionLayoutItem(
|
||||
layoutSize: NSCollectionLayoutSize(
|
||||
widthDimension: .fractionalWidth(1.0 / CGFloat(columns)),
|
||||
heightDimension: .absolute(itemHeight)
|
||||
)
|
||||
)
|
||||
let group = NSCollectionLayoutGroup.horizontal(
|
||||
layoutSize: NSCollectionLayoutSize(
|
||||
widthDimension: .fractionalWidth(1),
|
||||
heightDimension: .absolute(itemHeight)
|
||||
),
|
||||
subitem: item,
|
||||
count: columns
|
||||
)
|
||||
group.interItemSpacing = .fixed(interItemSpacing)
|
||||
let section = NSCollectionLayoutSection(group: group)
|
||||
section.interGroupSpacing = lineSpacing
|
||||
section.contentInsets = contentInsets ?? NSDirectionalEdgeInsets(
|
||||
top: AppMetrics.Spacing.xxSmall,
|
||||
leading: AppMetrics.Spacing.pageHorizontal,
|
||||
bottom: AppMetrics.Spacing.xxSmall,
|
||||
trailing: AppMetrics.Spacing.pageHorizontal
|
||||
)
|
||||
return section
|
||||
}
|
||||
|
||||
/// 带标题的 section 头。
|
||||
static func addHeader(
|
||||
to section: NSCollectionLayoutSection,
|
||||
height: CGFloat = 36
|
||||
) -> NSCollectionLayoutSection {
|
||||
let header = NSCollectionLayoutBoundarySupplementaryItem(
|
||||
layoutSize: NSCollectionLayoutSize(
|
||||
widthDimension: .fractionalWidth(1),
|
||||
heightDimension: .absolute(height)
|
||||
),
|
||||
elementKind: UICollectionView.elementKindSectionHeader,
|
||||
alignment: .top
|
||||
)
|
||||
section.boundarySupplementaryItems = [header]
|
||||
return section
|
||||
}
|
||||
}
|
||||
|
||||
/// 通用 section 标题补充视图。
|
||||
final class CollectionSectionHeaderView: UICollectionReusableView {
|
||||
static let reuseID = "CollectionSectionHeaderView"
|
||||
|
||||
private let titleLabel = UILabel()
|
||||
|
||||
/// 初始化实例。
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold)
|
||||
titleLabel.textColor = AppDesignUIKit.textSecondary
|
||||
addSubview(titleLabel)
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal)
|
||||
make.trailing.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal)
|
||||
make.bottom.equalToSuperview().inset(4)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 配置 section 标题文本。
|
||||
func configure(title: String) {
|
||||
titleLabel.text = title
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,7 @@ import UIKit
|
||||
final class TitleSubtitleTableViewCell: UITableViewCell {
|
||||
static let reuseIdentifier = "TitleSubtitleTableViewCell"
|
||||
|
||||
/// 初始化实例。
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: .subtitle, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .default
|
||||
@ -28,6 +29,7 @@ final class TitleSubtitleTableViewCell: UITableViewCell {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 填充标题、副标题与详情文本,副标题优先于详情展示。
|
||||
func configure(title: String, subtitle: String? = nil, detail: String? = nil) {
|
||||
textLabel?.text = title
|
||||
if let subtitle, !subtitle.isEmpty {
|
||||
@ -38,18 +40,20 @@ final class TitleSubtitleTableViewCell: UITableViewCell {
|
||||
}
|
||||
}
|
||||
|
||||
/// 模块列表页基类,封装 UITableView、下拉刷新和 ViewModel onChange 绑定。
|
||||
@MainActor
|
||||
class ModuleTableViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
|
||||
/// 模块列表页基类,封装 UITableView Diffable、下拉刷新和 ViewModel onChange 绑定。
|
||||
class ModuleTableViewController: UIViewController, UITableViewDelegate {
|
||||
let services = AppServices.shared
|
||||
let tableView = UITableView(frame: .zero, style: .insetGrouped)
|
||||
private let refreshControl = UIRefreshControl()
|
||||
private let activityIndicator = UIActivityIndicatorView(style: .medium)
|
||||
private var viewModelReloadHandler: (() -> Void)?
|
||||
|
||||
/// Diffable 数据源,驱动列表展示与动画刷新。
|
||||
private(set) var tableDataSource: UITableViewDiffableDataSource<ModuleTableSection, ModuleTableRow>!
|
||||
|
||||
var isLoading = false {
|
||||
didSet {
|
||||
if isLoading, tableView.numberOfSections > 0, tableView.numberOfRows(inSection: 0) == 0 {
|
||||
if isLoading, tableDataSource.snapshot().numberOfItems == 0 {
|
||||
activityIndicator.startAnimating()
|
||||
} else {
|
||||
activityIndicator.stopAnimating()
|
||||
@ -57,12 +61,19 @@ class ModuleTableViewController: UIViewController, UITableViewDataSource, UITabl
|
||||
}
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
||||
navigationItem.largeTitleDisplayMode = .never
|
||||
|
||||
tableView.dataSource = self
|
||||
tableDataSource = UITableViewDiffableDataSource<ModuleTableSection, ModuleTableRow>(
|
||||
tableView: tableView
|
||||
) { [weak self] (tableView: UITableView, indexPath: IndexPath, row: ModuleTableRow) -> UITableViewCell? in
|
||||
guard let self else { return UITableViewCell() }
|
||||
return self.tableCell(for: indexPath, row: row, in: tableView)
|
||||
}
|
||||
|
||||
tableView.delegate = self
|
||||
tableView.backgroundColor = .clear
|
||||
tableView.register(
|
||||
@ -85,36 +96,45 @@ class ModuleTableViewController: UIViewController, UITableViewDataSource, UITabl
|
||||
Task { await reloadContent() }
|
||||
}
|
||||
|
||||
/// 绑定 ViewModel 数据变更回调,子类在 `reloadContent` 完成后调用。
|
||||
func bindViewModel(onChange: (() -> Void)?) {
|
||||
viewModelReloadHandler = onChange
|
||||
}
|
||||
|
||||
func reloadTable() {
|
||||
tableView.reloadData()
|
||||
/// 通过 Diffable snapshot 刷新列表,替代 reloadData。
|
||||
func reloadTable(animated: Bool = true) {
|
||||
tableDataSource.apply(buildTableSnapshot(), animatingDifferences: animated)
|
||||
}
|
||||
|
||||
/// 返回 section 数量,子类可覆写以支持多 section。
|
||||
func numberOfTableSections() -> Int { 1 }
|
||||
|
||||
/// 返回指定 section 行数,子类必须覆写。
|
||||
func tableRowCount(in section: Int) -> Int {
|
||||
section == 0 ? tableRowCount() : 0
|
||||
}
|
||||
|
||||
/// 返回列表行数(单 section 场景),子类覆写。
|
||||
func tableRowCount() -> Int { 0 }
|
||||
|
||||
/// 构建 Diffable snapshot,子类一般无需覆写。
|
||||
func buildTableSnapshot() -> NSDiffableDataSourceSnapshot<ModuleTableSection, ModuleTableRow> {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<ModuleTableSection, ModuleTableRow>()
|
||||
for section in 0..<numberOfTableSections() {
|
||||
snapshot.appendSections([section])
|
||||
let rows = (0..<tableRowCount(in: section)).map {
|
||||
moduleTableRowID(section: section, row: $0)
|
||||
}
|
||||
snapshot.appendItems(rows, toSection: section)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/// 配置指定行的 Cell 内容,子类覆写以填充业务数据。
|
||||
func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {}
|
||||
|
||||
func didSelectTableRow(at indexPath: IndexPath) {}
|
||||
|
||||
func reloadContent() async {}
|
||||
|
||||
@objc private func handleRefresh() {
|
||||
Task {
|
||||
await reloadContent()
|
||||
refreshControl.endRefreshing()
|
||||
}
|
||||
}
|
||||
|
||||
func numberOfSections(in tableView: UITableView) -> Int { 1 }
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
tableRowCount()
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
/// 提供 Cell,子类可覆写以自定义多 section 展示。
|
||||
func tableCell(for indexPath: IndexPath, row: ModuleTableRow, in tableView: UITableView) -> UITableViewCell {
|
||||
guard let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
@ -125,19 +145,46 @@ class ModuleTableViewController: UIViewController, UITableViewDataSource, UITabl
|
||||
return cell
|
||||
}
|
||||
|
||||
/// 处理行选中事件,子类覆写以响应跳转或操作。
|
||||
func didSelectTableRow(at indexPath: IndexPath) {}
|
||||
|
||||
/// 加载或刷新页面数据,子类覆写以实现网络请求与状态更新。
|
||||
func reloadContent() async {}
|
||||
|
||||
/// section 标题,子类覆写以展示分组标题。
|
||||
func tableSectionTitle(for section: Int) -> String? { nil }
|
||||
|
||||
/// 下拉刷新触发,重新加载内容并结束刷新动画。
|
||||
@objc private func handleRefresh() {
|
||||
Task {
|
||||
await reloadContent()
|
||||
refreshControl.endRefreshing()
|
||||
}
|
||||
}
|
||||
|
||||
/// UITableView 代理:展示 section 标题。
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
tableSectionTitle(for: section)
|
||||
}
|
||||
|
||||
/// UITableView 代理:处理行选中。
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
didSelectTableRow(at: indexPath)
|
||||
}
|
||||
|
||||
/// UITableView 代理:行即将展示,用于分页加载等。
|
||||
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
willDisplayTableRow(at: indexPath)
|
||||
}
|
||||
|
||||
/// willDisplayTableRow 回调处理。
|
||||
func willDisplayTableRow(at indexPath: IndexPath) {}
|
||||
}
|
||||
|
||||
/// `ModuleTableViewController` 扩展,提供 ViewModel 绑定便捷方法。
|
||||
extension ModuleTableViewController {
|
||||
/// 将 ViewModel 的 onChange 与列表刷新联动,并在绑定时立即刷新一次。
|
||||
func wireViewModel(_ viewModel: AnyObject, reload: @escaping () -> Void) {
|
||||
if let bindable = viewModel as? ViewModelBindable {
|
||||
bindable.onChange = { [weak self] in
|
||||
@ -146,6 +193,7 @@ extension ModuleTableViewController {
|
||||
}
|
||||
}
|
||||
reload()
|
||||
reloadTable(animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
@ -154,3 +202,140 @@ extension ModuleTableViewController {
|
||||
protocol ViewModelBindable: AnyObject {
|
||||
var onChange: (() -> Void)? { get set }
|
||||
}
|
||||
|
||||
/// 简单固定行列表基类,使用 UITableView Diffable,适用于设置、详情等页面。
|
||||
class SimpleTableDiffableViewController: UIViewController, UITableViewDelegate {
|
||||
let tableView: UITableView
|
||||
|
||||
private(set) var tableDataSource: UITableViewDiffableDataSource<SimpleTableSection, SimpleTableRow>!
|
||||
|
||||
/// 初始化实例。
|
||||
init(style: UITableView.Style = .insetGrouped) {
|
||||
tableView = UITableView(frame: .zero, style: style)
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 构建 snapshot,子类必须覆写。
|
||||
func buildSnapshot() -> NSDiffableDataSourceSnapshot<SimpleTableSection, SimpleTableRow> {
|
||||
NSDiffableDataSourceSnapshot()
|
||||
}
|
||||
|
||||
/// 配置 Cell,子类必须覆写。
|
||||
func configureCell(_ cell: UITableViewCell, row: SimpleTableRow, at indexPath: IndexPath) {}
|
||||
|
||||
/// 行选中,子类可覆写。
|
||||
func didSelectRow(_ row: SimpleTableRow, at indexPath: IndexPath) {}
|
||||
|
||||
/// section 标题。
|
||||
func sectionTitle(for section: SimpleTableSection) -> String? { nil }
|
||||
|
||||
/// section 页脚。
|
||||
func sectionFooter(for section: SimpleTableSection) -> String? { nil }
|
||||
|
||||
/// 应用 snapshot 刷新列表。
|
||||
func applySnapshot(animated: Bool = true) {
|
||||
tableDataSource.apply(buildSnapshot(), animatingDifferences: animated)
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
tableDataSource = UITableViewDiffableDataSource<SimpleTableSection, SimpleTableRow>(
|
||||
tableView: tableView
|
||||
) { [weak self] (_: UITableView, indexPath: IndexPath, row: SimpleTableRow) -> UITableViewCell? in
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
self?.configureCell(cell, row: row, at: indexPath)
|
||||
return cell
|
||||
}
|
||||
tableView.delegate = self
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
applySnapshot(animated: false)
|
||||
}
|
||||
|
||||
/// UITableView 代理:section 标题。
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
guard let sectionID = tableDataSource.snapshot().sectionIdentifiers[safe: section] else { return nil }
|
||||
return sectionTitle(for: sectionID)
|
||||
}
|
||||
|
||||
/// UITableView 代理:section 页脚。
|
||||
func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
|
||||
guard let sectionID = tableDataSource.snapshot().sectionIdentifiers[safe: section] else { return nil }
|
||||
return sectionFooter(for: sectionID)
|
||||
}
|
||||
|
||||
/// UITableView 代理:行选中。
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard let row = tableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
didSelectRow(row, at: indexPath)
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全下标,避免 section 越界。
|
||||
private extension Array {
|
||||
subscript(safe index: Int) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 模块复杂列表基类,封装 UICollectionView Diffable 与下拉刷新。
|
||||
class ModuleCollectionViewController: UIViewController {
|
||||
let services = AppServices.shared
|
||||
let collectionView: UICollectionView
|
||||
private let refreshControl = UIRefreshControl()
|
||||
private let activityIndicator = UIActivityIndicatorView(style: .medium)
|
||||
|
||||
/// 初始化实例。
|
||||
init(collectionViewLayout: UICollectionViewLayout) {
|
||||
collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout)
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
||||
navigationItem.largeTitleDisplayMode = .never
|
||||
|
||||
collectionView.backgroundColor = .clear
|
||||
collectionView.refreshControl = refreshControl
|
||||
refreshControl.addTarget(self, action: #selector(handleRefresh), for: .valueChanged)
|
||||
|
||||
activityIndicator.hidesWhenStopped = true
|
||||
view.addSubview(collectionView)
|
||||
view.addSubview(activityIndicator)
|
||||
collectionView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
activityIndicator.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
}
|
||||
|
||||
Task { await reloadContent() }
|
||||
}
|
||||
|
||||
/// 加载或刷新页面数据,子类覆写。
|
||||
func reloadContent() async {}
|
||||
|
||||
/// 下拉刷新触发。
|
||||
@objc private func handleRefresh() {
|
||||
Task {
|
||||
await reloadContent()
|
||||
refreshControl.endRefreshing()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user