From 1bddfa3524cbbefa7549d59d24ac75668d7447cd Mon Sep 17 00:00:00 2001 From: graycreate Date: Sat, 20 Dec 2025 14:47:19 +0800 Subject: [PATCH 1/2] fix: optimize Toast component for smoother animations and reliable auto-dismiss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace DispatchQueue.main.asyncAfter with cancellable Swift Task - Add unique toast ID tracking to prevent stale timers dismissing new toasts - Implement asymmetric transition with combined move and opacity effects - Extract toast logic into ToastContainerView for better lifecycle management - Clean up timer on view disappear to prevent memory leaks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- V2er/View/Widget/Toast.swift | 89 ++++++++++++++++++++++++++++-------- 1 file changed, 69 insertions(+), 20 deletions(-) diff --git a/V2er/View/Widget/Toast.swift b/V2er/View/Widget/Toast.swift index dd2ad97..b7f1b28 100644 --- a/V2er/View/Widget/Toast.swift +++ b/V2er/View/Widget/Toast.swift @@ -50,34 +50,83 @@ struct DefaultToastView: View { } } +private struct ToastContainerView: View { + @Binding var isPresented: Bool + let paddingTop: CGFloat + let content: Content + + @State private var dismissTask: Task? + @State private var toastId = UUID() + + 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(.asymmetric( + insertion: .move(edge: .top).combined(with: .opacity), + removal: .move(edge: .top).combined(with: .opacity) + )) + .zIndex(1) + .onTapGesture { + dismissToast() + } + .onAppear { + scheduleDismiss() + } + .onDisappear { + cancelDismissTask() + } + .onChange(of: isPresented) { newValue in + if newValue { + toastId = UUID() + scheduleDismiss() + } + } + } + + private func scheduleDismiss() { + cancelDismissTask() + + let currentId = toastId + dismissTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 1_500_000_000) // 1.5 seconds + + guard !Task.isCancelled, toastId == currentId else { return } + + dismissToast() + } + } + + private func cancelDismissTask() { + dismissTask?.cancel() + dismissTask = nil + } + + private func dismissToast() { + cancelDismissTask() + withAnimation(.easeInOut(duration: 0.25)) { + isPresented = false + } + } +} + 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: 0.25), value: isPresented.wrappedValue) } } From d2d751914764b680060a2ac523345084271ed5d1 Mon Sep 17 00:00:00 2001 From: graycreate Date: Sat, 20 Dec 2025 14:58:08 +0800 Subject: [PATCH 2/2] fix: address Copilot review comments on Toast component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract magic numbers to ToastConfig enum (dismissDelay, animationDuration) - Add documentation explaining UUID-based race condition prevention - Simplify transition from asymmetric to symmetric (same effect for both) - Remove redundant double-animation (keep only animation modifier) - Add hasScheduledDismiss flag to prevent redundant scheduleDismiss calls 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- V2er/View/Widget/Toast.swift | 40 +++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/V2er/View/Widget/Toast.swift b/V2er/View/Widget/Toast.swift index b7f1b28..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,6 +59,15 @@ 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 @@ -57,6 +75,7 @@ private struct ToastContainerView: View { @State private var dismissTask: Task? @State private var toastId = UUID() + @State private var hasScheduledDismiss = false var body: some View { content @@ -64,10 +83,7 @@ private struct ToastContainerView: View { .cornerRadius(99) .shadow(color: Color.primaryText.opacity(0.3), radius: 3) .padding(.top, paddingTop) - .transition(.asymmetric( - insertion: .move(edge: .top).combined(with: .opacity), - removal: .move(edge: .top).combined(with: .opacity) - )) + .transition(.move(edge: .top).combined(with: .opacity)) .zIndex(1) .onTapGesture { dismissToast() @@ -79,7 +95,8 @@ private struct ToastContainerView: View { cancelDismissTask() } .onChange(of: isPresented) { newValue in - if newValue { + if newValue && hasScheduledDismiss { + // Re-presentation: reset timer for new toast toastId = UUID() scheduleDismiss() } @@ -88,10 +105,11 @@ private struct ToastContainerView: View { private func scheduleDismiss() { cancelDismissTask() + hasScheduledDismiss = true let currentId = toastId dismissTask = Task { @MainActor in - try? await Task.sleep(nanoseconds: 1_500_000_000) // 1.5 seconds + try? await Task.sleep(nanoseconds: ToastConfig.dismissDelay) guard !Task.isCancelled, toastId == currentId else { return } @@ -106,12 +124,12 @@ private struct ToastContainerView: View { private func dismissToast() { cancelDismissTask() - withAnimation(.easeInOut(duration: 0.25)) { - isPresented = false - } + isPresented = false } } +// MARK: - View Extension + extension View { func toast(isPresented: Binding, paddingTop: CGFloat = 0, @@ -126,10 +144,12 @@ extension View { ) } } - .animation(.easeInOut(duration: 0.25), value: isPresented.wrappedValue) + .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 {