Skip to content

Commit 1471783

Browse files
pwltrpiotr-iohk
andauthored
fix(ui): polish toast (#254)
* fix(ui): polish toast * RGS and Electrum toast ids --------- Co-authored-by: Piotr Stachyra <[email protected]>
1 parent 35b854f commit 1471783

File tree

6 files changed

+237
-57
lines changed

6 files changed

+237
-57
lines changed

Bitkit/Components/ToastView.swift

Lines changed: 78 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,89 @@
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)
48-
)
49-
.cornerRadius(8)
50-
.shadow(color: .black.opacity(0.2), radius: 8, x: 0, y: 4)
25+
.cornerRadius(16)
26+
.shadow(color: .black.opacity(0.4), radius: 10, x: 0, y: 25)
5127
.accessibilityIdentifierIfPresent(toast.accessibilityIdentifier)
28+
.overlay(alignment: .topTrailing) {
29+
if !toast.autoHide {
30+
Button(action: onDismiss) {
31+
Image("x-mark")
32+
.resizable()
33+
.frame(width: 16, height: 16)
34+
.foregroundColor(.textSecondary)
35+
}
36+
.accessibilityLabel("Dismiss toast")
37+
.padding(16)
38+
.contentShape(Rectangle())
39+
}
40+
}
41+
.offset(y: dragOffset)
42+
.gesture(
43+
DragGesture(minimumDistance: 0)
44+
.onChanged { value in
45+
// Allow both upward and downward drag, but limit downward drag
46+
let translation = value.translation.height
47+
if translation < 0 {
48+
// Upward drag - allow freely
49+
dragOffset = translation
50+
} else {
51+
// Downward drag - apply resistance
52+
dragOffset = translation * 0.08
53+
}
54+
55+
// Pause auto-hide when drag starts (only once)
56+
if abs(translation) > 5 && !hasPausedAutoHide {
57+
hasPausedAutoHide = true
58+
onDragStart()
59+
}
60+
}
61+
.onEnded { value in
62+
// Resume auto-hide when drag ends (if we paused it)
63+
if hasPausedAutoHide {
64+
hasPausedAutoHide = false
65+
onDragEnd()
66+
}
67+
68+
// Dismiss if swiped up enough, otherwise snap back
69+
if value.translation.height < -dismissThreshold {
70+
withAnimation(.easeOut(duration: 0.3)) {
71+
dragOffset = -200
72+
}
73+
74+
// Dismiss after animation
75+
Task { @MainActor in
76+
try? await Task.sleep(nanoseconds: UInt64(0.3 * 1_000_000_000))
77+
onDismiss()
78+
}
79+
} else {
80+
// Snap back to original position
81+
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
82+
dragOffset = 0
83+
}
84+
}
85+
}
86+
)
5287
}
5388

5489
private var accentColor: Color {
@@ -71,7 +106,10 @@ struct ToastView: View {
71106
autoHide: true,
72107
visibilityTime: 4.0,
73108
accessibilityIdentifier: nil
74-
), onDismiss: {}
109+
),
110+
onDismiss: {},
111+
onDragStart: {},
112+
onDragEnd: {}
75113
)
76114
.preferredColorScheme(.dark)
77115
}

Bitkit/Managers/ScannerManager.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,13 +104,15 @@ class ScannerManager: ObservableObject {
104104
app?.toast(
105105
type: .success,
106106
title: t("settings__es__server_updated_title"),
107-
description: t("settings__es__server_updated_message", variables: ["host": result.host, "port": result.port])
107+
description: t("settings__es__server_updated_message", variables: ["host": result.host, "port": result.port]),
108+
accessibilityIdentifier: "ElectrumUpdatedToast"
108109
)
109110
} else {
110111
app?.toast(
111112
type: .warning,
112113
title: t("settings__es__error_peer"),
113-
description: result.errorMessage ?? t("settings__es__server_error_description")
114+
description: result.errorMessage ?? t("settings__es__server_error_description"),
115+
accessibilityIdentifier: "ElectrumErrorToast"
114116
)
115117
}
116118
} else {

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: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,29 @@ 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
1314
let accessibilityIdentifier: String?
15+
16+
init(
17+
id: UUID = UUID(),
18+
type: ToastType,
19+
title: String,
20+
description: String? = nil,
21+
autoHide: Bool = true,
22+
visibilityTime: Double = 4.0,
23+
accessibilityIdentifier: String? = nil
24+
) {
25+
self.id = id
26+
self.type = type
27+
self.title = title
28+
self.description = description
29+
self.autoHide = autoHide
30+
self.visibilityTime = visibilityTime
31+
self.accessibilityIdentifier = accessibilityIdentifier
32+
}
1433
}

Bitkit/Views/Settings/Advanced/ElectrumSettingsScreen.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,13 +133,15 @@ struct ElectrumSettingsScreen: View {
133133
app.toast(
134134
type: .success,
135135
title: t("settings__es__server_updated_title"),
136-
description: t("settings__es__server_updated_message", variables: ["host": host, "port": port])
136+
description: t("settings__es__server_updated_message", variables: ["host": host, "port": port]),
137+
accessibilityIdentifier: "ElectrumUpdatedToast"
137138
)
138139
} else {
139140
app.toast(
140141
type: .warning,
141142
title: t("settings__es__error_peer"),
142-
description: errorMessage ?? t("settings__es__server_error_description")
143+
description: errorMessage ?? t("settings__es__server_error_description"),
144+
accessibilityIdentifier: "ElectrumErrorToast"
143145
)
144146
}
145147
}

0 commit comments

Comments
 (0)