Initial commit: suixinkan_ios UIKit rewrite project.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
111
suixinkan_ios/Core/Design/AppContentUnavailableView.swift
Normal file
111
suixinkan_ios/Core/Design/AppContentUnavailableView.swift
Normal file
@ -0,0 +1,111 @@
|
||||
//
|
||||
// AppContentUnavailableView.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// iOS 16 兼容的空状态视图,用于列表或页面无数据时的占位展示。
|
||||
final class AppContentUnavailableView: UIView {
|
||||
private let contentStack = UIStackView()
|
||||
private let labelStack = UIStackView()
|
||||
private let iconView = UIImageView()
|
||||
private let titleLabel = UILabel()
|
||||
private let descriptionLabel = UILabel()
|
||||
private let actionsContainer = UIStackView()
|
||||
|
||||
/// 使用标题、系统图标和可选描述、操作区构建空状态视图。
|
||||
init(
|
||||
title: String,
|
||||
systemImage: String,
|
||||
description: String? = nil,
|
||||
actions: [UIView] = []
|
||||
) {
|
||||
super.init(frame: .zero)
|
||||
configureViews(title: title, systemImage: systemImage, description: description, actions: actions)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
nil
|
||||
}
|
||||
|
||||
/// 更新空状态文案和图标。
|
||||
func update(title: String, systemImage: String, description: String? = nil) {
|
||||
titleLabel.text = title
|
||||
iconView.image = UIImage(systemName: systemImage)
|
||||
descriptionLabel.text = description
|
||||
descriptionLabel.isHidden = description?.isEmpty != false
|
||||
}
|
||||
|
||||
/// 替换操作区子视图。
|
||||
func setActions(_ actions: [UIView]) {
|
||||
actionsContainer.arrangedSubviews.forEach { view in
|
||||
actionsContainer.removeArrangedSubview(view)
|
||||
view.removeFromSuperview()
|
||||
}
|
||||
actions.forEach { actionsContainer.addArrangedSubview($0) }
|
||||
actionsContainer.isHidden = actions.isEmpty
|
||||
}
|
||||
|
||||
private func configureViews(
|
||||
title: String,
|
||||
systemImage: String,
|
||||
description: String?,
|
||||
actions: [UIView]
|
||||
) {
|
||||
contentStack.axis = .vertical
|
||||
contentStack.alignment = .center
|
||||
contentStack.spacing = AppMetrics.Spacing.small
|
||||
|
||||
labelStack.axis = .vertical
|
||||
labelStack.alignment = .center
|
||||
labelStack.spacing = AppMetrics.Spacing.xSmall
|
||||
|
||||
iconView.image = UIImage(systemName: systemImage)
|
||||
iconView.tintColor = AppDesign.textSecondary
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
iconView.setContentHuggingPriority(.required, for: .vertical)
|
||||
|
||||
titleLabel.text = title
|
||||
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title3, weight: .semibold)
|
||||
titleLabel.textColor = AppDesign.textPrimary
|
||||
titleLabel.textAlignment = .center
|
||||
titleLabel.numberOfLines = 0
|
||||
|
||||
descriptionLabel.text = description
|
||||
descriptionLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
||||
descriptionLabel.textColor = AppDesign.textSecondary
|
||||
descriptionLabel.textAlignment = .center
|
||||
descriptionLabel.numberOfLines = 0
|
||||
descriptionLabel.isHidden = description?.isEmpty != false
|
||||
|
||||
actionsContainer.axis = .vertical
|
||||
actionsContainer.alignment = .center
|
||||
actionsContainer.spacing = AppMetrics.Spacing.xSmall
|
||||
actionsContainer.isHidden = actions.isEmpty
|
||||
actions.forEach { actionsContainer.addArrangedSubview($0) }
|
||||
|
||||
labelStack.addArrangedSubview(iconView)
|
||||
labelStack.addArrangedSubview(titleLabel)
|
||||
if description?.isEmpty == false {
|
||||
labelStack.addArrangedSubview(descriptionLabel)
|
||||
}
|
||||
|
||||
contentStack.addArrangedSubview(labelStack)
|
||||
if !actions.isEmpty {
|
||||
contentStack.addArrangedSubview(actionsContainer)
|
||||
}
|
||||
|
||||
addSubview(contentStack)
|
||||
contentStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(AppMetrics.Spacing.large)
|
||||
}
|
||||
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.width.height.equalTo(44)
|
||||
}
|
||||
}
|
||||
}
|
||||
21
suixinkan_ios/Core/Design/AppDesign.swift
Normal file
21
suixinkan_ios/Core/Design/AppDesign.swift
Normal file
@ -0,0 +1,21 @@
|
||||
//
|
||||
// AppDesign.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/18.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 应用通用颜色定义,集中管理跨页面复用的设计色值。
|
||||
enum AppDesign {
|
||||
static let primary = UIColor(hex: 0x0073FF)
|
||||
static let primarySoft = UIColor(hex: 0xEFF6FF)
|
||||
static let textPrimary = UIColor(hex: 0x1F2937)
|
||||
static let textSecondary = UIColor(hex: 0x6B7280)
|
||||
static let placeholder = UIColor(hex: 0xA8B2C1)
|
||||
static let success = UIColor(hex: 0x14964A)
|
||||
static let warning = UIColor(hex: 0xFF7B00)
|
||||
static let inputBackground = UIColor(hex: 0xF8FAFC)
|
||||
static let inputBorder = UIColor(hex: 0xE2E8F0)
|
||||
}
|
||||
68
suixinkan_ios/Core/Design/AppMetrics.swift
Normal file
68
suixinkan_ios/Core/Design/AppMetrics.swift
Normal file
@ -0,0 +1,68 @@
|
||||
//
|
||||
// AppMetrics.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/20.
|
||||
//
|
||||
|
||||
import CoreGraphics
|
||||
|
||||
/// App 内通用尺寸定义。这里只放跨页面复用的字号和间距,具体业务页面的特殊尺寸仍留在页面本地。
|
||||
final class AppMetrics {
|
||||
private init() {}
|
||||
|
||||
/// 字体尺寸实体,统一维护 App 内常用字号。
|
||||
enum FontSize {
|
||||
static let caption: CGFloat = 12
|
||||
static let footnote: CGFloat = 13
|
||||
static let subheadline: CGFloat = 14
|
||||
static let body: CGFloat = 16
|
||||
static let callout: CGFloat = 17
|
||||
static let title3: CGFloat = 18
|
||||
static let title2: CGFloat = 20
|
||||
static let title: CGFloat = 24
|
||||
static let largeTitle: CGFloat = 30
|
||||
}
|
||||
|
||||
/// 间距实体,统一维护页面边距和组件间距。
|
||||
enum Spacing {
|
||||
static let xxxSmall: CGFloat = 2
|
||||
static let xxSmall: CGFloat = 4
|
||||
static let xSmall: CGFloat = 8
|
||||
static let small: CGFloat = 12
|
||||
static let substantial: CGFloat = 14
|
||||
static let medium: CGFloat = 16
|
||||
static let mediumLarge: CGFloat = 18
|
||||
static let large: CGFloat = 20
|
||||
static let sheet: CGFloat = 22
|
||||
static let xLarge: CGFloat = 24
|
||||
static let xxLarge: CGFloat = 30
|
||||
static let pageHorizontal: CGFloat = 16
|
||||
static let pageVertical: CGFloat = 24
|
||||
}
|
||||
|
||||
/// 控件尺寸实体,统一维护按钮、输入框和图标点击区尺寸。
|
||||
enum ControlSize {
|
||||
static let smallIcon: CGFloat = 16
|
||||
static let checkboxIcon: CGFloat = 20
|
||||
static let passwordIcon: CGFloat = 22
|
||||
static let progressWidth: CGFloat = 22
|
||||
static let checkboxTapArea: CGFloat = 28
|
||||
static let iconTapArea: CGFloat = 32
|
||||
static let sheetButtonHeight: CGFloat = 48
|
||||
static let primaryButtonHeight: CGFloat = 50
|
||||
static let inputHeight: CGFloat = 52
|
||||
}
|
||||
|
||||
/// 行距实体,统一维护多行文本的常用行间距。
|
||||
enum LineSpacing {
|
||||
static let title: CGFloat = 3
|
||||
}
|
||||
|
||||
/// 圆角尺寸实体,统一维护输入框、按钮和卡片圆角。
|
||||
enum CornerRadius {
|
||||
static let input: CGFloat = 12
|
||||
static let button: CGFloat = 12
|
||||
static let card: CGFloat = 16
|
||||
}
|
||||
}
|
||||
266
suixinkan_ios/Core/Design/GlobalLoadingCenter.swift
Normal file
266
suixinkan_ios/Core/Design/GlobalLoadingCenter.swift
Normal file
@ -0,0 +1,266 @@
|
||||
//
|
||||
// GlobalLoadingCenter.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
#if canImport(Lottie)
|
||||
import Lottie
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
/// 全局 Loading 命令中心,只负责展示状态控制,不承载业务数据。
|
||||
final class GlobalLoadingCenter {
|
||||
fileprivate let state = GlobalLoadingState()
|
||||
private weak var overlayView: GlobalLoadingOverlayView?
|
||||
|
||||
/// 显示全局 Loading;多次调用会通过引用计数叠加。
|
||||
func show(message: String = "") {
|
||||
if !message.isEmpty {
|
||||
state.message = message
|
||||
}
|
||||
state.activeCount += 1
|
||||
state.isVisible = true
|
||||
}
|
||||
|
||||
/// 隐藏一次全局 Loading;所有调用方都结束后才真正关闭。
|
||||
func hide() {
|
||||
state.activeCount = max(0, state.activeCount - 1)
|
||||
guard state.activeCount == 0 else { return }
|
||||
state.isVisible = false
|
||||
state.message = ""
|
||||
}
|
||||
|
||||
/// 更新当前 Loading 文案,仅在 Loading 可见且文案非空时生效。
|
||||
func updateMessage(_ message: String) {
|
||||
guard state.isVisible, !message.isEmpty else { return }
|
||||
state.message = message
|
||||
}
|
||||
|
||||
/// 包裹一个异步操作,操作结束或抛错时自动关闭 Loading。
|
||||
func withLoading<T>(
|
||||
message: String = "",
|
||||
operation: () async throws -> T
|
||||
) async rethrows -> T {
|
||||
show(message: message)
|
||||
defer { hide() }
|
||||
return try await operation()
|
||||
}
|
||||
|
||||
/// 根据开关决定是否展示 Loading,适合下拉刷新等可选 loading 场景。
|
||||
func withOptionalLoading<T>(
|
||||
_ enabled: Bool,
|
||||
message: String = "",
|
||||
operation: () async throws -> T
|
||||
) async rethrows -> T {
|
||||
if enabled {
|
||||
return try await withLoading(message: message, operation: operation)
|
||||
}
|
||||
return try await operation()
|
||||
}
|
||||
|
||||
/// 将全局 Loading 展示层挂载到指定窗口,通常只在应用根部调用一次。
|
||||
func attachToWindow(_ window: UIWindow) {
|
||||
if let overlayView, overlayView.superview === window {
|
||||
return
|
||||
}
|
||||
|
||||
let overlay = GlobalLoadingOverlayView(state: state)
|
||||
overlay.frame = window.bounds
|
||||
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
window.addSubview(overlay)
|
||||
overlayView = overlay
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// 测试专用快照,业务代码不应读取 Loading 展示状态。
|
||||
var snapshotForTests: GlobalLoadingSnapshot {
|
||||
GlobalLoadingSnapshot(
|
||||
isVisible: state.isVisible,
|
||||
message: state.message,
|
||||
activeCount: state.activeCount
|
||||
)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 全局 Loading 的私有状态,只允许根部 Overlay 订阅。
|
||||
final class GlobalLoadingState {
|
||||
var onChange: (() -> Void)?
|
||||
fileprivate var isVisible = false { didSet { onChange?() } }
|
||||
fileprivate var message = "" { didSet { onChange?() } }
|
||||
fileprivate var activeCount = 0 { didSet { onChange?() } }
|
||||
}
|
||||
|
||||
/// 全局 Loading 展示层,订阅 Loading 状态并覆盖在窗口最上层。
|
||||
fileprivate final class GlobalLoadingOverlayView: UIView {
|
||||
private let state: GlobalLoadingState
|
||||
private let dimmingView = UIView()
|
||||
private let cardView = UIView()
|
||||
private let animationContainer = UIView()
|
||||
private var animationView: UIView?
|
||||
|
||||
/// 使用全局 Loading 状态初始化展示层。
|
||||
init(state: GlobalLoadingState) {
|
||||
self.state = state
|
||||
super.init(frame: .zero)
|
||||
configureViews()
|
||||
state.onChange = { [weak self] in
|
||||
self?.refreshPresentation(animated: true)
|
||||
}
|
||||
refreshPresentation(animated: false)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
nil
|
||||
}
|
||||
|
||||
private func configureViews() {
|
||||
isUserInteractionEnabled = true
|
||||
accessibilityLabel = "加载中"
|
||||
|
||||
dimmingView.backgroundColor = UIColor.black.withAlphaComponent(0.28)
|
||||
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = 18
|
||||
cardView.layer.cornerCurve = .continuous
|
||||
cardView.layer.shadowColor = UIColor.black.cgColor
|
||||
cardView.layer.shadowOpacity = 0.12
|
||||
cardView.layer.shadowRadius = 24
|
||||
cardView.layer.shadowOffset = CGSize(width: 0, height: 10)
|
||||
|
||||
animationView = Self.makeAnimationView()
|
||||
if let animationView {
|
||||
animationContainer.addSubview(animationView)
|
||||
animationView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
animationView.leadingAnchor.constraint(equalTo: animationContainer.leadingAnchor),
|
||||
animationView.trailingAnchor.constraint(equalTo: animationContainer.trailingAnchor),
|
||||
animationView.topAnchor.constraint(equalTo: animationContainer.topAnchor),
|
||||
animationView.bottomAnchor.constraint(equalTo: animationContainer.bottomAnchor)
|
||||
])
|
||||
}
|
||||
|
||||
addSubview(dimmingView)
|
||||
addSubview(cardView)
|
||||
cardView.addSubview(animationContainer)
|
||||
|
||||
dimmingView.translatesAutoresizingMaskIntoConstraints = false
|
||||
cardView.translatesAutoresizingMaskIntoConstraints = false
|
||||
animationContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
dimmingView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
dimmingView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
dimmingView.topAnchor.constraint(equalTo: topAnchor),
|
||||
dimmingView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
|
||||
cardView.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||||
cardView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
|
||||
animationContainer.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 10),
|
||||
animationContainer.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -10),
|
||||
animationContainer.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 10),
|
||||
animationContainer.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -10),
|
||||
animationContainer.widthAnchor.constraint(equalToConstant: 132),
|
||||
animationContainer.heightAnchor.constraint(equalToConstant: 132)
|
||||
])
|
||||
}
|
||||
|
||||
private func refreshPresentation(animated: Bool) {
|
||||
let shouldShow = state.isVisible
|
||||
let updates = {
|
||||
self.isHidden = !shouldShow
|
||||
self.alpha = shouldShow ? 1 : 0
|
||||
}
|
||||
|
||||
guard animated else {
|
||||
updates()
|
||||
return
|
||||
}
|
||||
|
||||
if shouldShow {
|
||||
isHidden = false
|
||||
alpha = 0
|
||||
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseInOut]) {
|
||||
self.alpha = 1
|
||||
}
|
||||
} else {
|
||||
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseInOut], animations: {
|
||||
self.alpha = 0
|
||||
}, completion: { _ in
|
||||
self.isHidden = true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断当前包内是否存在可用 Loading 动画资源。
|
||||
private static var hasAnimationResource: Bool {
|
||||
loadAnimation() != nil
|
||||
}
|
||||
|
||||
/// 创建 Lottie 或系统菊花动画视图。
|
||||
private static func makeAnimationView() -> UIView? {
|
||||
#if canImport(Lottie)
|
||||
if hasAnimationResource {
|
||||
let container = UIView()
|
||||
container.backgroundColor = .clear
|
||||
|
||||
let animationView = LottieAnimationView()
|
||||
animationView.translatesAutoresizingMaskIntoConstraints = false
|
||||
animationView.contentMode = .scaleAspectFit
|
||||
animationView.loopMode = .loop
|
||||
animationView.backgroundBehavior = .pauseAndRestore
|
||||
animationView.animation = loadAnimation()
|
||||
animationView.play()
|
||||
|
||||
container.addSubview(animationView)
|
||||
NSLayoutConstraint.activate([
|
||||
animationView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
animationView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
animationView.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
animationView.bottomAnchor.constraint(equalTo: container.bottomAnchor)
|
||||
])
|
||||
return container
|
||||
}
|
||||
#endif
|
||||
|
||||
let indicator = UIActivityIndicatorView(style: .large)
|
||||
indicator.color = AppDesign.primary
|
||||
indicator.startAnimating()
|
||||
return indicator
|
||||
}
|
||||
|
||||
/// 从主包中加载 loading 动画资源,兼容 Resources 子目录和根目录。
|
||||
#if canImport(Lottie)
|
||||
private static func loadAnimation() -> LottieAnimation? {
|
||||
if let animation = LottieAnimation.named("loading") {
|
||||
return animation
|
||||
}
|
||||
if let url = Bundle.main.url(forResource: "loading", withExtension: "json", subdirectory: "Resources") {
|
||||
return LottieAnimation.filepath(url.path)
|
||||
}
|
||||
if let url = Bundle.main.url(forResource: "loading", withExtension: "json") {
|
||||
return LottieAnimation.filepath(url.path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
#else
|
||||
private static func loadAnimation() -> Any? {
|
||||
nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// 全局 Loading 测试快照,用于验证引用计数和展示文案。
|
||||
struct GlobalLoadingSnapshot: Equatable {
|
||||
let isVisible: Bool
|
||||
let message: String
|
||||
let activeCount: Int
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user