diff --git a/Yattee/Services/ToastManager.swift b/Yattee/Services/ToastManager.swift index 15e2c5bd..e0822556 100644 --- a/Yattee/Services/ToastManager.swift +++ b/Yattee/Services/ToastManager.swift @@ -380,6 +380,20 @@ final class ToastManager { LoggingService.shared.info("Dismissed toast: \(id)", category: .general) } + /// Cancel the pending auto-dismiss timer for a toast (e.g. while user is interacting with it). + func pauseAutoDismiss(id: UUID) { + dismissTasks[id]?.cancel() + dismissTasks.removeValue(forKey: id) + } + + /// Re-arm the auto-dismiss timer for a toast using its original delay. + func resumeAutoDismiss(id: UUID) { + guard let toast = activeToasts.first(where: { $0.id == id }) else { return } + guard !toast.isPersistent || toast.autoDismissDelay > 0 else { return } + dismissTasks[id]?.cancel() + scheduleAutoDismiss(for: toast) + } + private func scheduleAutoDismiss(for toast: Toast) { let task = Task { [weak self] in try? await Task.sleep(for: .seconds(toast.autoDismissDelay)) diff --git a/Yattee/Views/Toast/ToastCardView.swift b/Yattee/Views/Toast/ToastCardView.swift index 141971b0..bbd40fe2 100644 --- a/Yattee/Views/Toast/ToastCardView.swift +++ b/Yattee/Views/Toast/ToastCardView.swift @@ -12,8 +12,14 @@ struct ToastCardView: View { let toast: Toast let onDismiss: () -> Void let onAction: (() async -> Void)? + var onDragBegan: (() -> Void)? = nil + var onDragCancelled: (() -> Void)? = nil @State private var isAnimating = false + #if os(iOS) + @State private var dragOffset: CGFloat = 0 + @State private var didNotifyDragBegan = false + #endif var body: some View { HStack(spacing: Self.iconSpacing) { @@ -49,6 +55,13 @@ struct ToastCardView: View { .glassBackground(.regular, in: .capsule, fallback: .regularMaterial) .shadow(color: .black.opacity(0.15), radius: 10, y: 4) .scaleEffect(isAnimating ? 1 : 0.9) + #if os(iOS) + .compositingGroup() + .opacity(Double(1 - min(abs(min(dragOffset, 0)) / 120, 1))) + .offset(y: min(dragOffset, 0)) + .contentShape(Capsule()) + .simultaneousGesture(swipeGesture) + #endif .onAppear { withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { isAnimating = true @@ -56,6 +69,41 @@ struct ToastCardView: View { } } + #if os(iOS) + private var swipeGesture: some Gesture { + DragGesture() + .onChanged { value in + if !didNotifyDragBegan { + didNotifyDragBegan = true + onDragBegan?() + } + let h = value.translation.height + let target: CGFloat = h < 0 ? h : h * 0.2 + var tx = Transaction() + tx.disablesAnimations = true + withTransaction(tx) { + dragOffset = target + } + } + .onEnded { value in + didNotifyDragBegan = false + let translation = value.translation.height + let predicted = value.predictedEndTranslation.height + if translation < -60 || predicted < -200 { + withAnimation(.easeOut(duration: 0.2)) { + dragOffset = -200 + } + onDismiss() + } else { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + dragOffset = 0 + } + onDragCancelled?() + } + } + } + #endif + @ViewBuilder private var leadingIcon: some View { // Use fixed frame to prevent size changes when switching between spinner and icon diff --git a/Yattee/Views/Toast/ToastOverlayView.swift b/Yattee/Views/Toast/ToastOverlayView.swift index 487fdcc1..ba2850e3 100644 --- a/Yattee/Views/Toast/ToastOverlayView.swift +++ b/Yattee/Views/Toast/ToastOverlayView.swift @@ -31,12 +31,15 @@ struct ToastOverlayView: View { onDismiss: { toastManager?.dismiss(id: toast.id) }, - onAction: toast.action?.handler + onAction: toast.action?.handler, + onDragBegan: { + toastManager?.pauseAutoDismiss(id: toast.id) + }, + onDragCancelled: { + toastManager?.resumeAutoDismiss(id: toast.id) + } ) .transition(.move(edge: .top).combined(with: .opacity)) - #if !os(tvOS) - .gesture(swipeGesture(for: toast)) - #endif } } .padding(.top, topPadding) @@ -56,18 +59,6 @@ struct ToastOverlayView: View { return 60 #endif } - - #if !os(tvOS) - private func swipeGesture(for toast: Toast) -> some Gesture { - DragGesture() - .onEnded { value in - // Swipe up to dismiss - if value.translation.height < -50 { - toastManager?.dismiss(id: toast.id) - } - } - } - #endif } // MARK: - View Extension