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
}) {
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
}) {
constructPopup()
}
} 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
Copy Markdown
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) {}
}
134 changes: 98 additions & 36 deletions Sources/PopupView/WindowManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 +142 to 146
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