Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 89 additions & 20 deletions V2er/View/Widget/Toast.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down Expand Up @@ -50,37 +59,97 @@ struct DefaultToastView: View {
}
}

Copy link

Copilot AI Dec 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ToastContainerView manages complex state with UUID-based tracking to prevent race conditions, but lacks documentation explaining this mechanism. Consider adding a doc comment above the struct explaining: (1) why UUID tracking is needed, (2) how the dismiss timer works, and (3) the lifecycle of dismiss task management. This would help future maintainers understand the non-obvious complexity.

Suggested change
/// Container responsible for presenting and auto-dismissing a toast.
///
/// This view intentionally manages slightly complex state to avoid race conditions
/// between multiple presentations and their corresponding dismiss timers:
/// - Each time a toast is shown, a new `toastId` (`UUID`) is generated. Any
/// scheduled dismiss task captures the `toastId` value that was current when
/// it was created and only dismisses the toast if that ID still matches.
/// This prevents an old timer from dismissing a newer toast that reused the
/// same `isPresented` binding.
/// - `scheduleDismiss()` starts a `Task` that sleeps for 1.5 seconds and then,
/// if it has not been cancelled and the captured `toastId` still matches the
/// latest `toastId`, calls `dismissToast()` on the main actor. This is the
/// auto-dismiss timer for the current toast.
/// - Dismiss tasks are cancelled in three places: before scheduling a new timer,
/// when the view disappears, and when the user taps to dismiss. This ensures
/// that at most one active timer exists per toast and that no stale tasks can
/// outlive the toast they were created for.

Copilot uses AI. Check for mistakes.
// 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<Content: View>: View {
@Binding var isPresented: Bool
let paddingTop: CGFloat
let content: Content

@State private var dismissTask: Task<Void, Never>?
@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<Content: View>(isPresented: Binding<Bool>,
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 {
Expand Down
Loading