refactor: spring-in, fade-out toast motion#592
Conversation
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.
Greptile SummaryThis PR refactors the toast show/hide animation from a symmetric
Confidence Score: 4/5Safe to merge — the changes are scoped entirely to animation parameters with no logic, data, or networking paths affected. The refactor is clean and well-reasoned. The two concerns are both minor: The
|
| Filename | Overview |
|---|---|
| Bitkit/Managers/ToastWindowManager.swift | Adds the ToastMotion namespace and replaces all animation literals with its constants; removes the view-level .animation(_:value:) modifier in favour of explicit withAnimation transactions; introduces an asymmetric .transition (spring-offset+scale+opacity in, opacity-only out). Two P2 observations: exitSettleTime is decoupled from exit.duration with no enforced ordering, and the offset direction comment is semantically reversed. |
| Bitkit/Components/ToastView.swift | Single-line change: snap-back animation swapped from an inline .spring(response:dampingFraction:) to ToastMotion.entrance, unifying the motion dialect between entrance and drag-release. No logic changes; safe. |
| changelog.d/next/592.changed.md | New changelog entry describing the animation change; no code impact. |
Sequence Diagram
sequenceDiagram
participant Caller
participant ToastWindowManager
participant ToastWindowView
participant PassThroughWindow
Note over Caller,PassThroughWindow: Toast Show
Caller->>ToastWindowManager: showToast(_:)
ToastWindowManager->>PassThroughWindow: "hasToast = true"
ToastWindowManager->>ToastWindowView: "withAnimation(ToastMotion.entrance) currentToast = toast"
ToastWindowView-->>ToastWindowView: .asymmetric insertion (offset -12pt + opacity + scale 0.98→1.0) driven by .snappy(0.4s)
ToastWindowManager->>ToastWindowManager: scheduleAutoHide(after: visibilityTime)
Note over Caller,PassThroughWindow: Auto-hide / Manual hide
ToastWindowManager->>PassThroughWindow: "hasToast = false"
ToastWindowManager->>ToastWindowView: "withAnimation(ToastMotion.exit) currentToast = nil"
ToastWindowView-->>ToastWindowView: .asymmetric removal (opacity only) driven by .easeOut(0.2s)
ToastWindowManager->>ToastWindowManager: "Task.sleep(exitSettleTime = 0.3s)"
ToastWindowManager->>PassThroughWindow: "toastFrame = .zero"
Note over Caller,PassThroughWindow: Drag snap-back
ToastWindowView->>ToastWindowView: "withAnimation(ToastMotion.entrance) dragOffset = 0 (.snappy 0.4s)"
Reviews (1): Last reviewed commit: "refactor: rework toast motion to spring ..." | Re-trigger Greptile
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.
Description
This PR aims to establish a more-polished motion design for the app's toast show/hide motion, which previously animated with a flat ease (
easeInOut, 0.4s) that felt a tad mechanical compared to the spring motion used across the rest of the app (including the toast's own drag-to-dismiss snap-back).I went with a behavior that's asymmetric, mirroring Apple's system banner pattern:
.snappy(duration: 0.4)(a gently damped spring preset). This replaces the full slide-in from offscreen, which swept the whole banner across the top of the screen.easeOut, 0.2s). By dismissal time the toast has been read, so exit motion is kept to a minimum.The motion constants live in a single
ToastMotionnamespace. And I wanted to make them a convenient starting point for any feedback from the design team:.snappy(duration: 0.4).easeOut(duration: 0.2)Note on cross-platform consistency: Android currently slides the full toast in and out from the top edge with a fade, symmetric in both directions (
fadeIn() + slideInVertically/fadeOut() + slideOutVerticallyinToastView.kt), and its drag snap-back uses a 0.7-damping spring, the same entrance/gesture mismatch this PR addresses on iOS. If this PR goes forward, a counterpart Android PR maps 1:1 (fadeIn + slideInVertically(-12dp) + scaleIn(0.98)in,fadeOutout, snap-back damping aligned), and I can take that on as a follow-up.Linked Issues/Tasks
Follow-up to the motion conversation in #585: #585 (comment)
Screenshot / Video
ios-transfer-amount-exceeded-toast-side-by-side.mp4
Feels a bit less like the toast is being rammed into your face, IMO 😂 ... also plays nicely with "standard", informational toast messages:
ios-toast-motion-side-by-side.mp4
QA Notes
Manual Tests
regression:toast visible → tap content beneath the toast: taps pass through, and the area is tappable again after dismiss.regression:drag the toast upward: dismisses.Automated Checks