Skip to content

Commit 0d02dfd

Browse files
committed
fix(ui): polish toast
1 parent 5437871 commit 0d02dfd

File tree

3 files changed

+215
-50
lines changed

3 files changed

+215
-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: 128 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,24 @@ class ToastWindowManager: ObservableObject {
88
private var toastWindow: PassThroughWindow?
99
private var toastHostingController: UIHostingController<ToastWindowView>?
1010

11+
func updateToastFrame(globalFrame: CGRect) {
12+
guard let window = toastWindow else { return }
13+
// Convert from global (screen) coordinates to window coordinates
14+
let windowOrigin = window.convert(CGPoint.zero, to: nil)
15+
let convertedFrame = CGRect(
16+
origin: CGPoint(
17+
x: globalFrame.origin.x - windowOrigin.x,
18+
y: globalFrame.origin.y - windowOrigin.y
19+
),
20+
size: globalFrame.size
21+
)
22+
window.toastFrame = convertedFrame
23+
}
24+
1125
@Published var currentToast: Toast?
26+
private var autoHideTask: Task<Void, Never>?
27+
private var autoHideStartTime: Date?
28+
private var autoHideDuration: Double = 0
1229

1330
private init() {
1431
// Set up the window when the app starts
@@ -19,7 +36,12 @@ class ToastWindowManager: ObservableObject {
1936

2037
func showToast(_ toast: Toast) {
2138
// Dismiss any existing toast first
22-
hideToast()
39+
cancelAutoHide()
40+
toastWindow?.hasToast = false
41+
toastWindow?.toastFrame = .zero
42+
43+
// Update window's toast state for hit testing
44+
toastWindow?.hasToast = true
2345

2446
// Show the toast with animation
2547
withAnimation(.easeInOut(duration: 0.4)) {
@@ -28,18 +50,78 @@ class ToastWindowManager: ObservableObject {
2850

2951
// Auto-hide if needed
3052
if toast.autoHide {
31-
DispatchQueue.main.asyncAfter(deadline: .now() + toast.visibilityTime) {
32-
withAnimation(.easeInOut(duration: 0.4)) {
33-
self.currentToast = nil
34-
}
35-
}
53+
scheduleAutoHide(after: toast.visibilityTime)
3654
}
3755
}
3856

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

45127
private func setupToastWindow() {
@@ -65,12 +147,19 @@ class ToastWindowManager: ObservableObject {
65147

66148
// Custom window that only intercepts touches on interactive elements
67149
class PassThroughWindow: UIWindow {
150+
var hasToast: Bool = false
151+
var toastFrame: CGRect = .zero
152+
68153
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
69154
let hitView = super.hitTest(point, with: event)
70155

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
156+
// If the hit view is the root view controller's view (the background)
73157
if hitView == rootViewController?.view {
158+
// If a toast is showing, check if touch is within the toast's frame
159+
if hasToast && !toastFrame.isEmpty && toastFrame.contains(point) {
160+
return rootViewController?.view
161+
}
162+
74163
return nil
75164
}
76165

@@ -89,16 +178,44 @@ struct ToastWindowView: View {
89178

90179
if let toast = toastManager.currentToast {
91180
VStack {
92-
ToastView(toast: toast, onDismiss: toastManager.hideToast)
93-
.padding(.horizontal)
94-
.allowsHitTesting(true) // Only the toast itself can be tapped
181+
ToastView(
182+
toast: toast,
183+
onDismiss: toastManager.hideToast,
184+
onDragStart: toastManager.pauseAutoHide,
185+
onDragEnd: toastManager.resumeAutoHide
186+
)
187+
.padding(.horizontal)
188+
.allowsHitTesting(true) // Only the toast itself can be tapped
189+
.overlay(
190+
GeometryReader { toastGeometry in
191+
Color.clear
192+
.preference(
193+
key: ToastFramePreferenceKey.self,
194+
value: toastGeometry.frame(in: .global)
195+
)
196+
}
197+
)
198+
95199
Spacer()
96200
.allowsHitTesting(false) // Spacer doesn't intercept touches
97201
}
202+
.id(toast.id)
98203
.transition(.move(edge: .top).combined(with: .opacity))
99204
}
100205
}
206+
.onPreferenceChange(ToastFramePreferenceKey.self) { frame in
207+
// Only update if frame is not empty (valid frame from GeometryReader)
208+
guard !frame.isEmpty else { return }
209+
toastManager.updateToastFrame(globalFrame: frame)
210+
}
101211
.animation(.easeInOut(duration: 0.4), value: toastManager.currentToast)
102212
.preferredColorScheme(.dark) // Force dark color scheme
103213
}
104214
}
215+
216+
private struct ToastFramePreferenceKey: PreferenceKey {
217+
static var defaultValue: CGRect = .zero
218+
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
219+
value = nextValue()
220+
}
221+
}

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)