-
Notifications
You must be signed in to change notification settings - Fork 310
[Outdated]: 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() | ||
| } | ||
| .allowsHitTesting(!allowTapThroughBG) | ||
| .opacity(animatableOpacity) | ||
| .applyIf(closeOnTapOutside) { view in | ||
| view.contentShape(Rectangle()) | ||
| } | ||
| .addTapIfNotTV(if: closeOnTapOutside) { | ||
| if dismissEnabled.wrappedValue { | ||
| dismissSource = .tapOutside | ||
| isPresented = false | ||
| item = nil | ||
| } | ||
| } | ||
| .edgesIgnoringSafeArea(.all) | ||
| .animation(.linear(duration: 0.2), value: animatableOpacity) | ||
| } | ||
| } | ||
|
|
||
| /// A special view to handle hit-testing on background parts of popup content | ||
| struct PopupHitTestingBackground: UIViewRepresentable { | ||
| func makeUIView(context: Context) -> UIView { | ||
| let view = UIView() | ||
| view.backgroundColor = .clear | ||
|
Comment on lines
+47
to
+51
|
||
| view.isUserInteractionEnabled = false | ||
| return view | ||
| } | ||
|
|
||
| func updateUIView(_ uiView: UIView, context: Context) {} | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -12,79 +12,141 @@ import SwiftUI | |||||||||||
| @MainActor | ||||||||||||
| public final class WindowManager { | ||||||||||||
| static let shared = WindowManager() | ||||||||||||
| private var windows: [UUID: UIWindow] = [:] | ||||||||||||
| private var entries: [UUID: Entry] = [:] | ||||||||||||
|
|
||||||||||||
| private struct Entry { | ||||||||||||
| let window: UIWindow | ||||||||||||
| let controller: UIViewController | ||||||||||||
| private let rootViewUpdater: @MainActor (Any) -> Void | ||||||||||||
|
|
||||||||||||
| init<Content: View>(window: UIWindow, controller: UIHostingController<Content>) { | ||||||||||||
| self.window = window | ||||||||||||
| self.controller = controller | ||||||||||||
| self.rootViewUpdater = { @MainActor newContent in | ||||||||||||
| guard let content = newContent as? Content else { | ||||||||||||
| assertionFailure("Content type mismatch") | ||||||||||||
| return | ||||||||||||
| } | ||||||||||||
| controller.rootView = content | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| // Show a new window with hosted SwiftUI content | ||||||||||||
| public static func showInNewWindow<Content: View>(id: UUID, allowTapThroughBG: Bool, dismissClosure: @escaping ()->(), content: @escaping () -> Content) { | ||||||||||||
| @MainActor func updateRootView<Content: View>(_ content: Content) { | ||||||||||||
| rootViewUpdater(content) | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| public static func showInNewWindow<Content: View>( | ||||||||||||
| id: UUID, | ||||||||||||
| closeOnTapOutside: Bool, | ||||||||||||
| allowTapThroughBG: Bool, | ||||||||||||
| dismissClosure: @escaping SendableClosure, | ||||||||||||
| content: @escaping () -> Content | ||||||||||||
| ) { | ||||||||||||
| 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() | ||||||||||||
| let rootView = content() | ||||||||||||
| .environment(\.popupDismiss) { | ||||||||||||
| dismissClosure() | ||||||||||||
| } | ||||||||||||
| let controller: UIViewController | ||||||||||||
| if #available(iOS 18, *) { | ||||||||||||
| controller = UIHostingController(rootView: root) | ||||||||||||
|
|
||||||||||||
| let controller = if #available(iOS 18, *) { | ||||||||||||
| UIHostingController(rootView: rootView) | ||||||||||||
| } else { | ||||||||||||
| controller = UITextFieldCheckingVC(rootView: root) | ||||||||||||
| UITextFieldCheckingVC(rootView: rootView) | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| controller.view.backgroundColor = .clear | ||||||||||||
| window.rootViewController = controller | ||||||||||||
| window.windowLevel = .alert + 1 | ||||||||||||
| window.makeKeyAndVisible() | ||||||||||||
|
|
||||||||||||
| // Store window reference | ||||||||||||
| shared.windows[id] = window | ||||||||||||
| shared.entries[id] = Entry(window: window, controller: controller) | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| public static func updateRootView<Content: View>( | ||||||||||||
| id: UUID, | ||||||||||||
| dismissClosure: @escaping () -> (), | ||||||||||||
| content: @escaping () -> Content | ||||||||||||
| ) { | ||||||||||||
| guard let entry = shared.entries[id] else { return } | ||||||||||||
|
|
||||||||||||
| let rootView = content() | ||||||||||||
| .environment(\.popupDismiss) { | ||||||||||||
| dismissClosure() | ||||||||||||
| } | ||||||||||||
| entry.updateRootView(rootView) | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| 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) | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| 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
+104
to
+108
|
||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| 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.
The previous tap-outside dismissal logic (using
closeOnTapOutside+dismissEnabledwith a tap gesture/content shape) was removed fromPopupBackgroundView. Because this view is also used in.overlayand.sheetmodes, those modes will no longer dismiss on outside taps. Please restore the dismissal gesture for non-window presentations (and keep the hit-test marker approach scoped to the.window/UIWindow path).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