Initial commit: suixinkan_ios UIKit rewrite project.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
62
suixinkan_ios/Core/UI/FeaturePlaceholderViewController.swift
Normal file
62
suixinkan_ios/Core/UI/FeaturePlaceholderViewController.swift
Normal file
@ -0,0 +1,62 @@
|
||||
//
|
||||
// FeaturePlaceholderViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 功能迁移占位页,承接尚未 UIKit 化的首页菜单入口。
|
||||
final class FeaturePlaceholderViewController: UIViewController {
|
||||
|
||||
private let pageTitle: String
|
||||
private let uri: String
|
||||
|
||||
init(title: String, uri: String) {
|
||||
self.pageTitle = title
|
||||
self.uri = uri
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
||||
title = pageTitle
|
||||
|
||||
let icon = UIImageView(image: UIImage(systemName: "square.grid.2x2"))
|
||||
icon.tintColor = AppDesignUIKit.primary
|
||||
icon.contentMode = .scaleAspectFit
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = pageTitle
|
||||
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title2, weight: .semibold)
|
||||
titleLabel.textColor = AppDesignUIKit.textPrimary
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
let uriLabel = UILabel()
|
||||
uriLabel.text = uri
|
||||
uriLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
||||
uriLabel.textColor = AppDesignUIKit.textSecondary
|
||||
uriLabel.textAlignment = .center
|
||||
uriLabel.numberOfLines = 0
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [icon, titleLabel, uriLabel])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = AppMetrics.Spacing.medium
|
||||
stack.alignment = .center
|
||||
view.addSubview(stack)
|
||||
|
||||
icon.snp.makeConstraints { make in
|
||||
make.width.height.equalTo(44)
|
||||
}
|
||||
stack.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.large)
|
||||
}
|
||||
}
|
||||
}
|
||||
107
suixinkan_ios/Core/UI/RemoteImage.swift
Normal file
107
suixinkan_ios/Core/UI/RemoteImage.swift
Normal file
@ -0,0 +1,107 @@
|
||||
//
|
||||
// RemoteImage.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Kingfisher
|
||||
import UIKit
|
||||
|
||||
extension UIImageView {
|
||||
/// 使用 Kingfisher 加载远程图片,并提供占位图与失败回退。
|
||||
func loadRemoteImage(
|
||||
urlString: String?,
|
||||
contentMode: UIView.ContentMode = .scaleAspectFill,
|
||||
placeholder: UIImage? = nil,
|
||||
failureImage: UIImage? = nil
|
||||
) {
|
||||
let normalized = normalizedURLString(from: urlString)
|
||||
self.contentMode = contentMode
|
||||
|
||||
guard let normalized, let url = URL(string: normalized) else {
|
||||
kf.cancelDownloadTask()
|
||||
image = failureImage ?? placeholder
|
||||
return
|
||||
}
|
||||
|
||||
kf.setImage(
|
||||
with: url,
|
||||
placeholder: placeholder,
|
||||
options: [
|
||||
.cacheOriginalImage,
|
||||
.transition(.fade(0.2))
|
||||
]
|
||||
) { [weak self] result in
|
||||
guard let self else { return }
|
||||
if case .failure = result {
|
||||
self.image = failureImage ?? placeholder
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载远程圆形头像,失败时展示系统图标占位。
|
||||
func loadRemoteAvatar(
|
||||
urlString: String,
|
||||
systemImageName: String = "person.fill",
|
||||
iconSize: CGFloat = 44
|
||||
) {
|
||||
clipsToBounds = true
|
||||
layer.cornerRadius = min(bounds.width, bounds.height) / 2
|
||||
|
||||
let placeholder = Self.avatarPlaceholder(
|
||||
systemImageName: systemImageName,
|
||||
iconSize: iconSize,
|
||||
size: bounds.size == .zero ? CGSize(width: iconSize, height: iconSize) : bounds.size
|
||||
)
|
||||
|
||||
loadRemoteImage(
|
||||
urlString: urlString,
|
||||
contentMode: .scaleAspectFill,
|
||||
placeholder: placeholder,
|
||||
failureImage: placeholder
|
||||
)
|
||||
}
|
||||
|
||||
/// 取消当前远程图片加载任务。
|
||||
func cancelRemoteImageLoad() {
|
||||
kf.cancelDownloadTask()
|
||||
}
|
||||
|
||||
private func normalizedURLString(from urlString: String?) -> String? {
|
||||
let text = urlString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
|
||||
private static func avatarPlaceholder(
|
||||
systemImageName: String,
|
||||
iconSize: CGFloat,
|
||||
size: CGSize
|
||||
) -> UIImage? {
|
||||
let renderer = UIGraphicsImageRenderer(size: size)
|
||||
return renderer.image { context in
|
||||
AppDesign.primarySoft.setFill()
|
||||
context.fill(CGRect(origin: .zero, size: size))
|
||||
|
||||
let configuration = UIImage.SymbolConfiguration(pointSize: iconSize, weight: .semibold)
|
||||
guard let symbol = UIImage(systemName: systemImageName, withConfiguration: configuration)?
|
||||
.withTintColor(AppDesign.primary, renderingMode: .alwaysOriginal) else {
|
||||
return
|
||||
}
|
||||
|
||||
let origin = CGPoint(
|
||||
x: (size.width - symbol.size.width) / 2,
|
||||
y: (size.height - symbol.size.height) / 2
|
||||
)
|
||||
symbol.draw(at: origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension UIImageView {
|
||||
/// 在布局变化后刷新圆形头像圆角。
|
||||
func refreshRemoteAvatarCornerRadius() {
|
||||
guard bounds.width > 0, bounds.height > 0 else { return }
|
||||
layer.cornerRadius = min(bounds.width, bounds.height) / 2
|
||||
}
|
||||
}
|
||||
31
suixinkan_ios/Core/UI/UIColor+AppDesign.swift
Normal file
31
suixinkan_ios/Core/UI/UIColor+AppDesign.swift
Normal file
@ -0,0 +1,31 @@
|
||||
//
|
||||
// UIColor+AppDesign.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UIColor {
|
||||
/// 通过 0xRRGGBB 和透明度创建 UIColor。
|
||||
convenience init(hex: UInt, alpha: CGFloat = 1.0) {
|
||||
self.init(
|
||||
red: CGFloat((hex >> 16) & 0xff) / 255.0,
|
||||
green: CGFloat((hex >> 8) & 0xff) / 255.0,
|
||||
blue: CGFloat(hex & 0xff) / 255.0,
|
||||
alpha: alpha
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// UIKit 设计色值,与 SwiftUI `AppDesign` 保持一致。
|
||||
enum AppDesignUIKit {
|
||||
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 pageBackground = UIColor(hex: 0xF5F5F5)
|
||||
static let cardBackground = UIColor.white
|
||||
}
|
||||
111
suixinkan_ios/Core/UI/ViewControllerHelpers.swift
Normal file
111
suixinkan_ios/Core/UI/ViewControllerHelpers.swift
Normal file
@ -0,0 +1,111 @@
|
||||
//
|
||||
// ViewControllerHelpers.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// ViewController 通用工具,封装 Toast、Loading 和 ViewModel 绑定。
|
||||
@MainActor
|
||||
enum ViewControllerHelpers {
|
||||
static var services: AppServices { AppServices.shared }
|
||||
|
||||
/// 绑定 ViewModel 的 onChange 回调并在 deinit 时自动清理。
|
||||
static func bind(onChange: (() -> Void)?, owner: AnyObject, handler: @escaping () -> Void) {
|
||||
onChange?()
|
||||
_ = ViewModelBindingToken(owner: owner, handler: handler)
|
||||
}
|
||||
}
|
||||
|
||||
/// 持有 ViewModel onChange 闭包的弱引用令牌。
|
||||
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
|
||||
}
|
||||
|
||||
deinit {
|
||||
handler()
|
||||
}
|
||||
}
|
||||
|
||||
extension UIViewController {
|
||||
var appServices: AppServices { AppServices.shared }
|
||||
|
||||
func showToast(_ message: String) {
|
||||
appServices.toastCenter.show(message)
|
||||
}
|
||||
|
||||
func showGlobalLoading(_ message: String = "") {
|
||||
appServices.globalLoading.show(message: message)
|
||||
}
|
||||
|
||||
func hideGlobalLoading() {
|
||||
appServices.globalLoading.hide()
|
||||
}
|
||||
|
||||
/// 创建带圆角白底的卡片容器。
|
||||
func makeCardView(cornerRadius: CGFloat = 8) -> UIView {
|
||||
let view = UIView()
|
||||
view.backgroundColor = AppDesignUIKit.cardBackground
|
||||
view.layer.cornerRadius = cornerRadius
|
||||
view.clipsToBounds = true
|
||||
return view
|
||||
}
|
||||
|
||||
/// 创建空状态占位视图。
|
||||
func makeEmptyStateView(title: String, message: String, systemImage: String) -> UIView {
|
||||
let container = UIView()
|
||||
|
||||
let stack = UIStackView()
|
||||
stack.axis = .vertical
|
||||
stack.alignment = .center
|
||||
stack.spacing = AppMetrics.Spacing.small
|
||||
|
||||
let imageView = UIImageView(image: UIImage(systemName: systemImage))
|
||||
imageView.tintColor = AppDesignUIKit.textSecondary
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.snp.makeConstraints { make in
|
||||
make.width.height.equalTo(48)
|
||||
}
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = title
|
||||
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title3, weight: .semibold)
|
||||
titleLabel.textColor = AppDesignUIKit.textPrimary
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.text = message
|
||||
messageLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
||||
messageLabel.textColor = AppDesignUIKit.textSecondary
|
||||
messageLabel.textAlignment = .center
|
||||
messageLabel.numberOfLines = 0
|
||||
|
||||
stack.addArrangedSubview(imageView)
|
||||
stack.addArrangedSubview(titleLabel)
|
||||
stack.addArrangedSubview(messageLabel)
|
||||
container.addSubview(stack)
|
||||
stack.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.large)
|
||||
}
|
||||
return container
|
||||
}
|
||||
|
||||
/// 创建主色填充按钮。
|
||||
func makePrimaryButton(title: String) -> UIButton {
|
||||
var config = UIButton.Configuration.filled()
|
||||
config.title = title
|
||||
config.baseBackgroundColor = AppDesignUIKit.primary
|
||||
config.baseForegroundColor = .white
|
||||
config.cornerStyle = .medium
|
||||
config.contentInsets = NSDirectionalEdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)
|
||||
let button = UIButton(configuration: config)
|
||||
return button
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user