mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 18:35:05 +00:00
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:
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user