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:
2026-06-23 14:48:31 +08:00
parent 31a262fd08
commit b7054145e7
5 changed files with 146 additions and 27 deletions

View File

@ -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 {