Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions Sources/PopupView/FullscreenPopup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,18 @@ public struct FullscreenPopup<Item: Equatable, PopupContent: View>: 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 {
Expand Down Expand Up @@ -208,13 +220,17 @@ public struct FullscreenPopup<Item: Equatable, PopupContent: View>: 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
}) {
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?

}
} else {
WindowManager.closeWindow(id: id)
}
Expand Down
45 changes: 26 additions & 19 deletions Sources/PopupView/PopupBackgroundView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
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

}
.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
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.
view.isUserInteractionEnabled = false
return view
}

func updateUIView(_ uiView: UIView, context: Context) {}
}
121 changes: 87 additions & 34 deletions Sources/PopupView/WindowManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
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?

) {
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 {
Expand All @@ -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
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.
}

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?()
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.
}
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
}
Comment on lines +133 to 137
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.
}
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<Content: View>: UIHostingController<Content> {
Expand Down