183 lines
5.8 KiB
Swift
183 lines
5.8 KiB
Swift
//
|
||
// 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
|
||
})
|
||
}
|
||
}
|