Add interactive swipe-to-dismiss for iOS toasts

Toast cards now follow the finger upward and dismiss on either a
sufficient drag or a fast flick (via predicted-end translation). The
auto-dismiss timer pauses while the user is dragging and re-arms if
they release without dismissing.
This commit is contained in:
Arkadiusz Fal
2026-05-08 03:00:29 +02:00
parent 4f763373c1
commit b163864628
3 changed files with 69 additions and 16 deletions

View File

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

View File

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

View File

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