@@ -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
67153class 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+ }
0 commit comments