Skip to content

Commit b1f9adc

Browse files
author
vidy
committed
Add dynamic anchor frame and boundary avoidance
- Change anchorFrame from static CGRect to closure for dynamic positioning - Popup position now updates when source view moves (e.g., window resize) - Use AnchorFrameProvider wrapper for @unchecked Sendable compatibility - Add boundary avoidance to keep popups within screen bounds - New edgePadding config (default 16pt) - New constrainedEdges config (.horizontal by default, .vertical optional) - Automatically adjust position when popup would overflow edges - Simplify PopupAnchoredStackView architecture - Replace stored popupFrames with computed frame(for:) method - Fix "Publishing changes from within view updates" warnings - Use @mainactor for proper concurrency isolation API change: present(anchoredTo:) now takes a closure instead of CGRect Before: .present(anchoredTo: buttonFrame) After: .present(anchoredTo: { buttonFrame })
1 parent 5585421 commit b1f9adc

7 files changed

Lines changed: 150 additions & 54 deletions

File tree

Sources/Internal/Configurables/Local/LocalConfig+Anchored.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ public class LocalConfigAnchored: LocalConfig { required public init() {}
2222
public var popupAnchor: PopupAnchorPoint = .top
2323
public var offset: CGPoint = .zero
2424
public var isTapOutsidePassThroughEnabled: Bool = false
25+
public var edgePadding: CGFloat = 16
26+
public var constrainedEdges: Edge.Set = .horizontal
2527

2628
// MARK: Inactive Variables (inherited from LocalConfig protocol)
2729
public var ignoredSafeAreaEdges: Edge.Set = []
@@ -48,6 +50,16 @@ public extension LocalConfigAnchored {
4850
/// Enables touch pass-through when tapping outside the popup
4951
/// When enabled, touches outside the popup are passed to underlying views instead of being blocked
5052
func tapOutsidePassThrough(_ enabled: Bool) -> Self { self.isTapOutsidePassThroughEnabled = enabled; return self }
53+
54+
/// Configures edge padding and which edges to constrain
55+
/// - Parameters:
56+
/// - value: Padding value from screen edges
57+
/// - edges: Which edges to constrain (.horizontal, .vertical, .all, or [] to disable)
58+
func edgePadding(_ value: CGFloat, edges: Edge.Set = .horizontal) -> Self {
59+
self.edgePadding = value
60+
self.constrainedEdges = edges
61+
return self
62+
}
5163
}
5264

5365
// MARK: - Public Type Alias

Sources/Internal/Models/AnyPopup.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ extension AnyPopup {
6161
func updatedEnvironmentObject(_ environmentObject: some ObservableObject) -> AnyPopup { updated { $0._body = .init(_body.environmentObject(environmentObject)) }}
6262
func updatedKeyboardDismissal(_ shouldDismiss: Bool) -> AnyPopup { updated { $0.shouldDismissKeyboardOnPopupToggle = shouldDismiss }}
6363
func startDismissTimerIfNeeded(_ popupStack: PopupStack) -> AnyPopup { updated { $0._dismissTimer?.schedule { popupStack.modify(.removePopup(self)) }}}
64-
func updatedAnchorFrame(_ frame: CGRect) -> AnyPopup { updated { $0.config.anchorFrame = frame }}
64+
func updatedAnchorFrameProvider(_ provider: @escaping () -> CGRect) -> AnyPopup { updated { $0.config.anchorFrameProvider = .init(closure: provider) }}
6565
}
6666
private extension AnyPopup {
6767
func updated(_ customBuilder: (inout AnyPopup) -> ()) -> AnyPopup {

Sources/Internal/Models/AnyPopupConfig.swift

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,18 @@
1111

1212
import SwiftUI
1313

14+
/// Wrapper to bypass Sendable compiler checks for anchor frame closure.
15+
///
16+
/// This is safe because:
17+
/// 1. The closure only captures @State variables which are main-thread isolated in SwiftUI
18+
/// 2. The closure is only called from UI layer (main thread) in calculatePopupPosition()
19+
/// 3. No actual cross-thread access occurs - the Sendable requirement is only for
20+
/// passing AnyPopup through async boundaries, not for concurrent execution
21+
struct AnchorFrameProvider: @unchecked Sendable {
22+
let closure: () -> CGRect
23+
func callAsFunction() -> CGRect { closure() }
24+
}
25+
1426
struct AnyPopupConfig: LocalConfig, Sendable { init() {}
1527
// MARK: Content
1628
var alignment: PopupAlignment = .center
@@ -28,11 +40,14 @@ struct AnyPopupConfig: LocalConfig, Sendable { init() {}
2840
var dragGestureAreaSize: CGFloat = 0
2941

3042
// MARK: Anchored-specific
31-
var anchorFrame: CGRect = .zero
43+
var anchorFrameProvider: AnchorFrameProvider? = nil
44+
var anchorFrame: CGRect { anchorFrameProvider?() ?? .zero }
3245
var originAnchor: PopupAnchorPoint = .bottom
3346
var popupAnchor: PopupAnchorPoint = .top
3447
var anchorOffset: CGPoint = .zero
3548
var isTapOutsidePassThroughEnabled: Bool = false
49+
var edgePadding: CGFloat = 16
50+
var constrainedEdges: Edge.Set = .horizontal
3651
}
3752

3853
// MARK: Initialize
@@ -56,6 +71,8 @@ extension AnyPopupConfig {
5671
self.popupAnchor = anchoredConfig.popupAnchor
5772
self.anchorOffset = anchoredConfig.offset
5873
self.isTapOutsidePassThroughEnabled = anchoredConfig.isTapOutsidePassThroughEnabled
74+
self.edgePadding = anchoredConfig.edgePadding
75+
self.constrainedEdges = anchoredConfig.constrainedEdges
5976
}
6077
}
6178
}

Sources/Internal/UI/PopupAnchoredStackView.swift

Lines changed: 50 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,23 @@ class AnchoredPopupsContainer: UIView {
1717

1818
private var hostingController: UIHostingController<AnyView>?
1919
private var popupModel = AnchoredPopupModel()
20+
private var lastBoundsSize: CGSize = .zero
21+
22+
override func layoutSubviews() {
23+
super.layoutSubviews()
24+
guard bounds.size != lastBoundsSize else { return }
25+
lastBoundsSize = bounds.size
26+
27+
DispatchQueue.main.async { [weak self] in
28+
guard let self else { return }
29+
self.popupModel.containerSize = self.bounds.size
30+
}
31+
}
2032

2133
/// Returns true only if touch is inside a popup frame
2234
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
23-
for (_, frame) in popupModel.popupFrames {
24-
if frame.contains(point) {
35+
for popup in popupModel.popups {
36+
if let frame = popupModel.frame(for: popup), frame.contains(point) {
2537
return true
2638
}
2739
}
@@ -30,30 +42,17 @@ class AnchoredPopupsContainer: UIView {
3042

3143
/// Returns hit view only if touch is inside a popup frame, otherwise passes through
3244
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
33-
for (_, frame) in popupModel.popupFrames {
34-
if frame.contains(point) {
35-
// Touch inside popup, let UIHostingController handle it
45+
for popup in popupModel.popups {
46+
if let frame = popupModel.frame(for: popup), frame.contains(point) {
3647
return hostingController?.view.hitTest(point, with: event)
3748
}
3849
}
39-
// Touch outside all popups, pass through
4050
return nil
4151
}
4252

4353
/// Updates popups in the container
4454
func updatePopups(_ popups: [AnyPopup], viewModel: VM.AnchoredStack) {
45-
// Clean up frames for removed popups
46-
let currentIds = Set(popups.map { $0.id.rawValue })
47-
let existingIds = Set(popupModel.popupFrames.keys)
48-
for id in existingIds.subtracting(currentIds) {
49-
popupModel.popupFrames.removeValue(forKey: id)
50-
popupModel.popupSizes.removeValue(forKey: id)
51-
}
52-
53-
popupModel.popups = popups
54-
popupModel.viewModel = viewModel
55-
56-
// Create hosting controller if needed
55+
// Create hosting controller if needed (sync, only once)
5756
if hostingController == nil {
5857
let containerView = AnchoredPopupContainerView(model: popupModel)
5958
let hc = UIHostingController(rootView: AnyView(containerView))
@@ -63,6 +62,20 @@ class AnchoredPopupsContainer: UIView {
6362
addSubview(hc.view)
6463
hostingController = hc
6564
}
65+
66+
DispatchQueue.main.async { [weak self] in
67+
guard let self else { return }
68+
69+
// Clean up sizes for removed popups
70+
let currentIds = Set(popups.map { $0.id.rawValue })
71+
let existingIds = Set(self.popupModel.popupSizes.keys)
72+
for id in existingIds.subtracting(currentIds) {
73+
self.popupModel.popupSizes.removeValue(forKey: id)
74+
}
75+
76+
self.popupModel.popups = popups
77+
self.popupModel.viewModel = viewModel
78+
}
6679
}
6780

6881
/// Installs container directly on Window (above rootViewController.view)
@@ -87,11 +100,26 @@ class AnchoredPopupsContainer: UIView {
87100

88101
// MARK: - Popup Model (ObservableObject for SwiftUI)
89102

103+
@MainActor
90104
private class AnchoredPopupModel: ObservableObject {
91105
@Published var popups: [AnyPopup] = []
92106
@Published var popupSizes: [String: CGSize] = [:]
93-
@Published var popupFrames: [String: CGRect] = [:] // Store frame for hitTest
107+
@Published var containerSize: CGSize = .zero
94108
var viewModel: VM.AnchoredStack?
109+
110+
/// Calculate frame for popup (called during render, not stored)
111+
func frame(for popup: AnyPopup) -> CGRect? {
112+
let popupId = popup.id.rawValue
113+
guard let size = popupSizes[popupId],
114+
let viewModel = viewModel else { return nil }
115+
116+
let position = viewModel.calculatePopupPosition(
117+
for: popup,
118+
popupSize: size,
119+
containerSize: containerSize
120+
)
121+
return CGRect(origin: position, size: size)
122+
}
95123
}
96124

97125
// MARK: - SwiftUI Container View
@@ -103,41 +131,21 @@ private struct AnchoredPopupContainerView: View {
103131
ZStack(alignment: .topLeading) {
104132
ForEach(model.popups, id: \.self) { popup in
105133
let popupId = popup.id.rawValue
106-
let hasSize = model.popupSizes[popupId] != nil
134+
let frame = model.frame(for: popup)
107135

108136
PopupContentView(popup: popup, viewModel: model.viewModel)
109-
.opacity(hasSize ? 1 : 0)
137+
.opacity(frame != nil ? 1 : 0)
110138
.sizeReader { size in
111139
if model.popupSizes[popupId] != size {
112140
model.popupSizes[popupId] = size
113141
}
114142
}
115-
.offset(popupOffset(for: popup))
143+
.offset(x: frame?.origin.x ?? 0, y: frame?.origin.y ?? 0)
116144
}
117145
}
118146
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
119147
.edgesIgnoringSafeArea(.all)
120148
}
121-
122-
/// Calculate offset for popup positioning and store frame for hitTest
123-
private func popupOffset(for popup: AnyPopup) -> CGSize {
124-
let popupId = popup.id.rawValue
125-
guard let size = model.popupSizes[popupId], let viewModel = model.viewModel else {
126-
return .zero
127-
}
128-
129-
let position = viewModel.calculatePopupPosition(for: popup, popupSize: size)
130-
131-
// Store frame for hitTest
132-
let frame = CGRect(origin: position, size: size)
133-
if model.popupFrames[popupId] != frame {
134-
DispatchQueue.main.async {
135-
self.model.popupFrames[popupId] = frame
136-
}
137-
}
138-
139-
return CGSize(width: position.x, height: position.y)
140-
}
141149
}
142150

143151
/// SwiftUI content for a single popup

Sources/Internal/UI/PopupView.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@ private extension PopupView {
9696
await updateViewModels { $0.setup(updatePopupAction: updatePopup, closePopupAction: closePopup) }
9797
}}
9898
func onScreenChange(_ screenReader: GeometryProxy) { Task {
99-
await updateViewModels { await $0.updateScreen(screenHeight: screenReader.size.height + screenReader.safeAreaInsets.top + screenReader.safeAreaInsets.bottom, screenSafeArea: screenReader.safeAreaInsets) }
99+
let screenHeight = screenReader.size.height + screenReader.safeAreaInsets.top + screenReader.safeAreaInsets.bottom
100+
await updateViewModels { await $0.updateScreen(screenHeight: screenHeight, screenSafeArea: screenReader.safeAreaInsets) }
100101
}}
101102
func onPopupsHeightChange(_ p: Any) { Task {
102103
await updateViewModels { await $0.updatePopups(stack.popups) }

Sources/Internal/View Models/ViewModel+AnchoredStack.swift

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ extension VM.AnchoredStack {
9292
// MARK: Position Calculation
9393
extension VM.AnchoredStack {
9494
/// Calculates the position for the popup based on anchor frame and anchor points
95-
func calculatePopupPosition(for popup: AnyPopup, popupSize: CGSize) -> CGPoint {
95+
func calculatePopupPosition(for popup: AnyPopup, popupSize: CGSize, containerSize: CGSize = .zero) -> CGPoint {
9696
let config = popup.config
9797
let anchorFrame = config.anchorFrame
9898

@@ -102,11 +102,66 @@ extension VM.AnchoredStack {
102102
// Calculate the offset needed based on popup anchor point
103103
let popupOffset = calculatePopupOffset(for: config.popupAnchor, popupSize: popupSize)
104104

105-
// Final position = origin point - popup offset + user offset
106-
return CGPoint(
105+
// Initial position
106+
var popoverFrame = CGRect(
107107
x: originPoint.x - popupOffset.x + config.anchorOffset.x,
108-
y: originPoint.y - popupOffset.y + config.anchorOffset.y
108+
y: originPoint.y - popupOffset.y + config.anchorOffset.y,
109+
width: popupSize.width,
110+
height: popupSize.height
109111
)
112+
113+
// Apply boundary avoidance
114+
popoverFrame = applyBoundaryAvoidance(frame: popoverFrame, config: config, screenSize: containerSize)
115+
116+
return popoverFrame.origin
117+
}
118+
119+
/// Keeps popup within screen bounds based on configured edge constraints
120+
private func applyBoundaryAvoidance(frame: CGRect, config: AnyPopupConfig, screenSize: CGSize) -> CGRect {
121+
let edges = config.constrainedEdges
122+
123+
// No constraints, return original frame
124+
guard !edges.isEmpty else { return frame }
125+
126+
// Skip if screen size not initialized
127+
guard screenSize.width > 0, screenSize.height > 0 else { return frame }
128+
129+
var popoverFrame = frame
130+
let padding = config.edgePadding
131+
132+
// Horizontal constraint
133+
if edges.contains(.horizontal) {
134+
let minX = screen.safeArea.leading + padding
135+
let maxX = screenSize.width - screen.safeArea.trailing - padding
136+
137+
// Left edge overflow
138+
if popoverFrame.origin.x < minX {
139+
popoverFrame.origin.x = minX
140+
}
141+
// Right edge overflow
142+
if popoverFrame.maxX > maxX {
143+
let difference = popoverFrame.maxX - maxX
144+
popoverFrame.origin.x -= difference
145+
}
146+
}
147+
148+
// Vertical constraint
149+
if edges.contains(.vertical) {
150+
let minY = screen.safeArea.top + padding
151+
let maxY = screenSize.height - screen.safeArea.bottom - padding
152+
153+
// Top edge overflow
154+
if popoverFrame.origin.y < minY {
155+
popoverFrame.origin.y = minY
156+
}
157+
// Bottom edge overflow
158+
if popoverFrame.maxY > maxY {
159+
let difference = popoverFrame.maxY - maxY
160+
popoverFrame.origin.y -= difference
161+
}
162+
}
163+
164+
return popoverFrame
110165
}
111166

112167
/// Calculates a point on the anchor frame based on the anchor point type

Sources/Public/Present/Public+Present+Popup.swift

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,29 +41,32 @@ public extension AnchoredPopup {
4141
Presents the anchored popup positioned relative to a source view frame.
4242

4343
- Parameters:
44-
- anchorFrame: The frame of the source view to anchor the popup to (in global coordinates).
44+
- anchorFrameProvider: A closure that returns the frame of the source view (in global coordinates).
45+
The closure is called each time the popup position needs to be recalculated.
46+
- customID: Optional custom identifier for the popup.
4547
- popupStackID: The identifier registered in one of the application windows in which the popup is to be displayed.
4648

47-
- Important: The **anchorFrame** should be in global screen coordinates. Use `.frameReader` or `GeometryReader` to obtain the frame.
49+
- Important: The frame returned by **anchorFrameProvider** should be in global screen coordinates.
50+
Use `.frameReader` or `GeometryReader` to obtain the frame.
4851

4952
## Usage
5053
```swift
5154
@State private var buttonFrame: CGRect = .zero
5255

5356
Button("Show") {
54-
Task { await MyAnchoredPopup().present(anchoredTo: buttonFrame) }
57+
Task { await MyAnchoredPopup().present(anchoredTo: { buttonFrame }) }
5558
}
5659
.frameReader { frame in
5760
buttonFrame = frame
5861
}
5962
```
6063
*/
61-
@MainActor func present(anchoredTo anchorFrame: CGRect, customID: String? = nil, popupStackID: PopupStackID = .shared) async {
64+
@MainActor func present(anchoredTo anchorFrameProvider: @escaping () -> CGRect, customID: String? = nil, popupStackID: PopupStackID = .shared) async {
6265
var popup = await AnyPopup(self)
6366
if let customID = customID {
6467
popup = await popup.updatedID(customID)
6568
}
66-
popup = popup.updatedAnchorFrame(anchorFrame)
69+
popup = popup.updatedAnchorFrameProvider(anchorFrameProvider)
6770
await PopupStack.fetch(id: popupStackID)?.modify(.insertPopup(popup))
6871
}
6972
}

0 commit comments

Comments
 (0)