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