From de868560c8224a82279d79fc04b507ee5511eb4e Mon Sep 17 00:00:00 2001 From: CypherPoet Date: Wed, 10 Jun 2026 15:33:44 -0500 Subject: [PATCH 1/3] refactor: rework toast motion to spring in and fade out Toast show/hide previously animated with a flat easeInOut(0.4) that read as mechanical next to the spring motion used across the rest of the app, including the toast's own drag-to-dismiss snap-back. Make the motion asymmetric, mirroring Apple's system banner pattern: the toast materializes in place (12pt rise, opacity fade, 0.98 scale) with .snappy(duration: 0.4), and departs with a quick easeOut fade. The drag snap-back joins the same motion family, and the blanket .animation(_:value:) modifier is removed since it would force both directions onto one curve. Constants live in a ToastMotion namespace so the design team has one place to tune. --- Bitkit/Components/ToastView.swift | 2 +- Bitkit/Managers/ToastWindowManager.swift | 33 +++++++++++++++++++----- changelog.d/next/592.changed.md | 1 + 3 files changed, 28 insertions(+), 8 deletions(-) create mode 100644 changelog.d/next/592.changed.md diff --git a/Bitkit/Components/ToastView.swift b/Bitkit/Components/ToastView.swift index bd6254963..6761fe90e 100644 --- a/Bitkit/Components/ToastView.swift +++ b/Bitkit/Components/ToastView.swift @@ -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..ffbe25c6c 100644 --- a/Bitkit/Managers/ToastWindowManager.swift +++ b/Bitkit/Managers/ToastWindowManager.swift @@ -1,6 +1,15 @@ 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: 0.2) + /// Hit-test frame cleanup waits for the exit to finish. + static let exitSettleTime: Double = 0.3 +} + @MainActor class ToastWindowManager: ObservableObject { static let shared = ToastWindowManager() @@ -53,7 +62,7 @@ class ToastWindowManager: ObservableObject { window.hasToast = true // Show the toast with animation - withAnimation(.easeInOut(duration: 0.4)) { + withAnimation(ToastMotion.entrance) { currentToast = toast } @@ -66,12 +75,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 +121,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 +253,15 @@ struct ToastWindowView: View { .allowsHitTesting(false) // Spacer doesn't intercept touches } .id(toast.id) - .transition(.move(edge: .top).combined(with: .opacity)) + // Materialize in place: a short rise with a fade and a slight scale-up reads 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 +269,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..b5957afc6 --- /dev/null +++ b/changelog.d/next/592.changed.md @@ -0,0 +1 @@ +Toast notifications now arrive with a gentle spring settle and fade out quickly, replacing the previous flat slide animation. From f27ebfc4853c1df448d224285b3942791e8cc85d Mon Sep 17 00:00:00 2001 From: CypherPoet Date: Wed, 10 Jun 2026 18:46:28 -0500 Subject: [PATCH 2/3] refactor: derive toast exit settle time from the exit duration Addresses automated review feedback: the settle time used for hit-test frame cleanup is now computed from the exit animation's duration instead of relying on a comment-enforced invariant, and the transition comment now correctly describes the entrance as settling down from above rather than rising. --- Bitkit/Managers/ToastWindowManager.swift | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/Bitkit/Managers/ToastWindowManager.swift b/Bitkit/Managers/ToastWindowManager.swift index ffbe25c6c..ca0542509 100644 --- a/Bitkit/Managers/ToastWindowManager.swift +++ b/Bitkit/Managers/ToastWindowManager.swift @@ -5,9 +5,11 @@ import UIKit /// 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: 0.2) - /// Hit-test frame cleanup waits for the exit to finish. - static let exitSettleTime: Double = 0.3 + 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 @@ -253,9 +255,10 @@ struct ToastWindowView: View { .allowsHitTesting(false) // Spacer doesn't intercept touches } .id(toast.id) - // Materialize in place: a short rise with a fade and a slight scale-up reads 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. + // 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)), From e49634c2d3d18acfa93ff400844cc59e04c4b467 Mon Sep 17 00:00:00 2001 From: CypherPoet Date: Thu, 11 Jun 2026 21:06:23 -0500 Subject: [PATCH 3/3] refactor: use custom BlurView for design-true toast colors --- Bitkit/Components/ToastView.swift | 2 +- changelog.d/next/592.changed.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Bitkit/Components/ToastView.swift b/Bitkit/Components/ToastView.swift index 6761fe90e..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) diff --git a/changelog.d/next/592.changed.md b/changelog.d/next/592.changed.md index b5957afc6..4e255ea64 100644 --- a/changelog.d/next/592.changed.md +++ b/changelog.d/next/592.changed.md @@ -1 +1 @@ -Toast notifications now arrive with a gentle spring settle and fade out quickly, replacing the previous flat slide animation. +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.