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:
2026-06-26 15:16:12 +08:00
parent 9edf993432
commit d99a5b1bf8
124 changed files with 5195 additions and 1536 deletions

View File

@ -50,6 +50,7 @@ final class AppContentUnavailableView: UIView {
actionsContainer.isHidden = actions.isEmpty
}
/// Views
private func configureViews(
title: String,
systemImage: String,

View File

@ -9,6 +9,7 @@ import CoreGraphics
/// App
final class AppMetrics {
///
private init() {}
/// App

View File

@ -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
}

View File

@ -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

View File

@ -0,0 +1,16 @@
# Core/Map 模块
## 职责
封装高德地图 UIKit 组件,供运营区域围栏展示与打卡点编辑选点使用。
## 组件
- `OperatingAreaMapView`:绘制运营围栏 polygon模拟器降级为坐标摘要。
- `PunchPointMapPickerView`:打卡点编辑页地图点选;模拟器提示使用「当前位置」。
## 构建说明
- 真机构建:`AMAP_ENABLED` + CocoaPods 高德 SDK。
- 模拟器:不链接 AMap自动展示文字/坐标兜底 UI。
-`Info.plist` 配置 `AMapAPIKey` 为高德控制台 Key需与 Bundle ID 绑定)。

View 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")
}
}

View 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

View File

@ -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)
}
/// userCenter
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
@ -111,6 +113,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate
completionHandler([.banner, .list, .sound, .badge])
}
/// userCenter
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)

View File

@ -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:

View File

@ -9,6 +9,7 @@ import Foundation
///
struct ScenicQueueAnnouncement: Equatable {
///
enum Kind: Equatable {
case newTickets(count: Int)
case calledTicket(id: Int64)

View File

@ -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()

View File

@ -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) }

View File

@ -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)

View File

@ -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() {

View File

@ -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()
}

View 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
}
}

View File

@ -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()
}
}
}