// // ToastCenter.swift // suixinkan // // Created by Codex on 2026/6/18. // import Combine import SwiftUI import UIKit @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 独立窗口宿主,确保提示浮在 sheet / fullScreenCover 等 present 层之上。 @MainActor private final class ToastWindowPresenter { static let shared = ToastWindowPresenter() private var toastWindow: UIWindow? /// 在独立 UIWindow 中展示 Toast。 func show(message: String) { guard let windowScene = resolveWindowScene() else { return } let window: UIWindow if let existing = toastWindow, existing.windowScene === windowScene { window = existing } else { let newWindow = PassthroughWindow(windowScene: windowScene) newWindow.windowLevel = .alert + 1 newWindow.backgroundColor = .clear toastWindow = newWindow window = newWindow } let hosting = UIHostingController( rootView: ToastBannerHostView(message: message) ) hosting.view.backgroundColor = .clear window.rootViewController = hosting window.isHidden = false } /// 隐藏 Toast 窗口。 func hide() { toastWindow?.isHidden = true toastWindow?.rootViewController = nil } /// 取当前前台 window scene,供独立 Toast 窗口挂载。 private func resolveWindowScene() -> UIWindowScene? { let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } return scenes.first { $0.activationState == .foregroundActive } ?? scenes.first } } /// 透传触摸事件的 Toast 窗口,避免挡住下层弹窗交互。 private final class PassthroughWindow: UIWindow { override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { guard let view = super.hitTest(point, with: event) else { return nil } return view === rootViewController?.view ? nil : view } } /// Toast 全屏居中容器。 private struct ToastBannerHostView: View { let message: String var body: some View { ZStack { ToastBannerContent(message: message) } .frame(maxWidth: .infinity, maxHeight: .infinity) .allowsHitTesting(false) } } /// 屏幕中央的黑色半透明圆角 Toast 卡片。 private struct ToastBannerContent: View { let message: String var body: some View { HStack(spacing: 10) { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 16, weight: .semibold)) Text(message) .font(.system(size: 16, weight: .medium)) .lineLimit(3) .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) } .foregroundStyle(.white) .padding(.horizontal, 20) .padding(.vertical, 14) .background(Color.black.opacity(0.78), in: RoundedRectangle(cornerRadius: 12)) .padding(.horizontal, 40) .accessibilityIdentifier("global.toast") } } /// 全局 Toast 展示桥接层,监听 ToastCenter 并驱动独立窗口。 private struct GlobalToastOverlay: ViewModifier { @EnvironmentObject private var toastCenter: ToastCenter func body(content: Content) -> some View { content .onAppear { syncToastWindow(with: toastCenter.message) } .onChange(of: toastCenter.message) { message in syncToastWindow(with: message) } } /// 根据当前文案显示或隐藏 Toast 窗口。 private func syncToastWindow(with message: String?) { if let message { ToastWindowPresenter.shared.show(message: message) } else { ToastWindowPresenter.shared.hide() } } } #if DEBUG /// 全局 Toast 测试快照,用于验证文案和自动隐藏行为。 struct ToastSnapshot: Equatable { let message: String? } #endif extension View { /// 给任意根视图追加全局 Toast 展示能力。 func globalToastOverlay() -> some View { modifier(GlobalToastOverlay()) } }