-
Notifications
You must be signed in to change notification settings - Fork 315
Improve UIPassthroughWindow hitTest #280
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -25,26 +25,33 @@ struct PopupBackgroundView<Item: Equatable>: View { | |||||||||||||||||||||||||||||||
| var dismissEnabled: Binding<Bool> | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| var body: some View { | ||||||||||||||||||||||||||||||||
| Group { | ||||||||||||||||||||||||||||||||
| if let backgroundView = backgroundView { | ||||||||||||||||||||||||||||||||
| backgroundView | ||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||
| backgroundColor | ||||||||||||||||||||||||||||||||
| ZStack { | ||||||||||||||||||||||||||||||||
| Group { | ||||||||||||||||||||||||||||||||
| if let backgroundView = backgroundView { | ||||||||||||||||||||||||||||||||
| backgroundView | ||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||
| backgroundColor | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| .allowsHitTesting(!allowTapThroughBG) | ||||||||||||||||||||||||||||||||
| .opacity(animatableOpacity) | ||||||||||||||||||||||||||||||||
| .edgesIgnoringSafeArea(.all) | ||||||||||||||||||||||||||||||||
| .animation(.linear(duration: 0.2), value: animatableOpacity) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| PopupHitTestingBackground() // Hit testing workaround | ||||||||||||||||||||||||||||||||
| .ignoresSafeArea() | ||||||||||||||||||||||||||||||||
|
Comment on lines
+40
to
+42
|
||||||||||||||||||||||||||||||||
| PopupHitTestingBackground() // Hit testing workaround | |
| .ignoresSafeArea() | |
| .contentShape(Rectangle()) | |
| .onTapGesture { | |
| guard closeOnTapOutside, dismissEnabled.wrappedValue else { return } | |
| dismissSource = .tapOutside | |
| isPresented = false | |
| item = nil | |
| } | |
| if allowTapThroughBG { | |
| PopupHitTestingBackground() // Hit testing workaround for .window presentations | |
| .ignoresSafeArea() | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point
Copilot
AI
Feb 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PopupHitTestingBackground is declared as a UIViewRepresentable without #if os(iOS) || os(tvOS) guarding. Since this package supports macOS/watchOS too, this will fail to compile on non-UIKit platforms. Wrap this type (and its usage) in appropriate platform conditionals and/or provide an NSViewRepresentable implementation for macOS.
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -12,23 +12,44 @@ import SwiftUI | |||||||||||
| @MainActor | ||||||||||||
| public final class WindowManager { | ||||||||||||
| static let shared = WindowManager() | ||||||||||||
| private var windows: [UUID: UIWindow] = [:] | ||||||||||||
|
|
||||||||||||
| private struct Entry { | ||||||||||||
| let window: UIWindow | ||||||||||||
| let controller: (UIViewController & AnyViewHostingController) | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| private var entries: [UUID: Entry] = [:] | ||||||||||||
|
|
||||||||||||
| // Show a new window with hosted SwiftUI content | ||||||||||||
| public static func showInNewWindow<Content: View>(id: UUID, allowTapThroughBG: Bool, dismissClosure: @escaping ()->(), content: @escaping () -> Content) { | ||||||||||||
| public static func showInNewWindow( | ||||||||||||
| id: UUID, | ||||||||||||
| closeOnTapOutside: Bool, | ||||||||||||
| allowTapThroughBG: Bool, | ||||||||||||
| dismissClosure: @escaping ()->(), | ||||||||||||
| content: @escaping () -> AnyView | ||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. also here - could you please use generics trick instead of eplicit casting to AnyView? |
||||||||||||
| ) { | ||||||||||||
| guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { | ||||||||||||
| print("No valid scene available") | ||||||||||||
| return | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| let window = allowTapThroughBG ? UIPassthroughWindow(windowScene: scene) : UIWindow(windowScene: scene) | ||||||||||||
| let window = UIPassthroughWindow( | ||||||||||||
| windowScene: scene, | ||||||||||||
| closeOnTapOutside: closeOnTapOutside, | ||||||||||||
| isPassthrough: allowTapThroughBG, | ||||||||||||
| dismissClosure: dismissClosure | ||||||||||||
| ) | ||||||||||||
|
|
||||||||||||
| window.backgroundColor = .clear | ||||||||||||
|
|
||||||||||||
| let root = content() | ||||||||||||
| .environment(\.popupDismiss) { | ||||||||||||
| dismissClosure() | ||||||||||||
| } | ||||||||||||
| let controller: UIViewController | ||||||||||||
| let root = AnyView( | ||||||||||||
| content() | ||||||||||||
| .environment(\.popupDismiss) { | ||||||||||||
| dismissClosure() | ||||||||||||
| } | ||||||||||||
| ) | ||||||||||||
|
|
||||||||||||
| let controller: (UIViewController & AnyViewHostingController) | ||||||||||||
| if #available(iOS 18, *) { | ||||||||||||
| controller = UIHostingController(rootView: root) | ||||||||||||
| } else { | ||||||||||||
|
|
@@ -40,51 +61,83 @@ public final class WindowManager { | |||||||||||
| window.makeKeyAndVisible() | ||||||||||||
|
|
||||||||||||
| // Store window reference | ||||||||||||
| shared.windows[id] = window | ||||||||||||
| shared.entries[id] = Entry(window: window, controller: controller) | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| public static func updateRootView(id: UUID, dismissClosure: @escaping () -> (), content: @escaping () -> AnyView) { | ||||||||||||
| guard let entry = shared.entries[id] else { return } | ||||||||||||
|
|
||||||||||||
| entry.controller.rootView = AnyView( | ||||||||||||
| content() | ||||||||||||
| .environment(\.popupDismiss) { | ||||||||||||
| dismissClosure() | ||||||||||||
| } | ||||||||||||
| ) | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| static func closeWindow(id: UUID) { | ||||||||||||
| shared.windows[id]?.isHidden = true | ||||||||||||
| shared.windows.removeValue(forKey: id) | ||||||||||||
| shared.entries[id]?.window.isHidden = true | ||||||||||||
| shared.entries.removeValue(forKey: id) | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| protocol AnyViewHostingController: AnyObject { | ||||||||||||
| var rootView: AnyView { get set } | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| extension UIHostingController: AnyViewHostingController where Content == AnyView {} | ||||||||||||
|
|
||||||||||||
| class UIPassthroughWindow: UIWindow { | ||||||||||||
| var closeOnTapOutside: Bool | ||||||||||||
| var isPassthrough: Bool | ||||||||||||
| var dismissClosure: SendableClosure? | ||||||||||||
|
|
||||||||||||
| init(windowScene: UIWindowScene, closeOnTapOutside: Bool, isPassthrough: Bool, dismissClosure: SendableClosure?) { | ||||||||||||
| self.closeOnTapOutside = closeOnTapOutside | ||||||||||||
| self.isPassthrough = isPassthrough | ||||||||||||
| self.dismissClosure = dismissClosure | ||||||||||||
| super.init(windowScene: windowScene) | ||||||||||||
|
Comment on lines
+95
to
+99
|
||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| required init?(coder: NSCoder) { | ||||||||||||
| fatalError("init(coder:) has not been implemented") | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { | ||||||||||||
| if let vc = self.rootViewController { | ||||||||||||
| vc.view.layoutSubviews() // otherwise the frame is as if the popup is still outside the screen | ||||||||||||
|
|
||||||||||||
| let pointInRoot = vc.view.convert(point, from: self) | ||||||||||||
|
|
||||||||||||
| // iOS26 Passthrough Find Issue | ||||||||||||
| if #available(iOS 26, *), vc.view.point(inside: pointInRoot, with: event) { | ||||||||||||
| return isTouchInsideSubviewForiOS26(point: pointInRoot, view: vc.view) | ||||||||||||
| guard let vc = self.rootViewController else { | ||||||||||||
| return nil // pass to next window | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| vc.view.layoutIfNeeded() // otherwise the frame is as if the popup is still outside the screen | ||||||||||||
|
|
||||||||||||
| let layerHitTestResult = vc.view.layer.hitTest(vc.view.convert(point, from: self)) | ||||||||||||
| let superlayerDelegateName = layerHitTestResult?.superlayer?.delegate.map { String(describing: type(of: $0)) } | ||||||||||||
| let didTapBackground = superlayerDelegateName?.contains(String(describing: PopupHitTestingBackground.self)) ?? false | ||||||||||||
|
|
||||||||||||
| if didTapBackground { | ||||||||||||
| if closeOnTapOutside { | ||||||||||||
| dismissClosure?() | ||||||||||||
|
||||||||||||
| dismissClosure?() | |
| if let dismiss = dismissClosure { | |
| dismissClosure = nil | |
| dismiss() | |
| } |
Copilot
AI
Feb 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
isTouchInsideSubview(point:vc:) is no longer referenced anywhere in UIPassthroughWindow after the new layer-based hit-testing logic. Please remove this dead code (or reintroduce a call if it’s still needed) to keep the hit-testing implementation focused.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is it at all possible to avoid AnyView here?