Redesign global toast as auto-dismissing top banner.
Use a full-width primary banner with centered text, auto-hide after 2.2 seconds, and tests for timer reset behavior. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -13,16 +13,54 @@ import SwiftUI
|
||||
/// 全局 Toast 状态中心,负责保存和清除当前提示文案。
|
||||
final class ToastCenter {
|
||||
fileprivate var message: String?
|
||||
@ObservationIgnored private let autoDismissNanoseconds: UInt64
|
||||
@ObservationIgnored private var dismissTask: Task<Void, Never>?
|
||||
@ObservationIgnored 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 展示到页面顶部。
|
||||
@ -32,35 +70,44 @@ private struct GlobalToastOverlay: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.overlay(alignment: .top) {
|
||||
if let message = toastCenter.message {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
|
||||
Text(message)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.lineLimit(2)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Button("关闭") {
|
||||
toastCenter.dismiss()
|
||||
}
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
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)
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.black.opacity(0.78), in: RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 10)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
.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 {
|
||||
|
||||
Reference in New Issue
Block a user