// // ToastCenter.swift // suixinkan // // Created by Codex on 2026/6/18. // import Combine import SwiftUI @MainActor /// 全局 Toast 状态中心,负责保存和清除当前提示文案。 final class ToastCenter: ObservableObject { @Published fileprivate var message: String? private let autoDismissNanoseconds: UInt64 @Published private var dismissTask: Task? @Published private var displayToken: UInt64 = 0 /// 创建 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 安排自动隐藏任务,并避免旧任务误清除新 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 } /// 全局 Toast 叠加层修饰器,负责把 Toast 展示到页面顶部。 private struct GlobalToastOverlay: ViewModifier { @EnvironmentObject private var toastCenter: ToastCenter func body(content: Content) -> some View { content .overlay(alignment: .top) { GeometryReader { proxy in if let message = toastCenter.message { toastBanner(message, topInset: proxy.safeAreaInsets.top) .transition(.move(edge: .top).combined(with: .opacity)) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } } .allowsHitTesting(false) } .animation(.easeInOut(duration: 0.18), value: toastCenter.message) } /// 构建顶部全宽 Toast 横幅。 private func toastBanner(_ message: String, topInset: CGFloat) -> some View { HStack { Text(message) .font(.system(size: 14, weight: .medium)) .lineLimit(2) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .center) } .foregroundStyle(.white) .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) .padding(.top, topInset + AppMetrics.Spacing.small) .padding(.bottom, AppMetrics.Spacing.small) .frame(maxWidth: .infinity, alignment: .center) .background(AppDesign.primary.ignoresSafeArea(edges: .top)) } } #if DEBUG /// 全局 Toast 测试快照,用于验证文案和自动隐藏行为。 struct ToastSnapshot: Equatable { let message: String? } #endif extension View { /// 给任意根视图追加全局 Toast 展示能力。 func globalToastOverlay() -> some View { modifier(GlobalToastOverlay()) } }