// // 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? 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 }) } }