Skip to content

Commit 2af9b41

Browse files
committed
fix(ui): polish toast
1 parent 5437871 commit 2af9b41

File tree

3 files changed

+219
-50
lines changed

3 files changed

+219
-50
lines changed

Bitkit/Components/ToastView.swift

Lines changed: 77 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,88 @@
11
import SwiftUI
2-
import UIKit
32

43
struct ToastView: View {
54
let toast: Toast
65
let onDismiss: () -> Void
6+
let onDragStart: () -> Void
7+
let onDragEnd: () -> Void
8+
9+
@State private var dragOffset: CGFloat = 0
10+
@State private var hasPausedAutoHide = false
11+
private let dismissThreshold: CGFloat = 50
712

813
var body: some View {
9-
VStack(alignment: .leading, spacing: 8) {
10-
HStack {
11-
VStack(alignment: .leading, spacing: 2) {
12-
BodyMSBText(toast.title, textColor: accentColor)
13-
if let description = toast.description {
14-
CaptionText(description, textColor: .textPrimary)
15-
}
16-
}
17-
Spacer()
18-
if !toast.autoHide {
19-
Button(action: onDismiss) {
20-
Image("x-mark")
21-
.foregroundColor(.white.opacity(0.6))
22-
}
23-
}
14+
VStack(alignment: .leading, spacing: 2) {
15+
BodyMSBText(toast.title, textColor: accentColor)
16+
17+
if let description = toast.description {
18+
CaptionText(description, textColor: .textPrimary)
2419
}
2520
}
26-
.padding(16)
2721
.frame(maxWidth: .infinity, alignment: .leading)
28-
.background(
29-
ZStack {
30-
// Colored background
31-
accentColor.opacity(0.7)
32-
33-
// Black gradient overlay
34-
LinearGradient(
35-
gradient: Gradient(colors: [
36-
Color.black.opacity(0.6),
37-
Color.black,
38-
]),
39-
startPoint: .top,
40-
endPoint: .bottom
41-
)
42-
}
43-
)
22+
.padding(16)
23+
.background(accentColor.opacity(0.32))
4424
.background(.ultraThinMaterial)
45-
.overlay(
46-
RoundedRectangle(cornerRadius: 8)
47-
.stroke(accentColor, lineWidth: 2)
25+
.cornerRadius(16)
26+
.shadow(color: .black.opacity(0.4), radius: 10, x: 0, y: 25)
27+
.overlay(alignment: .topTrailing) {
28+
if !toast.autoHide {
29+
Button(action: onDismiss) {
30+
Image("x-mark")
31+
.resizable()
32+
.frame(width: 16, height: 16)
33+
.foregroundColor(.textSecondary)
34+
}
35+
.accessibilityLabel("Dismiss toast")
36+
.padding(16)
37+
.contentShape(Rectangle())
38+
}
39+
}
40+
.offset(y: dragOffset)
41+
.gesture(
42+
DragGesture(minimumDistance: 0)
43+
.onChanged { value in
44+
// Allow both upward and downward drag, but limit downward drag
45+
let translation = value.translation.height
46+
if translation < 0 {
47+
// Upward drag - allow freely
48+
dragOffset = translation
49+
} else {
50+
// Downward drag - apply resistance
51+
dragOffset = translation * 0.08
52+
}
53+
54+
// Pause auto-hide when drag starts (only once)
55+
if abs(translation) > 5 && !hasPausedAutoHide {
56+
hasPausedAutoHide = true
57+
onDragStart()
58+
}
59+
}
60+
.onEnded { value in
61+
// Resume auto-hide when drag ends (if we paused it)
62+
if hasPausedAutoHide {
63+
hasPausedAutoHide = false
64+
onDragEnd()
65+
}
66+
67+
// Dismiss if swiped up enough, otherwise snap back
68+
if value.translation.height < -dismissThreshold {
69+
withAnimation(.easeOut(duration: 0.3)) {
70+
dragOffset = -200
71+
}
72+
73+
// Dismiss after animation
74+
Task { @MainActor in
75+
try? await Task.sleep(nanoseconds: UInt64(0.3 * 1_000_000_000))
76+
onDismiss()
77+
}
78+
} else {
79+
// Snap back to original position
80+
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
81+
dragOffset = 0
82+
}
83+
}
84+
}
4885
)
49-
.cornerRadius(8)
50-
.shadow(color: .black.opacity(0.2), radius: 8, x: 0, y: 4)
5186
}
5287

5388
private var accentColor: Color {
@@ -69,7 +104,10 @@ struct ToastView: View {
69104
description: "This is a toast message",
70105
autoHide: true,
71106
visibilityTime: 4.0
72-
), onDismiss: {}
107+
),
108+
onDismiss: {},
109+
onDragStart: {},
110+
onDragEnd: {}
73111
)
74112
.preferredColorScheme(.dark)
75113
}

Bitkit/Managers/ToastWindowManager.swift

Lines changed: 132 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,28 @@ class ToastWindowManager: ObservableObject {
88
private var toastWindow: PassThroughWindow?
99
private var toastHostingController: UIHostingController<ToastWindowView>?
1010

11+
func updateToastFrame(globalFrame: CGRect) {
12+
// Wrap in async to ensure thread safety when called from onPreferenceChange
13+
// which may fire during animations while hitTest runs on another thread
14+
DispatchQueue.main.async { [weak self] in
15+
guard let self, let window = toastWindow else { return }
16+
// Convert from global (screen) coordinates to window coordinates
17+
let windowOrigin = window.convert(CGPoint.zero, to: nil)
18+
let convertedFrame = CGRect(
19+
origin: CGPoint(
20+
x: globalFrame.origin.x - windowOrigin.x,
21+
y: globalFrame.origin.y - windowOrigin.y
22+
),
23+
size: globalFrame.size
24+
)
25+
window.toastFrame = convertedFrame
26+
}
27+
}
28+
1129
@Published var currentToast: Toast?
30+
private var autoHideTask: Task<Void, Never>?
31+
private var autoHideStartTime: Date?
32+
private var autoHideDuration: Double = 0
1233

1334
private init() {
1435
// Set up the window when the app starts
@@ -19,7 +40,12 @@ class ToastWindowManager: ObservableObject {
1940

2041
func showToast(_ toast: Toast) {
2142
// Dismiss any existing toast first
22-
hideToast()
43+
cancelAutoHide()
44+
toastWindow?.hasToast = false
45+
toastWindow?.toastFrame = .zero
46+
47+
// Update window's toast state for hit testing
48+
toastWindow?.hasToast = true
2349

2450
// Show the toast with animation
2551
withAnimation(.easeInOut(duration: 0.4)) {
@@ -28,18 +54,78 @@ class ToastWindowManager: ObservableObject {
2854

2955
// Auto-hide if needed
3056
if toast.autoHide {
31-
DispatchQueue.main.asyncAfter(deadline: .now() + toast.visibilityTime) {
32-
withAnimation(.easeInOut(duration: 0.4)) {
33-
self.currentToast = nil
34-
}
35-
}
57+
scheduleAutoHide(after: toast.visibilityTime)
3658
}
3759
}
3860

3961
func hideToast() {
62+
cancelAutoHide()
63+
toastWindow?.hasToast = false
4064
withAnimation(.easeInOut(duration: 0.4)) {
4165
currentToast = nil
4266
}
67+
// Clear frame after animation completes to avoid race conditions during animation
68+
Task { @MainActor [weak self] in
69+
try? await Task.sleep(nanoseconds: UInt64(0.4 * 1_000_000_000))
70+
self?.toastWindow?.toastFrame = .zero
71+
}
72+
}
73+
74+
func pauseAutoHide() {
75+
guard autoHideStartTime != nil else { return } // Already paused or no auto-hide
76+
cancelAutoHide()
77+
78+
// Calculate remaining time
79+
if let startTime = autoHideStartTime {
80+
let elapsed = Date().timeIntervalSince(startTime)
81+
let remaining = max(0, autoHideDuration - elapsed)
82+
autoHideDuration = remaining
83+
autoHideStartTime = nil
84+
}
85+
}
86+
87+
func resumeAutoHide() {
88+
guard let toast = currentToast, toast.autoHide, autoHideStartTime == nil else { return }
89+
// Use remaining time if available, otherwise use full duration
90+
let delay = autoHideDuration > 0 ? autoHideDuration : toast.visibilityTime
91+
scheduleAutoHide(after: delay)
92+
}
93+
94+
private func scheduleAutoHide(after delay: Double) {
95+
cancelAutoHide()
96+
autoHideStartTime = Date()
97+
autoHideDuration = delay
98+
99+
// Use Task instead of DispatchWorkItem for better SwiftUI integration
100+
autoHideTask = Task { @MainActor [weak self] in
101+
// Sleep for the delay duration
102+
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
103+
104+
// Check if task was cancelled or toast no longer exists
105+
guard let self, !Task.isCancelled, currentToast != nil else { return }
106+
107+
// Atomically update both hasToast and toastFrame
108+
toastWindow?.hasToast = false
109+
110+
withAnimation(.easeInOut(duration: 0.4)) {
111+
self.currentToast = nil
112+
}
113+
114+
// Clear frame after animation completes to avoid race conditions during animation
115+
try? await Task.sleep(nanoseconds: UInt64(0.4 * 1_000_000_000))
116+
guard !Task.isCancelled else { return }
117+
toastWindow?.toastFrame = .zero
118+
119+
autoHideStartTime = nil
120+
autoHideDuration = 0
121+
}
122+
}
123+
124+
private func cancelAutoHide() {
125+
autoHideTask?.cancel()
126+
autoHideTask = nil
127+
autoHideStartTime = nil
128+
autoHideDuration = 0
43129
}
44130

45131
private func setupToastWindow() {
@@ -65,12 +151,19 @@ class ToastWindowManager: ObservableObject {
65151

66152
// Custom window that only intercepts touches on interactive elements
67153
class PassThroughWindow: UIWindow {
154+
var hasToast: Bool = false
155+
var toastFrame: CGRect = .zero
156+
68157
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
69158
let hitView = super.hitTest(point, with: event)
70159

71-
// If the hit view is the root view controller's view (the background),
72-
// return nil to pass the touch through to the underlying window
160+
// If the hit view is the root view controller's view (the background)
73161
if hitView == rootViewController?.view {
162+
// If a toast is showing, check if touch is within the toast's frame
163+
if hasToast && !toastFrame.isEmpty && toastFrame.contains(point) {
164+
return rootViewController?.view
165+
}
166+
74167
return nil
75168
}
76169

@@ -89,16 +182,44 @@ struct ToastWindowView: View {
89182

90183
if let toast = toastManager.currentToast {
91184
VStack {
92-
ToastView(toast: toast, onDismiss: toastManager.hideToast)
93-
.padding(.horizontal)
94-
.allowsHitTesting(true) // Only the toast itself can be tapped
185+
ToastView(
186+
toast: toast,
187+
onDismiss: toastManager.hideToast,
188+
onDragStart: toastManager.pauseAutoHide,
189+
onDragEnd: toastManager.resumeAutoHide
190+
)
191+
.padding(.horizontal)
192+
.allowsHitTesting(true) // Only the toast itself can be tapped
193+
.overlay(
194+
GeometryReader { toastGeometry in
195+
Color.clear
196+
.preference(
197+
key: ToastFramePreferenceKey.self,
198+
value: toastGeometry.frame(in: .global)
199+
)
200+
}
201+
)
202+
95203
Spacer()
96204
.allowsHitTesting(false) // Spacer doesn't intercept touches
97205
}
206+
.id(toast.id)
98207
.transition(.move(edge: .top).combined(with: .opacity))
99208
}
100209
}
210+
.onPreferenceChange(ToastFramePreferenceKey.self) { frame in
211+
// Only update if frame is not empty (valid frame from GeometryReader)
212+
guard !frame.isEmpty else { return }
213+
toastManager.updateToastFrame(globalFrame: frame)
214+
}
101215
.animation(.easeInOut(duration: 0.4), value: toastManager.currentToast)
102216
.preferredColorScheme(.dark) // Force dark color scheme
103217
}
104218
}
219+
220+
private struct ToastFramePreferenceKey: PreferenceKey {
221+
static var defaultValue: CGRect = .zero
222+
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
223+
value = nextValue()
224+
}
225+
}

Bitkit/Models/Toast.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,19 @@ struct Toast: Equatable {
55
case success, info, lightning, warning, error
66
}
77

8+
let id: UUID
89
let type: ToastType
910
let title: String
1011
let description: String?
1112
let autoHide: Bool
1213
let visibilityTime: Double
14+
15+
init(id: UUID = UUID(), type: ToastType, title: String, description: String? = nil, autoHide: Bool = true, visibilityTime: Double = 4.0) {
16+
self.id = id
17+
self.type = type
18+
self.title = title
19+
self.description = description
20+
self.autoHide = autoHide
21+
self.visibilityTime = visibilityTime
22+
}
1323
}

0 commit comments

Comments
 (0)