Skip to content

Improve UIPassthroughWindow hitTest#280

Open
denis-obukhov wants to merge 2 commits intomasterfrom
exyte/fix/hit-testing
Open

Improve UIPassthroughWindow hitTest#280
denis-obukhov wants to merge 2 commits intomasterfrom
exyte/fix/hit-testing

Conversation

@denis-obukhov
Copy link
Member

Fix missing touch handling in the popups view when using .allowTapThroughBG(true)
Supports iOS 26+ with backward compatibility. Uses a dedicated PopupHitTestingBackground view as a hit-test marker.

Before:

Screen.Recording.2026-02-07.at.12.37.21.mov

After:

Screen.Recording.2026-02-07.at.12.55.38.mov

It also enables this combination, which now passes the touch through while still dismissing the popup. Previously, the popup did not dismiss:

.closeOnTapOutside(true)
.allowTapThroughBG(true)

Also, for this combination, dismissal no longer requires a strict tap gesture (press and release). It now works for swipes, pinch, pan, two-finger gestures, etc:

.closeOnTapOutside(true)
.allowTapThroughBG(false)

Fix missing touches on the popup view
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the window-based popup presentation to improve background hit-testing so that .allowTapThroughBG(true) can still support outside-tap dismissal (and broader gesture dismissal behavior), including iOS 26+ behavior changes, by introducing a dedicated hit-test marker view.

Changes:

  • Refactors WindowManager.showInNewWindow to always use a custom UIPassthroughWindow with configurable pass-through + outside-tap dismissal behavior.
  • Reworks UIPassthroughWindow.hitTest to detect “background hits” using a dedicated PopupHitTestingBackground marker.
  • Updates popup background rendering to include the new hit-test marker view.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 7 comments.

File Description
Sources/PopupView/WindowManager.swift Refactors the window creation + implements new layer-based background hit-testing and dismissal in UIPassthroughWindow.
Sources/PopupView/PopupBackgroundView.swift Adds PopupHitTestingBackground marker view to support the new hit-testing approach.
Sources/PopupView/FullscreenPopup.swift Updates .window display mode to use the new showInNewWindow API and dismissal closure wiring.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +40 to +42

PopupHitTestingBackground() // Hit testing workaround
.ignoresSafeArea()
Copy link

Copilot AI Feb 7, 2026

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 + dismissEnabled with a tap gesture/content shape) was removed from PopupBackgroundView. Because this view is also used in .overlay and .sheet modes, 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).

Suggested change
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()
}

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point

Comment on lines +47 to +51
/// 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
Copy link

Copilot AI Feb 7, 2026

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.

Copilot uses AI. Check for mistakes.
closeOnTapOutside: closeOnTapOutside,
allowTapThroughBG: allowTapThroughBG,
dismissClosure: {
dismissSource = .tapOutside
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dismissClosure passed to showInNewWindow sets dismissSource = .tapOutside, but the same closure is also injected as @Environment(\.popupDismiss) inside WindowManager.showInNewWindow. That will cause programmatic dismissals from inside the popup to be reported as .tapOutside. Consider splitting tap-outside vs programmatic dismiss into separate closures (or pass a DismissSource into the closure).

Suggested change
dismissSource = .tapOutside

Copilot uses AI. Check for mistakes.

if didTapBackground {
if closeOnTapOutside {
dismissClosure?()
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hitTest can run multiple times per user interaction (e.g., multi-touch gestures, repeated hit-testing), but when didTapBackground is true it calls dismissClosure?() unconditionally. This can trigger dismissal logic multiple times. Add a guard so dismissal fires only once (e.g., an isDismissing flag or nil out the closure after the first call).

Suggested change
dismissClosure?()
if let dismiss = dismissClosure {
dismissClosure = nil
dismiss()
}

Copilot uses AI. Check for mistakes.
Comment on lines +107 to 111
private func isTouchInsideSubview(point: CGPoint, vc: UIView) -> UIView? {
for subview in vc.subviews {
if subview.frame.contains(point) {
return subview
}
Copy link

Copilot AI Feb 7, 2026

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.

Copilot uses AI. Check for mistakes.
id: UUID,
closeOnTapOutside: Bool,
allowTapThroughBG: Bool,
dismissClosure: SendableClosure?,
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dismissClosure is captured by an escaping environment closure (.environment(\.popupDismiss) { ... }) and stored on the window, but the showInNewWindow parameter isn’t marked @escaping. This will not compile (capturing/storing a non-escaping closure). Mark the parameter as escaping (e.g. dismissClosure: (@escaping SendableClosure)?, or make it non-optional and @escaping).

Suggested change
dismissClosure: SendableClosure?,
dismissClosure: (@escaping SendableClosure)?,

Copilot uses AI. Check for mistakes.
Comment on lines +69 to +73
init(windowScene: UIWindowScene, closeOnTapOutside: Bool, isPassthrough: Bool, dismissClosure: SendableClosure?) {
self.closeOnTapOutside = closeOnTapOutside
self.isPassthrough = isPassthrough
self.dismissClosure = dismissClosure
super.init(windowScene: windowScene)
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In UIPassthroughWindow.init, dismissClosure is assigned to a stored property, but the initializer parameter is not @escaping. This won’t compile with Swift’s non-escaping default for closure parameters. Update the initializer signature to accept an escaping closure (including the optional case).

Copilot uses AI. Check for mistakes.
isPresented = false
item = nil
}) {
AnyView(constructPopup())
Copy link
Collaborator

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?

closeOnTapOutside: Bool,
allowTapThroughBG: Bool,
dismissClosure: @escaping ()->(),
content: @escaping () -> AnyView
Copy link
Collaborator

Choose a reason for hiding this comment

The 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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants