Initial commit: suixinkan_ios UIKit rewrite project.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
182
suixinkan_ios/App/State/ToastCenter.swift
Normal file
182
suixinkan_ios/App/State/ToastCenter.swift
Normal file
@ -0,0 +1,182 @@
|
||||
//
|
||||
// ToastCenter.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/18.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
/// 全局 Toast 状态中心,负责保存和清除当前提示文案。
|
||||
final class ToastCenter {
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var message: String? { didSet { onChange?() } }
|
||||
|
||||
private let autoDismissNanoseconds: UInt64
|
||||
private var dismissTask: Task<Void, Never>?
|
||||
private var displayToken: UInt64 = 0
|
||||
private weak var overlayView: ToastOverlayView?
|
||||
|
||||
/// 创建 Toast 状态中心,默认 2.2 秒后自动隐藏。
|
||||
init(autoDismissNanoseconds: UInt64 = 2_200_000_000) {
|
||||
self.autoDismissNanoseconds = autoDismissNanoseconds
|
||||
}
|
||||
|
||||
/// 显示一条全局 Toast 文案。
|
||||
func show(_ message: String) {
|
||||
displayToken &+= 1
|
||||
self.message = message
|
||||
scheduleAutoDismiss(token: displayToken)
|
||||
}
|
||||
|
||||
/// 清除当前 Toast 文案。
|
||||
func dismiss() {
|
||||
displayToken &+= 1
|
||||
dismissTask?.cancel()
|
||||
dismissTask = nil
|
||||
message = nil
|
||||
}
|
||||
|
||||
/// 将全局 Toast 展示层挂载到指定窗口,通常只在应用根部调用一次。
|
||||
func attachToWindow(_ window: UIWindow) {
|
||||
if let overlayView, overlayView.superview === window {
|
||||
return
|
||||
}
|
||||
|
||||
let overlay = ToastOverlayView(center: self)
|
||||
overlay.frame = window.bounds
|
||||
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
window.addSubview(overlay)
|
||||
overlayView = overlay
|
||||
}
|
||||
|
||||
/// 为当前 Toast 安排自动隐藏任务,并避免旧任务误清除新 Toast。
|
||||
private func scheduleAutoDismiss(token: UInt64) {
|
||||
dismissTask?.cancel()
|
||||
let delay = autoDismissNanoseconds
|
||||
dismissTask = Task { [weak self] in
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: delay)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
guard let self, self.displayToken == token else { return }
|
||||
self.message = nil
|
||||
self.dismissTask = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// 测试专用快照,业务代码不应读取 Toast 展示状态。
|
||||
var snapshotForTests: ToastSnapshot {
|
||||
ToastSnapshot(message: message)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// 全局 Toast 测试快照,用于验证文案和自动隐藏行为。
|
||||
struct ToastSnapshot: Equatable {
|
||||
let message: String?
|
||||
}
|
||||
#endif
|
||||
|
||||
/// 全局 Toast 展示层,负责把 Toast 展示到窗口顶部。
|
||||
final class ToastOverlayView: UIView {
|
||||
private weak var toastCenter: ToastCenter?
|
||||
private let bannerView = UIView()
|
||||
private let messageLabel = UILabel()
|
||||
|
||||
/// 使用 Toast 状态中心初始化展示层。
|
||||
init(center: ToastCenter) {
|
||||
self.toastCenter = center
|
||||
super.init(frame: .zero)
|
||||
isUserInteractionEnabled = false
|
||||
configureViews()
|
||||
center.onChange = { [weak self] in
|
||||
self?.refreshPresentation(animated: true)
|
||||
}
|
||||
refreshPresentation(animated: false)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
nil
|
||||
}
|
||||
|
||||
private func configureViews() {
|
||||
bannerView.backgroundColor = AppDesign.primary
|
||||
bannerView.isHidden = true
|
||||
bannerView.alpha = 0
|
||||
|
||||
messageLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
messageLabel.textColor = .white
|
||||
messageLabel.textAlignment = .center
|
||||
messageLabel.numberOfLines = 2
|
||||
|
||||
bannerView.addSubview(messageLabel)
|
||||
addSubview(bannerView)
|
||||
|
||||
bannerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
messageLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
bannerView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
bannerView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
bannerView.topAnchor.constraint(equalTo: topAnchor),
|
||||
|
||||
messageLabel.leadingAnchor.constraint(equalTo: bannerView.leadingAnchor, constant: AppMetrics.Spacing.pageHorizontal),
|
||||
messageLabel.trailingAnchor.constraint(equalTo: bannerView.trailingAnchor, constant: -AppMetrics.Spacing.pageHorizontal),
|
||||
messageLabel.topAnchor.constraint(equalTo: bannerView.safeAreaLayoutGuide.topAnchor, constant: AppMetrics.Spacing.small),
|
||||
messageLabel.bottomAnchor.constraint(equalTo: bannerView.bottomAnchor, constant: -AppMetrics.Spacing.small)
|
||||
])
|
||||
}
|
||||
|
||||
private func refreshPresentation(animated: Bool) {
|
||||
guard let message = toastCenter?.message, !message.isEmpty else {
|
||||
hideBanner(animated: animated)
|
||||
return
|
||||
}
|
||||
|
||||
messageLabel.text = message
|
||||
showBanner(animated: animated)
|
||||
}
|
||||
|
||||
private func showBanner(animated: Bool) {
|
||||
guard bannerView.isHidden || bannerView.alpha < 1 else { return }
|
||||
|
||||
bannerView.isHidden = false
|
||||
guard animated else {
|
||||
bannerView.alpha = 1
|
||||
bannerView.transform = .identity
|
||||
return
|
||||
}
|
||||
|
||||
bannerView.alpha = 0
|
||||
bannerView.transform = CGAffineTransform(translationX: 0, y: -12)
|
||||
UIView.animate(withDuration: 0.18, delay: 0, options: [.curveEaseInOut]) {
|
||||
self.bannerView.alpha = 1
|
||||
self.bannerView.transform = .identity
|
||||
}
|
||||
}
|
||||
|
||||
private func hideBanner(animated: Bool) {
|
||||
guard !bannerView.isHidden else { return }
|
||||
|
||||
guard animated else {
|
||||
bannerView.isHidden = true
|
||||
bannerView.alpha = 0
|
||||
return
|
||||
}
|
||||
|
||||
UIView.animate(withDuration: 0.18, delay: 0, options: [.curveEaseInOut], animations: {
|
||||
self.bannerView.alpha = 0
|
||||
self.bannerView.transform = CGAffineTransform(translationX: 0, y: -12)
|
||||
}, completion: { _ in
|
||||
self.bannerView.isHidden = true
|
||||
self.bannerView.transform = .identity
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user