From a58ed6dea423c6982c53e6022fb532175ca93687 Mon Sep 17 00:00:00 2001 From: Denis Obukhov Date: Sat, 7 Feb 2026 12:33:34 +0700 Subject: [PATCH 1/2] Improve UIPassthroughWindow hitTest Fix missing touches on the popup view --- Sources/PopupView/FullscreenPopup.swift | 18 +++-- Sources/PopupView/PopupBackgroundView.swift | 45 +++++++----- Sources/PopupView/WindowManager.swift | 79 ++++++++++++++------- 3 files changed, 90 insertions(+), 52 deletions(-) diff --git a/Sources/PopupView/FullscreenPopup.swift b/Sources/PopupView/FullscreenPopup.swift index 94eeed0..ca57667 100644 --- a/Sources/PopupView/FullscreenPopup.swift +++ b/Sources/PopupView/FullscreenPopup.swift @@ -208,13 +208,17 @@ public struct FullscreenPopup: ViewModifier content .onChange(of: showSheet) { newValue in if newValue { - WindowManager.showInNewWindow(id: id, allowTapThroughBG: allowTapThroughBG, dismissClosure: { - dismissSource = .binding - isPresented = false - item = nil - }) { - constructPopup() - } + WindowManager.showInNewWindow( + id: id, + closeOnTapOutside: closeOnTapOutside, + allowTapThroughBG: allowTapThroughBG, + dismissClosure: { + dismissSource = .tapOutside + isPresented = false + item = nil + }) { + constructPopup() + } } else { WindowManager.closeWindow(id: id) } diff --git a/Sources/PopupView/PopupBackgroundView.swift b/Sources/PopupView/PopupBackgroundView.swift index 630633c..a8fa965 100644 --- a/Sources/PopupView/PopupBackgroundView.swift +++ b/Sources/PopupView/PopupBackgroundView.swift @@ -25,26 +25,33 @@ struct PopupBackgroundView: View { var dismissEnabled: Binding 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 + view.isUserInteractionEnabled = false + return view + } + + func updateUIView(_ uiView: UIView, context: Context) {} +} diff --git a/Sources/PopupView/WindowManager.swift b/Sources/PopupView/WindowManager.swift index bede34d..0d5c4e1 100644 --- a/Sources/PopupView/WindowManager.swift +++ b/Sources/PopupView/WindowManager.swift @@ -15,18 +15,30 @@ public final class WindowManager { private var windows: [UUID: UIWindow] = [:] // Show a new window with hosted SwiftUI content - public static func showInNewWindow(id: UUID, allowTapThroughBG: Bool, dismissClosure: @escaping ()->(), content: @escaping () -> Content) { + public static func showInNewWindow( + id: UUID, + closeOnTapOutside: Bool, + allowTapThroughBG: Bool, + dismissClosure: 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() .environment(\.popupDismiss) { - dismissClosure() + dismissClosure?() } let controller: UIViewController if #available(iOS 18, *) { @@ -50,41 +62,56 @@ public final class WindowManager { } 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) + } + + 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?() } - if let _ = isTouchInsideSubview(point: pointInRoot, view: vc.view) { - // pass tap to this UIPassthroughVC - return vc.view + + if isPassthrough { + return nil // pass to next window } + return vc.view } - return nil // pass to next window + + // pass tap to this + let farthestDescendent = super.hitTest(point, with: event) + return farthestDescendent } - private func isTouchInsideSubview(point: CGPoint, view: UIView) -> UIView? { - for subview in view.subviews { - if subview.isUserInteractionEnabled, subview.frame.contains(point) { + private func isTouchInsideSubview(point: CGPoint, vc: UIView) -> UIView? { + for subview in vc.subviews { + if subview.frame.contains(point) { return subview } } return nil } - - @available(iOS 26.0, *) - private func isTouchInsideSubviewForiOS26(point: CGPoint, view: UIView) -> UIView? { - guard view.layer.hitTest(point)?.name == nil else { - return nil - } - return view - } } class UITextFieldCheckingVC: UIHostingController { From 1414f616b5d9cb8482fae18e12cb453d52893ad4 Mon Sep 17 00:00:00 2001 From: Denis Obukhov Date: Mon, 9 Feb 2026 19:03:26 +0700 Subject: [PATCH 2/2] Fix not updating UIWindow contents when item changes --- Sources/PopupView/FullscreenPopup.swift | 16 +++++++- Sources/PopupView/WindowManager.swift | 50 +++++++++++++++++++------ 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/Sources/PopupView/FullscreenPopup.swift b/Sources/PopupView/FullscreenPopup.swift index ca57667..5b968f3 100644 --- a/Sources/PopupView/FullscreenPopup.swift +++ b/Sources/PopupView/FullscreenPopup.swift @@ -178,6 +178,18 @@ public struct FullscreenPopup: ViewModifier self.tempItemView = itemView(newValue) } appearAction(popupPresented: newValue != nil) + + #if os(iOS) + if displayMode == .window, showSheet, newValue != nil { + WindowManager.updateRootView(id: id, dismissClosure: { + dismissSource = .binding + isPresented = false + item = nil + }) { + AnyView(constructPopup()) + } + } + #endif } } .onAppear { @@ -213,11 +225,11 @@ public struct FullscreenPopup: ViewModifier closeOnTapOutside: closeOnTapOutside, allowTapThroughBG: allowTapThroughBG, dismissClosure: { - dismissSource = .tapOutside + dismissSource = .binding isPresented = false item = nil }) { - constructPopup() + AnyView(constructPopup()) } } else { WindowManager.closeWindow(id: id) diff --git a/Sources/PopupView/WindowManager.swift b/Sources/PopupView/WindowManager.swift index 0d5c4e1..9a77188 100644 --- a/Sources/PopupView/WindowManager.swift +++ b/Sources/PopupView/WindowManager.swift @@ -12,15 +12,21 @@ 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( + public static func showInNewWindow( id: UUID, closeOnTapOutside: Bool, allowTapThroughBG: Bool, - dismissClosure: SendableClosure?, - content: @escaping () -> Content + dismissClosure: @escaping ()->(), + content: @escaping () -> AnyView ) { guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { print("No valid scene available") @@ -36,11 +42,14 @@ public final class WindowManager { 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 { @@ -52,15 +61,32 @@ 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