diff --git a/Bitkit/Components/ToastView.swift b/Bitkit/Components/ToastView.swift index bd6254963..24305f1ab 100644 --- a/Bitkit/Components/ToastView.swift +++ b/Bitkit/Components/ToastView.swift @@ -21,7 +21,7 @@ struct ToastView: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(16) .background(accentColor.opacity(0.32)) - .background(.ultraThinMaterial) + .background(BlurView()) .cornerRadius(16) .shadow(color: .black.opacity(0.4), radius: 10, x: 0, y: 25) .accessibilityIdentifierIfPresent(toast.accessibilityIdentifier) @@ -78,7 +78,7 @@ struct ToastView: View { } } else { // Snap back to original position - withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + withAnimation(ToastMotion.entrance) { dragOffset = 0 } } diff --git a/Bitkit/Managers/ToastWindowManager.swift b/Bitkit/Managers/ToastWindowManager.swift index efa2aa0ea..ca0542509 100644 --- a/Bitkit/Managers/ToastWindowManager.swift +++ b/Bitkit/Managers/ToastWindowManager.swift @@ -1,6 +1,17 @@ import SwiftUI import UIKit +/// Motion for toast show/hide. Arrival is physical (a gentle spring settle); departure just +/// gets out of the way (a quick fade), mirroring Apple's system banner pattern. +enum ToastMotion { + static let entrance: Animation = .snappy(duration: 0.4) + static let exit: Animation = .easeOut(duration: exitDuration) + /// Hit-test frame cleanup waits for the exit to finish, with a small buffer. + static let exitSettleTime: Double = exitDuration + 0.1 + + private static let exitDuration: Double = 0.2 +} + @MainActor class ToastWindowManager: ObservableObject { static let shared = ToastWindowManager() @@ -53,7 +64,7 @@ class ToastWindowManager: ObservableObject { window.hasToast = true // Show the toast with animation - withAnimation(.easeInOut(duration: 0.4)) { + withAnimation(ToastMotion.entrance) { currentToast = toast } @@ -66,12 +77,12 @@ class ToastWindowManager: ObservableObject { func hideToast() { cancelAutoHide() toastWindow?.hasToast = false - withAnimation(.easeInOut(duration: 0.4)) { + withAnimation(ToastMotion.exit) { currentToast = nil } // Clear frame after animation completes to avoid race conditions during animation Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: UInt64(0.4 * 1_000_000_000)) + try? await Task.sleep(nanoseconds: UInt64(ToastMotion.exitSettleTime * 1_000_000_000)) self?.toastWindow?.toastFrame = .zero } } @@ -112,12 +123,12 @@ class ToastWindowManager: ObservableObject { // Atomically update both hasToast and toastFrame toastWindow?.hasToast = false - withAnimation(.easeInOut(duration: 0.4)) { + withAnimation(ToastMotion.exit) { self.currentToast = nil } // Clear frame after animation completes to avoid race conditions during animation - try? await Task.sleep(nanoseconds: UInt64(0.4 * 1_000_000_000)) + try? await Task.sleep(nanoseconds: UInt64(ToastMotion.exitSettleTime * 1_000_000_000)) guard !Task.isCancelled else { return } toastWindow?.toastFrame = .zero @@ -244,7 +255,16 @@ struct ToastWindowView: View { .allowsHitTesting(false) // Spacer doesn't intercept touches } .id(toast.id) - .transition(.move(edge: .top).combined(with: .opacity)) + // Materialize in place: the toast settles down from 12pt above its resting + // position with a fade and a slight scale-up, reading as "arriving" without + // sweeping the whole banner across the screen. Departure is a plain fade; the + // toast has been read by then, so any exit motion is just noise. + .transition( + .asymmetric( + insertion: .offset(y: -12).combined(with: .opacity).combined(with: .scale(scale: 0.98)), + removal: .opacity + ) + ) } } .onPreferenceChange(ToastFramePreferenceKey.self) { frame in @@ -252,7 +272,9 @@ struct ToastWindowView: View { guard !frame.isEmpty else { return } toastManager.updateToastFrame(globalFrame: frame) } - .animation(.easeInOut(duration: 0.4), value: toastManager.currentToast) + // No .animation(_:value:) here: it would override the withAnimation transactions in + // ToastWindowManager and force both directions onto one curve. Show and hide set their + // own (asymmetric) animations. .preferredColorScheme(.dark) // Force dark color scheme } } diff --git a/changelog.d/next/592.changed.md b/changelog.d/next/592.changed.md new file mode 100644 index 000000000..4e255ea64 --- /dev/null +++ b/changelog.d/next/592.changed.md @@ -0,0 +1 @@ +Toast notifications now arrive with a gentle spring settle, fade out quickly, and render their accent colors true to the design instead of washed out.