mirror of
https://github.com/yattee/yattee.git
synced 2026-05-13 10:55:03 +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)
|
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) {
|
private func scheduleAutoDismiss(for toast: Toast) {
|
||||||
let task = Task { [weak self] in
|
let task = Task { [weak self] in
|
||||||
try? await Task.sleep(for: .seconds(toast.autoDismissDelay))
|
try? await Task.sleep(for: .seconds(toast.autoDismissDelay))
|
||||||
|
|||||||
@@ -12,8 +12,14 @@ struct ToastCardView: View {
|
|||||||
let toast: Toast
|
let toast: Toast
|
||||||
let onDismiss: () -> Void
|
let onDismiss: () -> Void
|
||||||
let onAction: (() async -> Void)?
|
let onAction: (() async -> Void)?
|
||||||
|
var onDragBegan: (() -> Void)? = nil
|
||||||
|
var onDragCancelled: (() -> Void)? = nil
|
||||||
|
|
||||||
@State private var isAnimating = false
|
@State private var isAnimating = false
|
||||||
|
#if os(iOS)
|
||||||
|
@State private var dragOffset: CGFloat = 0
|
||||||
|
@State private var didNotifyDragBegan = false
|
||||||
|
#endif
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: Self.iconSpacing) {
|
HStack(spacing: Self.iconSpacing) {
|
||||||
@@ -49,6 +55,13 @@ struct ToastCardView: View {
|
|||||||
.glassBackground(.regular, in: .capsule, fallback: .regularMaterial)
|
.glassBackground(.regular, in: .capsule, fallback: .regularMaterial)
|
||||||
.shadow(color: .black.opacity(0.15), radius: 10, y: 4)
|
.shadow(color: .black.opacity(0.15), radius: 10, y: 4)
|
||||||
.scaleEffect(isAnimating ? 1 : 0.9)
|
.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 {
|
.onAppear {
|
||||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
|
||||||
isAnimating = true
|
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
|
@ViewBuilder
|
||||||
private var leadingIcon: some View {
|
private var leadingIcon: some View {
|
||||||
// Use fixed frame to prevent size changes when switching between spinner and icon
|
// Use fixed frame to prevent size changes when switching between spinner and icon
|
||||||
|
|||||||
@@ -31,12 +31,15 @@ struct ToastOverlayView: View {
|
|||||||
onDismiss: {
|
onDismiss: {
|
||||||
toastManager?.dismiss(id: toast.id)
|
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))
|
.transition(.move(edge: .top).combined(with: .opacity))
|
||||||
#if !os(tvOS)
|
|
||||||
.gesture(swipeGesture(for: toast))
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.top, topPadding)
|
.padding(.top, topPadding)
|
||||||
@@ -56,18 +59,6 @@ struct ToastOverlayView: View {
|
|||||||
return 60
|
return 60
|
||||||
#endif
|
#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
|
// MARK: - View Extension
|
||||||
|
|||||||
Reference in New Issue
Block a user