Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Bitkit/Components/ToastView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
}
Expand Down
36 changes: 29 additions & 7 deletions Bitkit/Managers/ToastWindowManager.swift
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -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
}

Expand All @@ -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
}
}
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -244,15 +255,26 @@ 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)),
Comment thread
CypherPoet marked this conversation as resolved.
removal: .opacity
)
)
}
}
.onPreferenceChange(ToastFramePreferenceKey.self) { frame in
// Only update if frame is not empty (valid frame from GeometryReader)
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
}
}
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/592.changed.md
Original file line number Diff line number Diff line change
@@ -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.
Loading