diff --git a/V2er/View/Widget/Toast.swift b/V2er/View/Widget/Toast.swift index dd2ad97..8a2565f 100644 --- a/V2er/View/Widget/Toast.swift +++ b/V2er/View/Widget/Toast.swift @@ -8,6 +8,15 @@ import SwiftUI +// MARK: - Toast Configuration + +private enum ToastConfig { + static let dismissDelay: UInt64 = 1_500_000_000 // 1.5 seconds in nanoseconds + static let animationDuration: Double = 0.25 +} + +// MARK: - Toast + final class Toast { var isPresented: Bool = false var title: String = "" @@ -50,37 +59,97 @@ struct DefaultToastView: View { } } +// MARK: - ToastContainerView + +/// Container responsible for presenting and auto-dismissing a toast. +/// +/// Uses UUID-based tracking to prevent race conditions between multiple presentations: +/// - Each toast gets a unique `toastId`. Dismiss timers only act if their captured ID +/// matches the current one, preventing stale timers from dismissing newer toasts. +/// - The dismiss `Task` is cancelled on view disappear, tap dismiss, or before scheduling +/// a new timer, ensuring at most one active timer exists per toast. +private struct ToastContainerView: View { + @Binding var isPresented: Bool + let paddingTop: CGFloat + let content: Content + + @State private var dismissTask: Task? + @State private var toastId = UUID() + @State private var hasScheduledDismiss = false + + var body: some View { + content + .background(Color.secondaryBackground.opacity(0.98)) + .cornerRadius(99) + .shadow(color: Color.primaryText.opacity(0.3), radius: 3) + .padding(.top, paddingTop) + .transition(.move(edge: .top).combined(with: .opacity)) + .zIndex(1) + .onTapGesture { + dismissToast() + } + .onAppear { + scheduleDismiss() + } + .onDisappear { + cancelDismissTask() + } + .onChange(of: isPresented) { newValue in + if newValue && hasScheduledDismiss { + // Re-presentation: reset timer for new toast + toastId = UUID() + scheduleDismiss() + } + } + } + + private func scheduleDismiss() { + cancelDismissTask() + hasScheduledDismiss = true + + let currentId = toastId + dismissTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: ToastConfig.dismissDelay) + + guard !Task.isCancelled, toastId == currentId else { return } + + dismissToast() + } + } + + private func cancelDismissTask() { + dismissTask?.cancel() + dismissTask = nil + } + + private func dismissToast() { + cancelDismissTask() + isPresented = false + } +} + +// MARK: - View Extension + extension View { func toast(isPresented: Binding, paddingTop: CGFloat = 0, @ViewBuilder content: () -> Content?) -> some View { ZStack(alignment: .top) { self - if isPresented.wrappedValue { - content() - .background(Color.secondaryBackground.opacity(0.98)) - .cornerRadius(99) - .shadow(color: Color.primaryText.opacity(0.3), radius: 3) - .padding(.top, paddingTop) - .transition(AnyTransition.move(edge: .top)) - .zIndex(1) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - withAnimation { - isPresented.wrappedValue = false - } - } - } - .onTapGesture { - withAnimation { - isPresented.wrappedValue = false - } - } + if isPresented.wrappedValue, let toastContent = content() { + ToastContainerView( + isPresented: isPresented, + paddingTop: paddingTop, + content: toastContent + ) } } + .animation(.easeInOut(duration: ToastConfig.animationDuration), value: isPresented.wrappedValue) } } +// MARK: - Preview + struct ToastView_Previews: PreviewProvider { @State static var showToast: Bool = true static var previews: some View {