diff --git a/Sources/PopupView/FullscreenPopup.swift b/Sources/PopupView/FullscreenPopup.swift index 94eeed0..071c0f0 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 + }) { + constructPopup() + } + } + #endif } } .onAppear { @@ -208,13 +220,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 = .binding + 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..f1f445c 100644 --- a/Sources/PopupView/WindowManager.swift +++ b/Sources/PopupView/WindowManager.swift @@ -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(window: UIWindow, controller: UIHostingController) { + 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(id: UUID, allowTapThroughBG: Bool, dismissClosure: @escaping ()->(), content: @escaping () -> Content) { + @MainActor func updateRootView(_ content: Content) { + rootViewUpdater(content) + } + } + + public static func showInNewWindow( + 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( + 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) + } + + 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 {