From 45487ef56e0fd0fafc5f2a1a38ebd8dff6baf7c5 Mon Sep 17 00:00:00 2001 From: vidy Date: Mon, 15 Dec 2025 11:21:54 +0800 Subject: [PATCH 01/15] feat: Add AnchoredPopup support for absolute positioning - Add PopupAlignment.anchored case - Create AnchoredPopup protocol and AnchoredPopupConfig - Implement present(anchoredTo:) API for specifying anchor frame - Create PopupAnchoredStackView for rendering anchored popups - Support originAnchor/popupAnchor configuration for flexible positioning - Add PopupAnchorPoint enum with 9 anchor positions --- .../Local/LocalConfig+Anchored.swift | 49 ++++++ .../Internal/Extensions/EdgeInsets++.swift | 1 + Sources/Internal/Models/AnyPopup.swift | 1 + Sources/Internal/Models/AnyPopupConfig.swift | 13 ++ Sources/Internal/Models/StackPriority.swift | 4 +- .../Internal/UI/PopupAnchoredStackView.swift | 91 +++++++++++ Sources/Internal/UI/PopupView.swift | 7 +- .../Internal/Utilities/PopupAlignment.swift | 12 ++ .../View Models/ViewModel+AnchoredStack.swift | 143 ++++++++++++++++++ .../View Models/ViewModel+VerticalStack.swift | 18 +-- .../Public/Popup/Public+Popup+Config.swift | 24 ++- Sources/Public/Popup/Public+Popup+Main.swift | 32 ++++ .../Public/Present/Public+Present+Popup.swift | 31 +++- 13 files changed, 411 insertions(+), 15 deletions(-) create mode 100644 Sources/Internal/Configurables/Local/LocalConfig+Anchored.swift create mode 100644 Sources/Internal/UI/PopupAnchoredStackView.swift create mode 100644 Sources/Internal/View Models/ViewModel+AnchoredStack.swift diff --git a/Sources/Internal/Configurables/Local/LocalConfig+Anchored.swift b/Sources/Internal/Configurables/Local/LocalConfig+Anchored.swift new file mode 100644 index 000000000..1633487f4 --- /dev/null +++ b/Sources/Internal/Configurables/Local/LocalConfig+Anchored.swift @@ -0,0 +1,49 @@ +// +// LocalConfig+Anchored.swift of MijickPopups +// +// Created by Vidy. Extending MijickPopups with anchored popup support. +// +// Copyright 2024 Mijick. All rights reserved. + + +import SwiftUI + +@MainActor +public class LocalConfigAnchored: LocalConfig { required public init() {} + // MARK: Active Variables + public var popupPadding: EdgeInsets = GlobalConfigContainer.center.popupPadding + public var cornerRadius: CGFloat = GlobalConfigContainer.center.cornerRadius + public var backgroundColor: Color = GlobalConfigContainer.center.backgroundColor + public var overlayColor: Color = GlobalConfigContainer.center.overlayColor + public var isTapOutsideToDismissEnabled: Bool = true + + // MARK: Anchored-specific Variables + public var originAnchor: PopupAnchorPoint = .bottom + public var popupAnchor: PopupAnchorPoint = .top + public var offset: CGPoint = .zero + + // MARK: Inactive Variables (inherited from LocalConfig protocol) + public var ignoredSafeAreaEdges: Edge.Set = [] + public var heightMode: HeightMode = .auto + public var dragDetents: [DragDetent] = [] + public var isDragGestureEnabled: Bool = false + public var dragGestureAreaSize: CGFloat = 0 +} + +// MARK: - Chained Modifiers +public extension LocalConfigAnchored { + /// Sets the anchor point on the source view (where the popup originates from) + func originAnchor(_ anchor: PopupAnchorPoint) -> Self { self.originAnchor = anchor; return self } + + /// Sets the anchor point on the popup itself (which point aligns with origin) + func popupAnchor(_ anchor: PopupAnchorPoint) -> Self { self.popupAnchor = anchor; return self } + + /// Sets additional offset from the calculated position + func offset(_ offset: CGPoint) -> Self { self.offset = offset; return self } + + /// Sets additional offset from the calculated position + func offset(x: CGFloat = 0, y: CGFloat = 0) -> Self { self.offset = CGPoint(x: x, y: y); return self } +} + +// MARK: - Public Type Alias +public typealias AnchoredPopupConfig = LocalConfigAnchored diff --git a/Sources/Internal/Extensions/EdgeInsets++.swift b/Sources/Internal/Extensions/EdgeInsets++.swift index 521eaa664..7bd44fa60 100644 --- a/Sources/Internal/Extensions/EdgeInsets++.swift +++ b/Sources/Internal/Extensions/EdgeInsets++.swift @@ -16,5 +16,6 @@ extension EdgeInsets { case .top: top case .center: 0 case .bottom: bottom + case .anchored: 0 }} } diff --git a/Sources/Internal/Models/AnyPopup.swift b/Sources/Internal/Models/AnyPopup.swift index 350c9bbe2..58e0d0faa 100644 --- a/Sources/Internal/Models/AnyPopup.swift +++ b/Sources/Internal/Models/AnyPopup.swift @@ -61,6 +61,7 @@ extension AnyPopup { func updatedEnvironmentObject(_ environmentObject: some ObservableObject) -> AnyPopup { updated { $0._body = .init(_body.environmentObject(environmentObject)) }} func updatedKeyboardDismissal(_ shouldDismiss: Bool) -> AnyPopup { updated { $0.shouldDismissKeyboardOnPopupToggle = shouldDismiss }} func startDismissTimerIfNeeded(_ popupStack: PopupStack) -> AnyPopup { updated { $0._dismissTimer?.schedule { popupStack.modify(.removePopup(self)) }}} + func updatedAnchorFrame(_ frame: CGRect) -> AnyPopup { updated { $0.config.anchorFrame = frame }} } private extension AnyPopup { func updated(_ customBuilder: (inout AnyPopup) -> ()) -> AnyPopup { diff --git a/Sources/Internal/Models/AnyPopupConfig.swift b/Sources/Internal/Models/AnyPopupConfig.swift index f2f64d8ce..15c307a07 100644 --- a/Sources/Internal/Models/AnyPopupConfig.swift +++ b/Sources/Internal/Models/AnyPopupConfig.swift @@ -26,6 +26,12 @@ struct AnyPopupConfig: LocalConfig, Sendable { init() {} var isTapOutsideToDismissEnabled: Bool = false var isDragGestureEnabled: Bool = false var dragGestureAreaSize: CGFloat = 0 + + // MARK: Anchored-specific + var anchorFrame: CGRect = .zero + var originAnchor: PopupAnchorPoint = .bottom + var popupAnchor: PopupAnchorPoint = .top + var anchorOffset: CGPoint = .zero } // MARK: Initialize @@ -42,5 +48,12 @@ extension AnyPopupConfig { self.isTapOutsideToDismissEnabled = config.isTapOutsideToDismissEnabled self.isDragGestureEnabled = config.isDragGestureEnabled self.dragGestureAreaSize = config.dragGestureAreaSize + + // Anchored-specific properties + if let anchoredConfig = config as? LocalConfigAnchored { + self.originAnchor = anchoredConfig.originAnchor + self.popupAnchor = anchoredConfig.popupAnchor + self.anchorOffset = anchoredConfig.offset + } } } diff --git a/Sources/Internal/Models/StackPriority.swift b/Sources/Internal/Models/StackPriority.swift index 68e67ac80..43ca360bd 100644 --- a/Sources/Internal/Models/StackPriority.swift +++ b/Sources/Internal/Models/StackPriority.swift @@ -15,9 +15,10 @@ struct StackPriority: Equatable, Sendable { var top: CGFloat { values[0] } var center: CGFloat { values[1] } var bottom: CGFloat { values[2] } + var anchored: CGFloat { values[3] } var overlay: CGFloat { 1 } - private var values: [CGFloat] = [0, 0, 0] + private var values: [CGFloat] = [0, 0, 0, 0] } // MARK: Reshuffled @@ -26,6 +27,7 @@ extension StackPriority { case .top: reshuffled(0) case .center: reshuffled(1) case .bottom: reshuffled(2) + case .anchored: reshuffled(3) default: self }} } diff --git a/Sources/Internal/UI/PopupAnchoredStackView.swift b/Sources/Internal/UI/PopupAnchoredStackView.swift new file mode 100644 index 000000000..c51b9e8d3 --- /dev/null +++ b/Sources/Internal/UI/PopupAnchoredStackView.swift @@ -0,0 +1,91 @@ +// +// PopupAnchoredStackView.swift of MijickPopups +// +// Created by Vidy. Extending MijickPopups with anchored popup support. +// +// Copyright 2024 Mijick. All rights reserved. + + +import SwiftUI + +struct PopupAnchoredStackView: View { + @ObservedObject var viewModel: VM.AnchoredStack + + + var body: some View { if viewModel.screen.height > 0 { + ZStack(alignment: .topLeading, content: createPopupStack) + .id(viewModel.popups.isEmpty) + .frame(maxWidth: .infinity, maxHeight: viewModel.screen.height) + }} +} +private extension PopupAnchoredStackView { + func createPopupStack() -> some View { + ForEach(viewModel.popups, id: \.self, content: createPopup) + } +} +private extension PopupAnchoredStackView { + func createPopup(_ popup: AnyPopup) -> some View { + PopupAnchoredContentView(popup: popup, viewModel: viewModel) + .opacity(viewModel.calculateOpacity(for: popup)) + } +} + +// MARK: - Anchored Content View +/// A wrapper view that measures popup size and positions it correctly +private struct PopupAnchoredContentView: View { + let popup: AnyPopup + @ObservedObject var viewModel: VM.AnchoredStack + @State private var popupSize: CGSize = .zero + + var body: some View { + let position = viewModel.calculatePopupPosition(for: popup, popupSize: popupSize) + + popupContent + .background(GeometryReader { geometry in + Color.clear.preference(key: SizePreferenceKey.self, value: geometry.size) + }) + .onPreferenceChange(SizePreferenceKey.self) { newSize in + if popupSize != newSize { + popupSize = newSize + } + } + .offset(x: position.x, y: position.y) + .transition(transition) + } + + @ViewBuilder + private var popupContent: some View { + popup.body + .compositingGroup() + .fixedSize(horizontal: false, vertical: viewModel.activePopupProperties.verticalFixedSize) + .onHeightChange { await viewModel.updatePopupHeight($0, popup) } + .background(backgroundColor: popup.config.backgroundColor, overlayColor: .clear, corners: viewModel.activePopupProperties.corners) + .focusSection_tvOS() + } + + private var transition: AnyTransition { + .scale(scale: 0.9, anchor: transitionAnchor).combined(with: .opacity) + } + + private var transitionAnchor: UnitPoint { + switch popup.config.popupAnchor { + case .topLeft: return .topLeading + case .top: return .top + case .topRight: return .topTrailing + case .left: return .leading + case .center: return .center + case .right: return .trailing + case .bottomLeft: return .bottomLeading + case .bottom: return .bottom + case .bottomRight: return .bottomTrailing + } + } +} + +// MARK: - Size Preference Key +private struct SizePreferenceKey: PreferenceKey { + nonisolated(unsafe) static var defaultValue: CGSize = .zero + static func reduce(value: inout CGSize, nextValue: () -> CGSize) { + value = nextValue() + } +} diff --git a/Sources/Internal/UI/PopupView.swift b/Sources/Internal/UI/PopupView.swift index c80dd5294..2c1d34730 100644 --- a/Sources/Internal/UI/PopupView.swift +++ b/Sources/Internal/UI/PopupView.swift @@ -20,6 +20,7 @@ struct PopupView: View { private let topStackViewModel: VM.VerticalStack = .init(TopPopupConfig.self) private let centerStackViewModel: VM.CenterStack = .init(CenterPopupConfig.self) private let bottomStackViewModel: VM.VerticalStack = .init(BottomPopupConfig.self) + private let anchoredStackViewModel: VM.AnchoredStack = .init(AnchoredPopupConfig.self) init(rootView: any View, popupStack: PopupStack) { @@ -61,6 +62,7 @@ private extension PopupView { createTopPopupStackView() createCenterPopupStackView() createBottomPopupStackView() + createAnchoredPopupStackView() } } } @@ -80,6 +82,9 @@ private extension PopupView { func createBottomPopupStackView() -> some View { PopupVerticalStackView(viewModel: bottomStackViewModel).zIndex(stack.priority.bottom) } + func createAnchoredPopupStackView() -> some View { + PopupAnchoredStackView(viewModel: anchoredStackViewModel).zIndex(stack.priority.anchored) + } } private extension PopupView { func getOverlayColor() -> Color { stack.popups.last?.config.overlayColor ?? .clear } @@ -119,7 +124,7 @@ private extension PopupView { await stack.modify(.removePopup(popup)) } func updateViewModels(_ updateBuilder: @MainActor @escaping (any ViewModel) async -> ()) async { - for viewModel in [topStackViewModel, centerStackViewModel, bottomStackViewModel] { await updateBuilder(viewModel as! any ViewModel) } + for viewModel in [topStackViewModel, centerStackViewModel, bottomStackViewModel, anchoredStackViewModel] { await updateBuilder(viewModel as! any ViewModel) } } } private extension PopupView { diff --git a/Sources/Internal/Utilities/PopupAlignment.swift b/Sources/Internal/Utilities/PopupAlignment.swift index 4047536db..3aa47c151 100644 --- a/Sources/Internal/Utilities/PopupAlignment.swift +++ b/Sources/Internal/Utilities/PopupAlignment.swift @@ -15,6 +15,14 @@ enum PopupAlignment { case top case center case bottom + case anchored +} + +// MARK: Anchor Point +public enum PopupAnchorPoint: Sendable { + case topLeft, top, topRight + case left, center, right + case bottomLeft, bottom, bottomRight } // MARK: Initialize @@ -23,6 +31,7 @@ extension PopupAlignment { case is TopPopupConfig.Type: self = .top case is CenterPopupConfig.Type: self = .center case is BottomPopupConfig.Type: self = .bottom + case is AnchoredPopupConfig.Type: self = .anchored default: fatalError() }} } @@ -33,6 +42,7 @@ extension PopupAlignment { case .top: .bottom case .center: .center case .bottom: .top + case .anchored: .anchored }} } @@ -42,10 +52,12 @@ extension PopupAlignment { case .top: .top case .center: .bottom case .bottom: .bottom + case .anchored: .top }} func toAlignment() -> Alignment { switch self { case .top: .top case .center: .center case .bottom: .bottom + case .anchored: .topLeading }} } diff --git a/Sources/Internal/View Models/ViewModel+AnchoredStack.swift b/Sources/Internal/View Models/ViewModel+AnchoredStack.swift new file mode 100644 index 000000000..f4c50059f --- /dev/null +++ b/Sources/Internal/View Models/ViewModel+AnchoredStack.swift @@ -0,0 +1,143 @@ +// +// ViewModel+AnchoredStack.swift of MijickPopups +// +// Created by Vidy. Extending MijickPopups with anchored popup support. +// +// Copyright 2024 Mijick. All rights reserved. + + +import SwiftUI + +extension VM { class AnchoredStack: ViewModel { required init() {} + var alignment: PopupAlignment = .anchored + var popups: [AnyPopup] = [] + var activePopupProperties: ActivePopupProperties = .init() + var screen: Screen = .init() + var updatePopupAction: ((AnyPopup) async -> ())? + var closePopupAction: ((AnyPopup) async -> ())? +}} + + +// MARK: - METHODS / VIEW MODEL / ACTIVE POPUP + + + +// MARK: Height +extension VM.AnchoredStack { + func calculateActivePopupHeight() async -> CGFloat? { + popups.last?.height + } +} + +// MARK: Outer Padding +extension VM.AnchoredStack { + func calculateActivePopupOuterPadding() async -> EdgeInsets { .init() } +} + +// MARK: Inner Padding +extension VM.AnchoredStack { + func calculateActivePopupInnerPadding() async -> EdgeInsets { .init() } +} + +// MARK: Corners +extension VM.AnchoredStack { + func calculateActivePopupCorners() async -> [PopupAlignment : CGFloat] { [ + .top: popups.last?.config.cornerRadius ?? 0, + .bottom: popups.last?.config.cornerRadius ?? 0 + ]} +} + +// MARK: Vertical Fixed Size +extension VM.AnchoredStack { + func calculateActivePopupVerticalFixedSize() async -> Bool { true } +} + +// MARK: Translation Progress +extension VM.AnchoredStack { + func calculateActivePopupTranslationProgress() async -> CGFloat { 0 } +} + + +// MARK: - METHODS / VIEW MODEL / SELECTED POPUP + + + +// MARK: Height +extension VM.AnchoredStack { + func calculatePopupHeight(_ heightCandidate: CGFloat, _ popup: AnyPopup) async -> CGFloat { + min(heightCandidate, calculateMaxHeight()) + } +} +private extension VM.AnchoredStack { + func calculateMaxHeight() -> CGFloat { + let fullscreenHeight = screen.height, + safeAreaHeight = screen.safeArea.top + screen.safeArea.bottom + return fullscreenHeight - safeAreaHeight + } +} + + +// MARK: - METHODS / VIEW + + + +// MARK: Opacity +extension VM.AnchoredStack { + func calculateOpacity(for popup: AnyPopup) -> CGFloat { + popups.last == popup ? 1 : 0 + } +} + +// MARK: Position Calculation +extension VM.AnchoredStack { + /// Calculates the position for the popup based on anchor frame and anchor points + func calculatePopupPosition(for popup: AnyPopup, popupSize: CGSize) -> CGPoint { + let config = popup.config + let anchorFrame = config.anchorFrame + + // Calculate origin point on the anchor view + let originPoint = calculateAnchorPoint(for: config.originAnchor, in: anchorFrame) + + // Calculate the offset needed based on popup anchor point + let popupOffset = calculatePopupOffset(for: config.popupAnchor, popupSize: popupSize) + + // Final position = origin point - popup offset + user offset + return CGPoint( + x: originPoint.x - popupOffset.x + config.anchorOffset.x, + y: originPoint.y - popupOffset.y + config.anchorOffset.y + ) + } + + /// Calculates a point on the anchor frame based on the anchor point type + private func calculateAnchorPoint(for anchor: PopupAnchorPoint, in frame: CGRect) -> CGPoint { + switch anchor { + case .topLeft: return CGPoint(x: frame.minX, y: frame.minY) + case .top: return CGPoint(x: frame.midX, y: frame.minY) + case .topRight: return CGPoint(x: frame.maxX, y: frame.minY) + case .left: return CGPoint(x: frame.minX, y: frame.midY) + case .center: return CGPoint(x: frame.midX, y: frame.midY) + case .right: return CGPoint(x: frame.maxX, y: frame.midY) + case .bottomLeft: return CGPoint(x: frame.minX, y: frame.maxY) + case .bottom: return CGPoint(x: frame.midX, y: frame.maxY) + case .bottomRight: return CGPoint(x: frame.maxX, y: frame.maxY) + } + } + + /// Calculates the offset within the popup based on its anchor point + private func calculatePopupOffset(for anchor: PopupAnchorPoint, popupSize: CGSize) -> CGPoint { + let width = popupSize.width + let height = popupSize.height + + switch anchor { + case .topLeft: return CGPoint(x: 0, y: 0) + case .top: return CGPoint(x: width / 2, y: 0) + case .topRight: return CGPoint(x: width, y: 0) + case .left: return CGPoint(x: 0, y: height / 2) + case .center: return CGPoint(x: width / 2, y: height / 2) + case .right: return CGPoint(x: width, y: height / 2) + case .bottomLeft: return CGPoint(x: 0, y: height) + case .bottom: return CGPoint(x: width / 2, y: height) + case .bottomRight: return CGPoint(x: width, y: height) + } + } +} diff --git a/Sources/Internal/View Models/ViewModel+VerticalStack.swift b/Sources/Internal/View Models/ViewModel+VerticalStack.swift index a29306926..ff573719c 100644 --- a/Sources/Internal/View Models/ViewModel+VerticalStack.swift +++ b/Sources/Internal/View Models/ViewModel+VerticalStack.swift @@ -40,7 +40,7 @@ private extension VM.VerticalStack { func getDragTranslationMultiplier() -> CGFloat { switch alignment { case .top: 1 case .bottom: -1 - case .center: fatalError() + case .center, .anchored: fatalError() }} } @@ -93,7 +93,7 @@ private extension VM.VerticalStack { return switch alignment { case .top: calculateVerticalInnerPaddingAdhereEdge(safeAreaHeight: screen.safeArea.top, popupOuterPadding: activePopupProperties.outerPadding.top) case .bottom: calculateVerticalInnerPaddingCounterEdge(popupHeight: activePopupProperties.height ?? 0, safeArea: screen.safeArea.top) - case .center: fatalError() + case .center, .anchored: fatalError() } } func calculateBottomInnerPadding(popup: AnyPopup) -> CGFloat { @@ -102,7 +102,7 @@ private extension VM.VerticalStack { return switch alignment { case .top: calculateVerticalInnerPaddingCounterEdge(popupHeight: activePopupProperties.height ?? 0, safeArea: screen.safeArea.bottom) case .bottom: calculateVerticalInnerPaddingAdhereEdge(safeAreaHeight: screen.safeArea.bottom, popupOuterPadding: activePopupProperties.outerPadding.bottom) - case .center: fatalError() + case .center, .anchored: fatalError() } } func calculateLeadingInnerPadding(popup: AnyPopup) -> CGFloat { switch popup.config.ignoredSafeAreaEdges.contains(.leading) { @@ -143,12 +143,12 @@ private extension VM.VerticalStack { func calculateTopCornerRadius(_ cornerRadiusValue: CGFloat) -> CGFloat { switch alignment { case .top: activePopupProperties.outerPadding.top != 0 ? cornerRadiusValue : 0 case .bottom: cornerRadiusValue - case .center: fatalError() + case .center, .anchored: fatalError() }} func calculateBottomCornerRadius(_ cornerRadiusValue: CGFloat) -> CGFloat { switch alignment { case .top: cornerRadiusValue case .bottom: activePopupProperties.outerPadding.bottom != 0 ? cornerRadiusValue : 0 - case .center: fatalError() + case .center, .anchored: fatalError() }} } @@ -165,7 +165,7 @@ extension VM.VerticalStack { func calculateActivePopupTranslationProgress() async -> CGFloat { guard let activePopupHeight = popups.last?.height else { return 0 }; return switch alignment { case .top: abs(min(activePopupProperties.gestureTranslation + (popups.last?.dragHeight ?? 0), 0)) / activePopupHeight case .bottom: max(activePopupProperties.gestureTranslation - (popups.last?.dragHeight ?? 0), 0) / activePopupHeight - case .center: fatalError() + case .center, .anchored: fatalError() }} } @@ -237,7 +237,7 @@ private extension VM.VerticalStack { return switch alignment { case .top: min(activePopupProperties.gestureTranslation + lastPopupDragHeight, 0) case .bottom: max(activePopupProperties.gestureTranslation - lastPopupDragHeight, 0) - case .center: fatalError() + case .center, .anchored: fatalError() } } func calculateOffsetForStackedPopup(_ popup: AnyPopup) -> CGFloat { @@ -246,7 +246,7 @@ private extension VM.VerticalStack { let alignmentMultiplier = switch alignment { case .top: 1.0 case .bottom: -1.0 - case .center: fatalError() + case .center, .anchored: fatalError() } return offsetValue * alignmentMultiplier @@ -364,7 +364,7 @@ private extension VM.VerticalStack { func calculateDragExtremeValue(_ value1: CGFloat, _ value2: CGFloat) -> CGFloat { switch alignment { case .top: min(value1, value2) case .bottom: max(value1, value2) - case .center: fatalError() + case .center, .anchored: fatalError() }} } diff --git a/Sources/Public/Popup/Public+Popup+Config.swift b/Sources/Public/Popup/Public+Popup+Config.swift index 54855e1d3..093fb345e 100644 --- a/Sources/Public/Popup/Public+Popup+Config.swift +++ b/Sources/Public/Popup/Public+Popup+Config.swift @@ -153,12 +153,30 @@ public extension LocalConfigVertical { /** Defines the vertical size (in points) of the area that responds to dismissal drag gesture. - - Use this to control how much of the popup’s top/bottom region is draggable for dismiss gesture. + + Use this to control how much of the popup's top/bottom region is draggable for dismiss gesture. A larger value allows dragging from a wider area. - + ## Visualisation ![image](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/bottom-popup-draggable-area.png?raw=true) */ func dragGestureAreaSize(_ value: CGFloat) -> Self { self.dragGestureAreaSize = value; return self } } + +// MARK: Anchored +public extension LocalConfigAnchored { + /// Distance of the popup from its edges. + func popupPadding(_ value: EdgeInsets) -> Self { self.popupPadding = value; return self } + + /// Corner radius of the background of the active popup. + func cornerRadius(_ value: CGFloat) -> Self { self.cornerRadius = value; return self } + + /// Background color of the popup. + func backgroundColor(_ color: Color) -> Self { self.backgroundColor = color; return self } + + /// The color of the overlay covering the view behind the popup. + func overlayColor(_ color: Color) -> Self { self.overlayColor = color; return self } + + /// If enabled, dismisses the active popup when touched outside its area. + func tapOutsideToDismissPopup(_ value: Bool) -> Self { self.isTapOutsideToDismissEnabled = value; return self } +} diff --git a/Sources/Public/Popup/Public+Popup+Main.swift b/Sources/Public/Popup/Public+Popup+Main.swift index 60b0fb039..38f002480 100644 --- a/Sources/Public/Popup/Public+Popup+Main.swift +++ b/Sources/Public/Popup/Public+Popup+Main.swift @@ -130,3 +130,35 @@ public typealias CenterPopupConfig = LocalConfigCenter */ public protocol BottomPopup: Popup { associatedtype Config = BottomPopupConfig } public typealias BottomPopupConfig = LocalConfigVertical.Bottom + +/** + The view to be displayed as an Anchored popup, positioned relative to a source view. + + ## Optional Methods + - ``Popup/configurePopup(config:)-98ha0`` + - ``Popup/onFocus()-loq5`` + - ``Popup/onDismiss()-254h8`` + + ## Usage + ```swift + struct AnchoredPopupExample: AnchoredPopup { + func onFocus() { print("Popup is now active") } + func onDismiss() { print("Popup was dismissed") } + func configurePopup(config: AnchoredPopupConfig) -> AnchoredPopupConfig { config + .originAnchor(.bottom) + .popupAnchor(.top) + .offset(x: 0, y: 8) + .cornerRadius(12) + } + var body: some View { + Text("Hello Kitty") + } + } + + // Present with anchor frame + Button("Show") { + Task { await AnchoredPopupExample().present(anchoredTo: buttonFrame) } + } + ``` + */ +public protocol AnchoredPopup: Popup { associatedtype Config = AnchoredPopupConfig } diff --git a/Sources/Public/Present/Public+Present+Popup.swift b/Sources/Public/Present/Public+Present+Popup.swift index 9591b6e53..829f6a935 100644 --- a/Sources/Public/Present/Public+Present+Popup.swift +++ b/Sources/Public/Present/Public+Present+Popup.swift @@ -29,12 +29,41 @@ public extension Popup { ``SwiftUICore/View/dismissPopup(_:popupStackID:)-9mkd5``, ``SwiftUICore/View/dismissAllPopups(popupStackID:)`` should be called with the same **popupStackID** as the one used here. - + - Warning: To present multiple popups of the same type, set a unique identifier using the method ``Popup/setCustomID(_:)``. */ @MainActor func present(popupStackID: PopupStackID = .shared) async { await PopupStack.fetch(id: popupStackID)?.modify(.insertPopup(.init(self))) } } +// MARK: Anchored Popup Present +public extension AnchoredPopup { + /** + Presents the anchored popup positioned relative to a source view frame. + + - Parameters: + - anchorFrame: The frame of the source view to anchor the popup to (in global coordinates). + - popupStackID: The identifier registered in one of the application windows in which the popup is to be displayed. + + - Important: The **anchorFrame** should be in global screen coordinates. Use `.frameReader` or `GeometryReader` to obtain the frame. + + ## Usage + ```swift + @State private var buttonFrame: CGRect = .zero + + Button("Show") { + Task { await MyAnchoredPopup().present(anchoredTo: buttonFrame) } + } + .frameReader { frame in + buttonFrame = frame + } + ``` + */ + @MainActor func present(anchoredTo anchorFrame: CGRect, popupStackID: PopupStackID = .shared) async { + let popup = await AnyPopup(self).updatedAnchorFrame(anchorFrame) + await PopupStack.fetch(id: popupStackID)?.modify(.insertPopup(popup)) + } +} + // MARK: Configure Popup public extension Popup { /** From d5474d2c211697c84079cf9ab9fdfa6e95fb7829 Mon Sep 17 00:00:00 2001 From: vidy Date: Tue, 16 Dec 2025 22:04:02 +0800 Subject: [PATCH 02/15] Add tapOutsidePassThrough for AnchoredPopup - Add isTapOutsidePassThroughEnabled config option - Update hitTest for iOS 17/18/26 to support pass-through --- .../Local/LocalConfig+Anchored.swift | 5 + Sources/Internal/Models/AnyPopupConfig.swift | 2 + .../Internal/UI/PopupAnchoredStackView.swift | 203 +++++++++++++----- Sources/Internal/UI/PopupView.swift | 1 + .../View Models/ViewModel+AnchoredStack.swift | 3 +- .../Setup/Public+Setup+SceneDelegate.swift | 82 +++++-- 6 files changed, 222 insertions(+), 74 deletions(-) diff --git a/Sources/Internal/Configurables/Local/LocalConfig+Anchored.swift b/Sources/Internal/Configurables/Local/LocalConfig+Anchored.swift index 1633487f4..f317f5255 100644 --- a/Sources/Internal/Configurables/Local/LocalConfig+Anchored.swift +++ b/Sources/Internal/Configurables/Local/LocalConfig+Anchored.swift @@ -21,6 +21,7 @@ public class LocalConfigAnchored: LocalConfig { required public init() {} public var originAnchor: PopupAnchorPoint = .bottom public var popupAnchor: PopupAnchorPoint = .top public var offset: CGPoint = .zero + public var isTapOutsidePassThroughEnabled: Bool = false // MARK: Inactive Variables (inherited from LocalConfig protocol) public var ignoredSafeAreaEdges: Edge.Set = [] @@ -43,6 +44,10 @@ public extension LocalConfigAnchored { /// Sets additional offset from the calculated position func offset(x: CGFloat = 0, y: CGFloat = 0) -> Self { self.offset = CGPoint(x: x, y: y); return self } + + /// Enables touch pass-through when tapping outside the popup + /// When enabled, touches outside the popup are passed to underlying views instead of being blocked + func tapOutsidePassThrough(_ enabled: Bool) -> Self { self.isTapOutsidePassThroughEnabled = enabled; return self } } // MARK: - Public Type Alias diff --git a/Sources/Internal/Models/AnyPopupConfig.swift b/Sources/Internal/Models/AnyPopupConfig.swift index 15c307a07..b3531816c 100644 --- a/Sources/Internal/Models/AnyPopupConfig.swift +++ b/Sources/Internal/Models/AnyPopupConfig.swift @@ -32,6 +32,7 @@ struct AnyPopupConfig: LocalConfig, Sendable { init() {} var originAnchor: PopupAnchorPoint = .bottom var popupAnchor: PopupAnchorPoint = .top var anchorOffset: CGPoint = .zero + var isTapOutsidePassThroughEnabled: Bool = false } // MARK: Initialize @@ -54,6 +55,7 @@ extension AnyPopupConfig { self.originAnchor = anchoredConfig.originAnchor self.popupAnchor = anchoredConfig.popupAnchor self.anchorOffset = anchoredConfig.offset + self.isTapOutsidePassThroughEnabled = anchoredConfig.isTapOutsidePassThroughEnabled } } } diff --git a/Sources/Internal/UI/PopupAnchoredStackView.swift b/Sources/Internal/UI/PopupAnchoredStackView.swift index c51b9e8d3..deb0e0215 100644 --- a/Sources/Internal/UI/PopupAnchoredStackView.swift +++ b/Sources/Internal/UI/PopupAnchoredStackView.swift @@ -7,85 +7,172 @@ import SwiftUI +import UIKit -struct PopupAnchoredStackView: View { - @ObservedObject var viewModel: VM.AnchoredStack +// MARK: - Anchored Popups Container (Added directly to Window, bypassing SwiftUI hierarchy) +/// UIKit container managing multiple popup UIHostingControllers +/// Added directly to Window, not through SwiftUI view hierarchy +class AnchoredPopupsContainer: UIView { + static var shared: AnchoredPopupsContainer? - var body: some View { if viewModel.screen.height > 0 { - ZStack(alignment: .topLeading, content: createPopupStack) - .id(viewModel.popups.isEmpty) - .frame(maxWidth: .infinity, maxHeight: viewModel.screen.height) - }} -} -private extension PopupAnchoredStackView { - func createPopupStack() -> some View { - ForEach(viewModel.popups, id: \.self, content: createPopup) + private var popupViews: [String: UIView] = [:] + private var hostingControllers: [String: UIHostingController] = [:] + + /// Returns false when touch is outside popup, allowing events to pass through to underlying views + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + for subview in subviews { + let convertedPoint = convert(point, to: subview) + if subview.point(inside: convertedPoint, with: event) { + return true + } + } + return false } -} -private extension PopupAnchoredStackView { - func createPopup(_ popup: AnyPopup) -> some View { - PopupAnchoredContentView(popup: popup, viewModel: viewModel) - .opacity(viewModel.calculateOpacity(for: popup)) + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + for subview in subviews.reversed() { + let convertedPoint = convert(point, to: subview) + if let hitView = subview.hitTest(convertedPoint, with: event) { + return hitView + } + } + return nil + } + + /// Updates popups in the container + func updatePopups(_ popups: [AnyPopup], viewModel: VM.AnchoredStack) { + let currentIds = Set(popups.map { $0.id.rawValue }) + let existingIds = Set(popupViews.keys) + + // Remove popups that no longer exist + for id in existingIds.subtracting(currentIds) { + popupViews[id]?.removeFromSuperview() + popupViews[id] = nil + hostingControllers[id] = nil + } + + // Add or update popups + for popup in popups { + let id = popup.id.rawValue + + if let existingView = popupViews[id] { + let actualSize = existingView.frame.size + let position = viewModel.calculatePopupPosition(for: popup, popupSize: actualSize) + var newFrame = existingView.frame + newFrame.origin = CGPoint(x: position.x, y: position.y) + existingView.frame = newFrame + } else { + let (hostingController, popupView) = createPopupView(for: popup, viewModel: viewModel) + popupView.sizeToFit() + let actualSize = popupView.frame.size + let position = viewModel.calculatePopupPosition(for: popup, popupSize: actualSize) + var frame = popupView.frame + frame.origin = CGPoint(x: position.x, y: position.y) + popupView.frame = frame + addSubview(popupView) + popupViews[id] = popupView + hostingControllers[id] = hostingController + } + } + } + + private func createPopupView(for popup: AnyPopup, viewModel: VM.AnchoredStack) -> (UIHostingController, UIView) { + let content = AnyView(PopupContentView(popup: popup, viewModel: viewModel)) + let hostingController = UIHostingController(rootView: content) + hostingController.view.backgroundColor = .clear + hostingController.view.translatesAutoresizingMaskIntoConstraints = true + return (hostingController, hostingController.view) + } + + /// Installs container directly on Window (above rootViewController.view) + static func install(on window: UIWindow) { + guard shared == nil else { return } + let container = AnchoredPopupsContainer() + container.backgroundColor = .clear + container.translatesAutoresizingMaskIntoConstraints = false + + // Add directly to window, above rootViewController.view + window.addSubview(container) + NSLayoutConstraint.activate([ + container.topAnchor.constraint(equalTo: window.topAnchor), + container.bottomAnchor.constraint(equalTo: window.bottomAnchor), + container.leadingAnchor.constraint(equalTo: window.leadingAnchor), + container.trailingAnchor.constraint(equalTo: window.trailingAnchor) + ]) + // Force layout to apply constraints + container.setNeedsLayout() + window.layoutIfNeeded() + shared = container } } -// MARK: - Anchored Content View -/// A wrapper view that measures popup size and positions it correctly -private struct PopupAnchoredContentView: View { +/// SwiftUI content for a single popup +private struct PopupContentView: View { let popup: AnyPopup @ObservedObject var viewModel: VM.AnchoredStack - @State private var popupSize: CGSize = .zero var body: some View { - let position = viewModel.calculatePopupPosition(for: popup, popupSize: popupSize) - - popupContent - .background(GeometryReader { geometry in - Color.clear.preference(key: SizePreferenceKey.self, value: geometry.size) - }) - .onPreferenceChange(SizePreferenceKey.self) { newSize in - if popupSize != newSize { - popupSize = newSize - } - } - .offset(x: position.x, y: position.y) - .transition(transition) - } - - @ViewBuilder - private var popupContent: some View { popup.body .compositingGroup() .fixedSize(horizontal: false, vertical: viewModel.activePopupProperties.verticalFixedSize) - .onHeightChange { await viewModel.updatePopupHeight($0, popup) } .background(backgroundColor: popup.config.backgroundColor, overlayColor: .clear, corners: viewModel.activePopupProperties.corners) - .focusSection_tvOS() + .opacity(viewModel.calculateOpacity(for: popup)) } +} + +// MARK: - Popup Anchored Stack View (Triggers installation and updates) + +struct PopupAnchoredStackView: View { + @ObservedObject var viewModel: VM.AnchoredStack - private var transition: AnyTransition { - .scale(scale: 0.9, anchor: transitionAnchor).combined(with: .opacity) + var body: some View { + // InstallerView is only used to get window reference and install container + // AnchoredPopupsContainer is added directly to Window, not in SwiftUI hierarchy + AnchoredStackInstaller(viewModel: viewModel) + .frame(width: 1, height: 1) } +} - private var transitionAnchor: UnitPoint { - switch popup.config.popupAnchor { - case .topLeft: return .topLeading - case .top: return .top - case .topRight: return .topTrailing - case .left: return .leading - case .center: return .center - case .right: return .trailing - case .bottomLeft: return .bottomLeading - case .bottom: return .bottom - case .bottomRight: return .bottomTrailing - } +/// Used to get the current view's window and install the container +private struct AnchoredStackInstaller: UIViewRepresentable { + @ObservedObject var viewModel: VM.AnchoredStack + + func makeUIView(context: Context) -> InstallerView { + let view = InstallerView() + view.viewModel = viewModel + return view + } + + func updateUIView(_ uiView: InstallerView, context: Context) { + uiView.viewModel = viewModel + uiView.updateIfNeeded() } } -// MARK: - Size Preference Key -private struct SizePreferenceKey: PreferenceKey { - nonisolated(unsafe) static var defaultValue: CGSize = .zero - static func reduce(value: inout CGSize, nextValue: () -> CGSize) { - value = nextValue() +private class InstallerView: UIView { + var viewModel: VM.AnchoredStack? + + override func didMoveToWindow() { + super.didMoveToWindow() + installContainerIfNeeded() + updatePopups() + } + + func updateIfNeeded() { + installContainerIfNeeded() + updatePopups() + } + + private func installContainerIfNeeded() { + guard AnchoredPopupsContainer.shared == nil, let window = window else { return } + AnchoredPopupsContainer.install(on: window) + } + + private func updatePopups() { + if let viewModel = viewModel { + AnchoredPopupsContainer.shared?.updatePopups(viewModel.popups, viewModel: viewModel) + } } } + diff --git a/Sources/Internal/UI/PopupView.swift b/Sources/Internal/UI/PopupView.swift index 2c1d34730..59095fff4 100644 --- a/Sources/Internal/UI/PopupView.swift +++ b/Sources/Internal/UI/PopupView.swift @@ -69,6 +69,7 @@ private extension PopupView { private extension PopupView { func createOverlayView() -> some View { getOverlayColor() + .contentShape(Rectangle()) .zIndex(stack.priority.overlay) .animation(.linear, value: stack.popups) .onTapGesture(perform: onTap) diff --git a/Sources/Internal/View Models/ViewModel+AnchoredStack.swift b/Sources/Internal/View Models/ViewModel+AnchoredStack.swift index f4c50059f..e47be0c64 100644 --- a/Sources/Internal/View Models/ViewModel+AnchoredStack.swift +++ b/Sources/Internal/View Models/ViewModel+AnchoredStack.swift @@ -84,7 +84,8 @@ private extension VM.AnchoredStack { // MARK: Opacity extension VM.AnchoredStack { func calculateOpacity(for popup: AnyPopup) -> CGFloat { - popups.last == popup ? 1 : 0 + // AnchoredPopup supports multiple popups displayed simultaneously, so all popups have opacity 1 + 1 } } diff --git a/Sources/Public/Setup/Public+Setup+SceneDelegate.swift b/Sources/Public/Setup/Public+Setup+SceneDelegate.swift index 6cc35c3e7..554000734 100644 --- a/Sources/Public/Setup/Public+Setup+SceneDelegate.swift +++ b/Sources/Public/Setup/Public+Setup+SceneDelegate.swift @@ -95,14 +95,14 @@ fileprivate class Window: UIWindow { // MARK: Implementation extension Window { override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { - if #available(iOS 26, *) { point_iOS26(inside: point, with: event) } - else if #available(iOS 18, *) { point_iOS18(inside: point, with: event) } - else { point_iOS17(inside: point, with: event) } + if #available(iOS 26, *) { return point_iOS26(inside: point, with: event) } + else if #available(iOS 18, *) { return point_iOS18(inside: point, with: event) } + else { return point_iOS17(inside: point, with: event) } } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - if #available(iOS 26, *) { hitTest_iOS26(point, with: event) } - else if #available(iOS 18, *) { hitTest_iOS18(point, with: event) } - else { hitTest_iOS17(point, with: event) } + if #available(iOS 26, *) { return hitTest_iOS26(point, with: event) } + else if #available(iOS 18, *) { return hitTest_iOS18(point, with: event) } + else { return hitTest_iOS17(point, with: event) } } override func resignKey() { super.resignKey() @@ -130,24 +130,76 @@ private extension Window { // MARK: Hit Test private extension Window { + enum AnchoredHitTestResult { + case hit(UIView) // Touch inside AnchoredPopup + case passThrough // Pass through to underlying views + case block // Block touch + case noAnchoredPopup // No AnchoredPopup displayed + } + + func handleAnchoredPopupHitTest(_ point: CGPoint, with event: UIEvent?) -> AnchoredHitTestResult { + guard let container = AnchoredPopupsContainer.shared, !container.subviews.isEmpty else { + return .noAnchoredPopup + } + + let convertedPoint = convert(point, to: container) + if let hit = container.hitTest(convertedPoint, with: event) { + return .hit(hit) + } + + // Touch outside AnchoredPopup - check if pass-through is enabled + let anchoredPopups = PopupStackContainer.stacks.first?.popups.filter { $0.config.alignment == .anchored } + if let lastAnchored = anchoredPopups?.last, + lastAnchored.config.isTapOutsidePassThroughEnabled { + return .passThrough + } + return .block + } + @available(iOS 26, *) func hitTest_iOS26(_ point: CGPoint, with event: UIEvent?) -> UIView? { - guard let rootView = self.rootViewController?.view else { return nil } - guard let isDismissParameterEmpty = PopupStackContainer.stacks.first?.popups.last?.config.isTapOutsideToDismissEnabled else { return nil } - - let pointInRootView = self.convert(point, to: rootView) + switch handleAnchoredPopupHitTest(point, with: event) { + case .hit(let view): return view + case .passThrough: return nil + case .block: return rootViewController?.view + case .noAnchoredPopup: break + } + + // No AnchoredPopup displayed, use original logic (for BottomPopup, CenterPopup, etc.) + guard let rootView = rootViewController?.view else { return nil } + guard PopupStackContainer.stacks.first?.popups.last != nil else { return nil } + + let pointInRootView = convert(point, to: rootView) let hitView = rootView.hitTest(pointInRootView, with: event) let isTapOutsideToDismissEnabled = PopupStackContainer.stacks.first?.popups.last?.config.isTapOutsideToDismissEnabled ?? false - - if hitView == rootView || hitView == nil { return isTapOutsideToDismissEnabled ? rootView : hitView } + + if hitView == rootView || hitView == nil { + return isTapOutsideToDismissEnabled ? rootView : hitView + } return hitView } - + @available(iOS 18, *) - func hitTest_iOS18(_ point: CGPoint, with event: UIEvent?) -> UIView? { - super.hitTest(point, with: event) + func hitTest_iOS18(_ point: CGPoint, with event: UIEvent?) -> UIView? { + switch handleAnchoredPopupHitTest(point, with: event) { + case .hit(let view): return view + case .passThrough: return nil + case .block: return rootViewController?.view + case .noAnchoredPopup: break } + + guard let hit = super.hitTest(point, with: event) else { return nil } + return rootViewController?.view == hit ? nil : hit + } + func hitTest_iOS17(_ point: CGPoint, with event: UIEvent?) -> UIView? { + switch handleAnchoredPopupHitTest(point, with: event) { + case .hit(let view): return view + case .passThrough: return nil + case .block: return rootViewController?.view + case .noAnchoredPopup: break + } + guard let hit = super.hitTest(point, with: event) else { return nil } return rootViewController?.view == hit ? nil : hit } From 8c2643804334b2a8a989817f1559e9bfe25f7683 Mon Sep 17 00:00:00 2001 From: vidy Date: Wed, 17 Dec 2025 10:53:18 +0800 Subject: [PATCH 03/15] refactor(AnchoredPopup): use single UIHostingController and add customID support - Use single UIHostingController for all popups instead of one per popup - Add AnchoredPopupModel to manage popups, sizes, and frames - Use SwiftUI ZStack + offset() for positioning - Add sizeReader to auto-reposition on size changes - Fix hitTest to check actual popup frames - Fix handleAnchoredPopupHitTest to check anchored popups existence first - Add customID parameter to present(anchoredTo:) for same-type popup differentiation --- .../Internal/UI/PopupAnchoredStackView.swift | 171 ++++++++++++------ .../Public/Present/Public+Present+Popup.swift | 8 +- .../Setup/Public+Setup+SceneDelegate.swift | 11 +- 3 files changed, 127 insertions(+), 63 deletions(-) diff --git a/Sources/Internal/UI/PopupAnchoredStackView.swift b/Sources/Internal/UI/PopupAnchoredStackView.swift index deb0e0215..989c874be 100644 --- a/Sources/Internal/UI/PopupAnchoredStackView.swift +++ b/Sources/Internal/UI/PopupAnchoredStackView.swift @@ -9,82 +9,62 @@ import SwiftUI import UIKit -// MARK: - Anchored Popups Container (Added directly to Window, bypassing SwiftUI hierarchy) +// MARK: - Anchored Popups Container -/// UIKit container managing multiple popup UIHostingControllers -/// Added directly to Window, not through SwiftUI view hierarchy +/// UIKit container with single UIHostingController for all popups class AnchoredPopupsContainer: UIView { static var shared: AnchoredPopupsContainer? - private var popupViews: [String: UIView] = [:] - private var hostingControllers: [String: UIHostingController] = [:] + private var hostingController: UIHostingController? + private var popupModel = AnchoredPopupModel() - /// Returns false when touch is outside popup, allowing events to pass through to underlying views + /// Returns true only if touch is inside a popup frame override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { - for subview in subviews { - let convertedPoint = convert(point, to: subview) - if subview.point(inside: convertedPoint, with: event) { + for (_, frame) in popupModel.popupFrames { + if frame.contains(point) { return true } } return false } + /// Returns hit view only if touch is inside a popup frame, otherwise passes through override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - for subview in subviews.reversed() { - let convertedPoint = convert(point, to: subview) - if let hitView = subview.hitTest(convertedPoint, with: event) { - return hitView + for (_, frame) in popupModel.popupFrames { + if frame.contains(point) { + // Touch inside popup, let UIHostingController handle it + return hostingController?.view.hitTest(point, with: event) } } + // Touch outside all popups, pass through return nil } /// Updates popups in the container func updatePopups(_ popups: [AnyPopup], viewModel: VM.AnchoredStack) { + // Clean up frames for removed popups let currentIds = Set(popups.map { $0.id.rawValue }) - let existingIds = Set(popupViews.keys) - - // Remove popups that no longer exist + let existingIds = Set(popupModel.popupFrames.keys) for id in existingIds.subtracting(currentIds) { - popupViews[id]?.removeFromSuperview() - popupViews[id] = nil - hostingControllers[id] = nil + popupModel.popupFrames.removeValue(forKey: id) + popupModel.popupSizes.removeValue(forKey: id) } - // Add or update popups - for popup in popups { - let id = popup.id.rawValue - - if let existingView = popupViews[id] { - let actualSize = existingView.frame.size - let position = viewModel.calculatePopupPosition(for: popup, popupSize: actualSize) - var newFrame = existingView.frame - newFrame.origin = CGPoint(x: position.x, y: position.y) - existingView.frame = newFrame - } else { - let (hostingController, popupView) = createPopupView(for: popup, viewModel: viewModel) - popupView.sizeToFit() - let actualSize = popupView.frame.size - let position = viewModel.calculatePopupPosition(for: popup, popupSize: actualSize) - var frame = popupView.frame - frame.origin = CGPoint(x: position.x, y: position.y) - popupView.frame = frame - addSubview(popupView) - popupViews[id] = popupView - hostingControllers[id] = hostingController - } + popupModel.popups = popups + popupModel.viewModel = viewModel + + // Create hosting controller if needed + if hostingController == nil { + let containerView = AnchoredPopupContainerView(model: popupModel) + let hc = UIHostingController(rootView: AnyView(containerView)) + hc.view.frame = bounds + hc.view.backgroundColor = .clear + hc.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + addSubview(hc.view) + hostingController = hc } } - private func createPopupView(for popup: AnyPopup, viewModel: VM.AnchoredStack) -> (UIHostingController, UIView) { - let content = AnyView(PopupContentView(popup: popup, viewModel: viewModel)) - let hostingController = UIHostingController(rootView: content) - hostingController.view.backgroundColor = .clear - hostingController.view.translatesAutoresizingMaskIntoConstraints = true - return (hostingController, hostingController.view) - } - /// Installs container directly on Window (above rootViewController.view) static func install(on window: UIWindow) { guard shared == nil else { return } @@ -92,7 +72,6 @@ class AnchoredPopupsContainer: UIView { container.backgroundColor = .clear container.translatesAutoresizingMaskIntoConstraints = false - // Add directly to window, above rootViewController.view window.addSubview(container) NSLayoutConstraint.activate([ container.topAnchor.constraint(equalTo: window.topAnchor), @@ -100,24 +79,103 @@ class AnchoredPopupsContainer: UIView { container.leadingAnchor.constraint(equalTo: window.leadingAnchor), container.trailingAnchor.constraint(equalTo: window.trailingAnchor) ]) - // Force layout to apply constraints container.setNeedsLayout() window.layoutIfNeeded() shared = container } } +// MARK: - Popup Model (ObservableObject for SwiftUI) + +private class AnchoredPopupModel: ObservableObject { + @Published var popups: [AnyPopup] = [] + @Published var popupSizes: [String: CGSize] = [:] + @Published var popupFrames: [String: CGRect] = [:] // Store frame for hitTest + var viewModel: VM.AnchoredStack? +} + +// MARK: - SwiftUI Container View + +private struct AnchoredPopupContainerView: View { + @ObservedObject var model: AnchoredPopupModel + + var body: some View { + ZStack(alignment: .topLeading) { + ForEach(model.popups, id: \.self) { popup in + let popupId = popup.id.rawValue + let hasSize = model.popupSizes[popupId] != nil + + PopupContentView(popup: popup, viewModel: model.viewModel) + .opacity(hasSize ? 1 : 0) + .sizeReader { size in + if model.popupSizes[popupId] != size { + model.popupSizes[popupId] = size + } + } + .offset(popupOffset(for: popup)) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .edgesIgnoringSafeArea(.all) + } + + /// Calculate offset for popup positioning and store frame for hitTest + private func popupOffset(for popup: AnyPopup) -> CGSize { + let popupId = popup.id.rawValue + guard let size = model.popupSizes[popupId], let viewModel = model.viewModel else { + return .zero + } + + let position = viewModel.calculatePopupPosition(for: popup, popupSize: size) + + // Store frame for hitTest + let frame = CGRect(origin: position, size: size) + if model.popupFrames[popupId] != frame { + DispatchQueue.main.async { + self.model.popupFrames[popupId] = frame + } + } + + return CGSize(width: position.x, height: position.y) + } +} + /// SwiftUI content for a single popup private struct PopupContentView: View { let popup: AnyPopup - @ObservedObject var viewModel: VM.AnchoredStack + var viewModel: VM.AnchoredStack? var body: some View { popup.body .compositingGroup() - .fixedSize(horizontal: false, vertical: viewModel.activePopupProperties.verticalFixedSize) - .background(backgroundColor: popup.config.backgroundColor, overlayColor: .clear, corners: viewModel.activePopupProperties.corners) - .opacity(viewModel.calculateOpacity(for: popup)) + .fixedSize(horizontal: false, vertical: viewModel?.activePopupProperties.verticalFixedSize ?? true) + .background(backgroundColor: popup.config.backgroundColor, overlayColor: .clear, corners: viewModel?.activePopupProperties.corners ?? [:]) + .opacity(Double(viewModel?.calculateOpacity(for: popup) ?? 1)) + } +} + +// MARK: - Size Reader + +private extension View { + func sizeReader(size: @escaping (CGSize) -> Void) -> some View { + background( + GeometryReader { geometry in + Color.clear + .preference(key: PopupSizePreferenceKey.self, value: geometry.size) + .onPreferenceChange(PopupSizePreferenceKey.self) { newValue in + DispatchQueue.main.async { + size(newValue) + } + } + } + ) + } +} + +private struct PopupSizePreferenceKey: PreferenceKey { + static var defaultValue: CGSize { .zero } + static func reduce(value: inout CGSize, nextValue: () -> CGSize) { + value = nextValue() } } @@ -127,8 +185,6 @@ struct PopupAnchoredStackView: View { @ObservedObject var viewModel: VM.AnchoredStack var body: some View { - // InstallerView is only used to get window reference and install container - // AnchoredPopupsContainer is added directly to Window, not in SwiftUI hierarchy AnchoredStackInstaller(viewModel: viewModel) .frame(width: 1, height: 1) } @@ -175,4 +231,3 @@ private class InstallerView: UIView { } } } - diff --git a/Sources/Public/Present/Public+Present+Popup.swift b/Sources/Public/Present/Public+Present+Popup.swift index 829f6a935..12ba361b5 100644 --- a/Sources/Public/Present/Public+Present+Popup.swift +++ b/Sources/Public/Present/Public+Present+Popup.swift @@ -58,8 +58,12 @@ public extension AnchoredPopup { } ``` */ - @MainActor func present(anchoredTo anchorFrame: CGRect, popupStackID: PopupStackID = .shared) async { - let popup = await AnyPopup(self).updatedAnchorFrame(anchorFrame) + @MainActor func present(anchoredTo anchorFrame: CGRect, customID: String? = nil, popupStackID: PopupStackID = .shared) async { + var popup = await AnyPopup(self) + if let customID = customID { + popup = await popup.updatedID(customID) + } + popup = popup.updatedAnchorFrame(anchorFrame) await PopupStack.fetch(id: popupStackID)?.modify(.insertPopup(popup)) } } diff --git a/Sources/Public/Setup/Public+Setup+SceneDelegate.swift b/Sources/Public/Setup/Public+Setup+SceneDelegate.swift index 554000734..741cd8682 100644 --- a/Sources/Public/Setup/Public+Setup+SceneDelegate.swift +++ b/Sources/Public/Setup/Public+Setup+SceneDelegate.swift @@ -138,7 +138,13 @@ private extension Window { } func handleAnchoredPopupHitTest(_ point: CGPoint, with event: UIEvent?) -> AnchoredHitTestResult { - guard let container = AnchoredPopupsContainer.shared, !container.subviews.isEmpty else { + // Check if there are any anchored popups displayed + let anchoredPopups = PopupStackContainer.stacks.first?.popups.filter { $0.config.alignment == .anchored } ?? [] + guard !anchoredPopups.isEmpty else { + return .noAnchoredPopup + } + + guard let container = AnchoredPopupsContainer.shared else { return .noAnchoredPopup } @@ -148,8 +154,7 @@ private extension Window { } // Touch outside AnchoredPopup - check if pass-through is enabled - let anchoredPopups = PopupStackContainer.stacks.first?.popups.filter { $0.config.alignment == .anchored } - if let lastAnchored = anchoredPopups?.last, + if let lastAnchored = anchoredPopups.last, lastAnchored.config.isTapOutsidePassThroughEnabled { return .passThrough } From ab46fc04b51fb02be2bc81988970f124e5a6b368 Mon Sep 17 00:00:00 2001 From: vidy Date: Wed, 17 Dec 2025 11:09:12 +0800 Subject: [PATCH 04/15] fix(AnchoredPopup): allow other popups to receive touches when passThrough enabled - Add hasNonAnchoredPopups() to check for BottomPopup/CenterPopup presence - When AnchoredPopup has tapOutsidePassThrough enabled: - If no other popups exist: pass through to app (original behavior) - If other popups exist: continue hitTest to let them handle touches - Fixes issue where BottomPopup was unresponsive when AnchoredPopup was displayed --- .../Setup/Public+Setup+SceneDelegate.swift | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/Sources/Public/Setup/Public+Setup+SceneDelegate.swift b/Sources/Public/Setup/Public+Setup+SceneDelegate.swift index 741cd8682..2683f018e 100644 --- a/Sources/Public/Setup/Public+Setup+SceneDelegate.swift +++ b/Sources/Public/Setup/Public+Setup+SceneDelegate.swift @@ -161,16 +161,25 @@ private extension Window { return .block } + /// Check if there are non-anchored popups (BottomPopup, CenterPopup, etc.) + func hasNonAnchoredPopups() -> Bool { + let nonAnchoredPopups = PopupStackContainer.stacks.first?.popups.filter { $0.config.alignment != .anchored } ?? [] + return !nonAnchoredPopups.isEmpty + } + @available(iOS 26, *) func hitTest_iOS26(_ point: CGPoint, with event: UIEvent?) -> UIView? { switch handleAnchoredPopupHitTest(point, with: event) { case .hit(let view): return view - case .passThrough: return nil + case .passThrough: + // If no other popups, pass through to app + if !hasNonAnchoredPopups() { return nil } + // Otherwise continue to check other popups case .block: return rootViewController?.view case .noAnchoredPopup: break } - // No AnchoredPopup displayed, use original logic (for BottomPopup, CenterPopup, etc.) + // Check other popups (BottomPopup, CenterPopup, etc.) guard let rootView = rootViewController?.view else { return nil } guard PopupStackContainer.stacks.first?.popups.last != nil else { return nil } @@ -188,7 +197,10 @@ private extension Window { func hitTest_iOS18(_ point: CGPoint, with event: UIEvent?) -> UIView? { switch handleAnchoredPopupHitTest(point, with: event) { case .hit(let view): return view - case .passThrough: return nil + case .passThrough: + // If no other popups, pass through to app + if !hasNonAnchoredPopups() { return nil } + // Otherwise continue to check other popups case .block: return rootViewController?.view case .noAnchoredPopup: break } @@ -200,7 +212,10 @@ private extension Window { func hitTest_iOS17(_ point: CGPoint, with event: UIEvent?) -> UIView? { switch handleAnchoredPopupHitTest(point, with: event) { case .hit(let view): return view - case .passThrough: return nil + case .passThrough: + // If no other popups, pass through to app + if !hasNonAnchoredPopups() { return nil } + // Otherwise continue to check other popups case .block: return rootViewController?.view case .noAnchoredPopup: break } From 96835a9d68d8e54088b031cadb6d2ddb24453b00 Mon Sep 17 00:00:00 2001 From: vidy Date: Wed, 17 Dec 2025 11:55:25 +0800 Subject: [PATCH 05/15] fix: overlay no longer covers popup content during priority update delay When closing a popup and immediately opening another, priority update was delayed by Animation.duration, causing overlay (fixed zIndex=1) to appear above the new popup content. Changed overlay zIndex from fixed `1` to dynamic `(values.min() ?? 0) - 1`, ensuring overlay always stays below all popup stacks regardless of timing. --- Sources/Internal/Models/StackPriority.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Internal/Models/StackPriority.swift b/Sources/Internal/Models/StackPriority.swift index 43ca360bd..29cd9d4df 100644 --- a/Sources/Internal/Models/StackPriority.swift +++ b/Sources/Internal/Models/StackPriority.swift @@ -16,7 +16,7 @@ struct StackPriority: Equatable, Sendable { var center: CGFloat { values[1] } var bottom: CGFloat { values[2] } var anchored: CGFloat { values[3] } - var overlay: CGFloat { 1 } + var overlay: CGFloat { (values.min() ?? 0) - 1 } private var values: [CGFloat] = [0, 0, 0, 0] } From 5585421b408eebb71e0acb171729e8feb762c7ef Mon Sep 17 00:00:00 2001 From: vidy Date: Wed, 17 Dec 2025 12:22:26 +0800 Subject: [PATCH 06/15] feat: add dismissAllPopups(excluding:) method Add ability to dismiss all popups except those with specified IDs. - Add removeAllPopupsExcluding case to StackOperation - Add removedAllPopupsExcluding implementation in PopupStack - Add public API dismissAllPopups(excluding:) in PopupStack and View extension This allows keeping specific popups open while closing others. --- Sources/Internal/Containers/PopupStack.swift | 8 +++++++- .../Public/Dismiss/Public+Dismiss+PopupStack.swift | 11 +++++++++++ Sources/Public/Dismiss/Public+Dismiss+View.swift | 11 +++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/Sources/Internal/Containers/PopupStack.swift b/Sources/Internal/Containers/PopupStack.swift index 554e67168..d55c642ee 100644 --- a/Sources/Internal/Containers/PopupStack.swift +++ b/Sources/Internal/Containers/PopupStack.swift @@ -34,7 +34,7 @@ extension PopupStack { // MARK: Modify extension PopupStack { enum StackOperation { case insertPopup(AnyPopup) - case removeLastPopup, removePopup(AnyPopup), removeAllPopupsOfType(any Popup.Type), removeAllPopupsWithID(String), removeAllPopups + case removeLastPopup, removePopup(AnyPopup), removeAllPopupsOfType(any Popup.Type), removeAllPopupsWithID(String), removeAllPopups, removeAllPopupsExcluding([String]) }} extension PopupStack { func modify(_ operation: StackOperation) { Task { @@ -67,6 +67,7 @@ private extension PopupStack { case .removeAllPopupsOfType(let popupType): await removedAllPopupsOfType(popupType) case .removeAllPopupsWithID(let id): await removedAllPopupsWithID(id) case .removeAllPopups: await removedAllPopups() + case .removeAllPopupsExcluding(let excludedIDs): await removedAllPopupsExcluding(excludedIDs) }} nonisolated func getNewPriority(_ newPopups: [AnyPopup]) async -> StackPriority { await priority.reshuffled(newPopups) @@ -100,6 +101,11 @@ private extension PopupStack { nonisolated func removedAllPopups() async -> [AnyPopup] { [] } + nonisolated func removedAllPopupsExcluding(_ excludedIDs: [String]) async -> [AnyPopup] { + await popups.filter { popup in + excludedIDs.contains { popup.id.isSameType(as: $0) } + } + } } diff --git a/Sources/Public/Dismiss/Public+Dismiss+PopupStack.swift b/Sources/Public/Dismiss/Public+Dismiss+PopupStack.swift index b89ed73c3..0041a156f 100644 --- a/Sources/Public/Dismiss/Public+Dismiss+PopupStack.swift +++ b/Sources/Public/Dismiss/Public+Dismiss+PopupStack.swift @@ -54,4 +54,15 @@ public extension PopupStack { - Important: Make sure you use the correct **popupStackID** from which you want to remove the popups. */ @MainActor static func dismissAllPopups(popupStackID: PopupStackID = .shared) async { await fetch(id: popupStackID)?.modify(.removeAllPopups) } + + /** + Dismisses all the popups except those with the specified identifiers. + + - Parameters: + - ids: Identifiers of the popups to keep on the stack. + - popupStackID: The identifier for which the popup was presented. For more information, see ``Popup/present(popupStackID:)``. + + - Important: Make sure you use the correct **popupStackID** from which you want to remove the popups. + */ + @MainActor static func dismissAllPopups(excluding ids: [String], popupStackID: PopupStackID = .shared) async { await fetch(id: popupStackID)?.modify(.removeAllPopupsExcluding(ids)) } } diff --git a/Sources/Public/Dismiss/Public+Dismiss+View.swift b/Sources/Public/Dismiss/Public+Dismiss+View.swift index da812635b..40ec5d997 100644 --- a/Sources/Public/Dismiss/Public+Dismiss+View.swift +++ b/Sources/Public/Dismiss/Public+Dismiss+View.swift @@ -54,4 +54,15 @@ public extension View { - Important: Make sure you use the correct **popupStackID** from which you want to remove the popups. */ @MainActor func dismissAllPopups(popupStackID: PopupStackID = .shared) async { await PopupStack.dismissAllPopups(popupStackID: popupStackID) } + + /** + Dismisses all the popups except those with the specified identifiers. + + - Parameters: + - ids: Identifiers of the popups to keep on the stack. + - popupStackID: The identifier for which the popup was presented. For more information, see ``Popup/present(popupStackID:)``. + + - Important: Make sure you use the correct **popupStackID** from which you want to remove the popups. + */ + @MainActor func dismissAllPopups(excluding ids: [String], popupStackID: PopupStackID = .shared) async { await PopupStack.dismissAllPopups(excluding: ids, popupStackID: popupStackID) } } From 62b6af8352a9d46dccc524a9b85882033cf4611d Mon Sep 17 00:00:00 2001 From: vidy Date: Wed, 17 Dec 2025 17:32:32 +0800 Subject: [PATCH 07/15] 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 }) --- .../Local/LocalConfig+Anchored.swift | 12 +++ Sources/Internal/Models/AnyPopup.swift | 2 +- Sources/Internal/Models/AnyPopupConfig.swift | 19 +++- .../Internal/UI/PopupAnchoredStackView.swift | 95 +++++++++++-------- Sources/Internal/UI/PopupView.swift | 3 +- .../View Models/ViewModel+AnchoredStack.swift | 63 +++++++++++- .../Public/Present/Public+Present+Popup.swift | 13 ++- 7 files changed, 153 insertions(+), 54 deletions(-) diff --git a/Sources/Internal/Configurables/Local/LocalConfig+Anchored.swift b/Sources/Internal/Configurables/Local/LocalConfig+Anchored.swift index f317f5255..404499704 100644 --- a/Sources/Internal/Configurables/Local/LocalConfig+Anchored.swift +++ b/Sources/Internal/Configurables/Local/LocalConfig+Anchored.swift @@ -22,6 +22,8 @@ public class LocalConfigAnchored: LocalConfig { required public init() {} public var popupAnchor: PopupAnchorPoint = .top public var offset: CGPoint = .zero public var isTapOutsidePassThroughEnabled: Bool = false + public var edgePadding: CGFloat = 16 + public var constrainedEdges: Edge.Set = .horizontal // MARK: Inactive Variables (inherited from LocalConfig protocol) public var ignoredSafeAreaEdges: Edge.Set = [] @@ -48,6 +50,16 @@ public extension LocalConfigAnchored { /// Enables touch pass-through when tapping outside the popup /// When enabled, touches outside the popup are passed to underlying views instead of being blocked func tapOutsidePassThrough(_ enabled: Bool) -> Self { self.isTapOutsidePassThroughEnabled = enabled; return self } + + /// Configures edge padding and which edges to constrain + /// - Parameters: + /// - value: Padding value from screen edges + /// - edges: Which edges to constrain (.horizontal, .vertical, .all, or [] to disable) + func edgePadding(_ value: CGFloat, edges: Edge.Set = .horizontal) -> Self { + self.edgePadding = value + self.constrainedEdges = edges + return self + } } // MARK: - Public Type Alias diff --git a/Sources/Internal/Models/AnyPopup.swift b/Sources/Internal/Models/AnyPopup.swift index 58e0d0faa..de3dd49a4 100644 --- a/Sources/Internal/Models/AnyPopup.swift +++ b/Sources/Internal/Models/AnyPopup.swift @@ -61,7 +61,7 @@ extension AnyPopup { func updatedEnvironmentObject(_ environmentObject: some ObservableObject) -> AnyPopup { updated { $0._body = .init(_body.environmentObject(environmentObject)) }} func updatedKeyboardDismissal(_ shouldDismiss: Bool) -> AnyPopup { updated { $0.shouldDismissKeyboardOnPopupToggle = shouldDismiss }} func startDismissTimerIfNeeded(_ popupStack: PopupStack) -> AnyPopup { updated { $0._dismissTimer?.schedule { popupStack.modify(.removePopup(self)) }}} - func updatedAnchorFrame(_ frame: CGRect) -> AnyPopup { updated { $0.config.anchorFrame = frame }} + func updatedAnchorFrameProvider(_ provider: @escaping () -> CGRect) -> AnyPopup { updated { $0.config.anchorFrameProvider = .init(closure: provider) }} } private extension AnyPopup { func updated(_ customBuilder: (inout AnyPopup) -> ()) -> AnyPopup { diff --git a/Sources/Internal/Models/AnyPopupConfig.swift b/Sources/Internal/Models/AnyPopupConfig.swift index b3531816c..2cd995f8a 100644 --- a/Sources/Internal/Models/AnyPopupConfig.swift +++ b/Sources/Internal/Models/AnyPopupConfig.swift @@ -11,6 +11,18 @@ import SwiftUI +/// Wrapper to bypass Sendable compiler checks for anchor frame closure. +/// +/// This is safe because: +/// 1. The closure only captures @State variables which are main-thread isolated in SwiftUI +/// 2. The closure is only called from UI layer (main thread) in calculatePopupPosition() +/// 3. No actual cross-thread access occurs - the Sendable requirement is only for +/// passing AnyPopup through async boundaries, not for concurrent execution +struct AnchorFrameProvider: @unchecked Sendable { + let closure: () -> CGRect + func callAsFunction() -> CGRect { closure() } +} + struct AnyPopupConfig: LocalConfig, Sendable { init() {} // MARK: Content var alignment: PopupAlignment = .center @@ -28,11 +40,14 @@ struct AnyPopupConfig: LocalConfig, Sendable { init() {} var dragGestureAreaSize: CGFloat = 0 // MARK: Anchored-specific - var anchorFrame: CGRect = .zero + var anchorFrameProvider: AnchorFrameProvider? = nil + var anchorFrame: CGRect { anchorFrameProvider?() ?? .zero } var originAnchor: PopupAnchorPoint = .bottom var popupAnchor: PopupAnchorPoint = .top var anchorOffset: CGPoint = .zero var isTapOutsidePassThroughEnabled: Bool = false + var edgePadding: CGFloat = 16 + var constrainedEdges: Edge.Set = .horizontal } // MARK: Initialize @@ -56,6 +71,8 @@ extension AnyPopupConfig { self.popupAnchor = anchoredConfig.popupAnchor self.anchorOffset = anchoredConfig.offset self.isTapOutsidePassThroughEnabled = anchoredConfig.isTapOutsidePassThroughEnabled + self.edgePadding = anchoredConfig.edgePadding + self.constrainedEdges = anchoredConfig.constrainedEdges } } } diff --git a/Sources/Internal/UI/PopupAnchoredStackView.swift b/Sources/Internal/UI/PopupAnchoredStackView.swift index 989c874be..00b412492 100644 --- a/Sources/Internal/UI/PopupAnchoredStackView.swift +++ b/Sources/Internal/UI/PopupAnchoredStackView.swift @@ -17,11 +17,23 @@ class AnchoredPopupsContainer: UIView { private var hostingController: UIHostingController? private var popupModel = AnchoredPopupModel() + private var lastBoundsSize: CGSize = .zero + + override func layoutSubviews() { + super.layoutSubviews() + guard bounds.size != lastBoundsSize else { return } + lastBoundsSize = bounds.size + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.popupModel.containerSize = self.bounds.size + } + } /// Returns true only if touch is inside a popup frame override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { - for (_, frame) in popupModel.popupFrames { - if frame.contains(point) { + for popup in popupModel.popups { + if let frame = popupModel.frame(for: popup), frame.contains(point) { return true } } @@ -30,30 +42,17 @@ class AnchoredPopupsContainer: UIView { /// Returns hit view only if touch is inside a popup frame, otherwise passes through override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - for (_, frame) in popupModel.popupFrames { - if frame.contains(point) { - // Touch inside popup, let UIHostingController handle it + for popup in popupModel.popups { + if let frame = popupModel.frame(for: popup), frame.contains(point) { return hostingController?.view.hitTest(point, with: event) } } - // Touch outside all popups, pass through return nil } /// Updates popups in the container func updatePopups(_ popups: [AnyPopup], viewModel: VM.AnchoredStack) { - // Clean up frames for removed popups - let currentIds = Set(popups.map { $0.id.rawValue }) - let existingIds = Set(popupModel.popupFrames.keys) - for id in existingIds.subtracting(currentIds) { - popupModel.popupFrames.removeValue(forKey: id) - popupModel.popupSizes.removeValue(forKey: id) - } - - popupModel.popups = popups - popupModel.viewModel = viewModel - - // Create hosting controller if needed + // Create hosting controller if needed (sync, only once) if hostingController == nil { let containerView = AnchoredPopupContainerView(model: popupModel) let hc = UIHostingController(rootView: AnyView(containerView)) @@ -63,6 +62,20 @@ class AnchoredPopupsContainer: UIView { addSubview(hc.view) hostingController = hc } + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + + // Clean up sizes for removed popups + let currentIds = Set(popups.map { $0.id.rawValue }) + let existingIds = Set(self.popupModel.popupSizes.keys) + for id in existingIds.subtracting(currentIds) { + self.popupModel.popupSizes.removeValue(forKey: id) + } + + self.popupModel.popups = popups + self.popupModel.viewModel = viewModel + } } /// Installs container directly on Window (above rootViewController.view) @@ -87,11 +100,26 @@ class AnchoredPopupsContainer: UIView { // MARK: - Popup Model (ObservableObject for SwiftUI) +@MainActor private class AnchoredPopupModel: ObservableObject { @Published var popups: [AnyPopup] = [] @Published var popupSizes: [String: CGSize] = [:] - @Published var popupFrames: [String: CGRect] = [:] // Store frame for hitTest + @Published var containerSize: CGSize = .zero var viewModel: VM.AnchoredStack? + + /// Calculate frame for popup (called during render, not stored) + func frame(for popup: AnyPopup) -> CGRect? { + let popupId = popup.id.rawValue + guard let size = popupSizes[popupId], + let viewModel = viewModel else { return nil } + + let position = viewModel.calculatePopupPosition( + for: popup, + popupSize: size, + containerSize: containerSize + ) + return CGRect(origin: position, size: size) + } } // MARK: - SwiftUI Container View @@ -100,44 +128,27 @@ private struct AnchoredPopupContainerView: View { @ObservedObject var model: AnchoredPopupModel var body: some View { + // Reference containerSize to trigger re-render when it changes + let _ = model.containerSize + ZStack(alignment: .topLeading) { ForEach(model.popups, id: \.self) { popup in let popupId = popup.id.rawValue - let hasSize = model.popupSizes[popupId] != nil + let frame = model.frame(for: popup) PopupContentView(popup: popup, viewModel: model.viewModel) - .opacity(hasSize ? 1 : 0) + .opacity(frame != nil ? 1 : 0) .sizeReader { size in if model.popupSizes[popupId] != size { model.popupSizes[popupId] = size } } - .offset(popupOffset(for: popup)) + .offset(x: frame?.origin.x ?? 0, y: frame?.origin.y ?? 0) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .edgesIgnoringSafeArea(.all) } - - /// Calculate offset for popup positioning and store frame for hitTest - private func popupOffset(for popup: AnyPopup) -> CGSize { - let popupId = popup.id.rawValue - guard let size = model.popupSizes[popupId], let viewModel = model.viewModel else { - return .zero - } - - let position = viewModel.calculatePopupPosition(for: popup, popupSize: size) - - // Store frame for hitTest - let frame = CGRect(origin: position, size: size) - if model.popupFrames[popupId] != frame { - DispatchQueue.main.async { - self.model.popupFrames[popupId] = frame - } - } - - return CGSize(width: position.x, height: position.y) - } } /// SwiftUI content for a single popup diff --git a/Sources/Internal/UI/PopupView.swift b/Sources/Internal/UI/PopupView.swift index 59095fff4..6b11c0912 100644 --- a/Sources/Internal/UI/PopupView.swift +++ b/Sources/Internal/UI/PopupView.swift @@ -96,7 +96,8 @@ private extension PopupView { await updateViewModels { $0.setup(updatePopupAction: updatePopup, closePopupAction: closePopup) } }} func onScreenChange(_ screenReader: GeometryProxy) { Task { - await updateViewModels { await $0.updateScreen(screenHeight: screenReader.size.height + screenReader.safeAreaInsets.top + screenReader.safeAreaInsets.bottom, screenSafeArea: screenReader.safeAreaInsets) } + let screenHeight = screenReader.size.height + screenReader.safeAreaInsets.top + screenReader.safeAreaInsets.bottom + await updateViewModels { await $0.updateScreen(screenHeight: screenHeight, screenSafeArea: screenReader.safeAreaInsets) } }} func onPopupsHeightChange(_ p: Any) { Task { await updateViewModels { await $0.updatePopups(stack.popups) } diff --git a/Sources/Internal/View Models/ViewModel+AnchoredStack.swift b/Sources/Internal/View Models/ViewModel+AnchoredStack.swift index e47be0c64..e3aeec2fe 100644 --- a/Sources/Internal/View Models/ViewModel+AnchoredStack.swift +++ b/Sources/Internal/View Models/ViewModel+AnchoredStack.swift @@ -92,7 +92,7 @@ extension VM.AnchoredStack { // MARK: Position Calculation extension VM.AnchoredStack { /// Calculates the position for the popup based on anchor frame and anchor points - func calculatePopupPosition(for popup: AnyPopup, popupSize: CGSize) -> CGPoint { + func calculatePopupPosition(for popup: AnyPopup, popupSize: CGSize, containerSize: CGSize = .zero) -> CGPoint { let config = popup.config let anchorFrame = config.anchorFrame @@ -102,11 +102,66 @@ extension VM.AnchoredStack { // Calculate the offset needed based on popup anchor point let popupOffset = calculatePopupOffset(for: config.popupAnchor, popupSize: popupSize) - // Final position = origin point - popup offset + user offset - return CGPoint( + // Initial position + var popoverFrame = CGRect( x: originPoint.x - popupOffset.x + config.anchorOffset.x, - y: originPoint.y - popupOffset.y + config.anchorOffset.y + y: originPoint.y - popupOffset.y + config.anchorOffset.y, + width: popupSize.width, + height: popupSize.height ) + + // Apply boundary avoidance + popoverFrame = applyBoundaryAvoidance(frame: popoverFrame, config: config, screenSize: containerSize) + + return popoverFrame.origin + } + + /// Keeps popup within screen bounds based on configured edge constraints + private func applyBoundaryAvoidance(frame: CGRect, config: AnyPopupConfig, screenSize: CGSize) -> CGRect { + let edges = config.constrainedEdges + + // No constraints, return original frame + guard !edges.isEmpty else { return frame } + + // Skip if screen size not initialized + guard screenSize.width > 0, screenSize.height > 0 else { return frame } + + var popoverFrame = frame + let padding = config.edgePadding + + // Horizontal constraint + if edges.contains(.horizontal) { + let minX = screen.safeArea.leading + padding + let maxX = screenSize.width - screen.safeArea.trailing - padding + + // Left edge overflow + if popoverFrame.origin.x < minX { + popoverFrame.origin.x = minX + } + // Right edge overflow + if popoverFrame.maxX > maxX { + let difference = popoverFrame.maxX - maxX + popoverFrame.origin.x -= difference + } + } + + // Vertical constraint + if edges.contains(.vertical) { + let minY = screen.safeArea.top + padding + let maxY = screenSize.height - screen.safeArea.bottom - padding + + // Top edge overflow + if popoverFrame.origin.y < minY { + popoverFrame.origin.y = minY + } + // Bottom edge overflow + if popoverFrame.maxY > maxY { + let difference = popoverFrame.maxY - maxY + popoverFrame.origin.y -= difference + } + } + + return popoverFrame } /// Calculates a point on the anchor frame based on the anchor point type diff --git a/Sources/Public/Present/Public+Present+Popup.swift b/Sources/Public/Present/Public+Present+Popup.swift index 12ba361b5..164db20a4 100644 --- a/Sources/Public/Present/Public+Present+Popup.swift +++ b/Sources/Public/Present/Public+Present+Popup.swift @@ -41,29 +41,32 @@ public extension AnchoredPopup { Presents the anchored popup positioned relative to a source view frame. - Parameters: - - anchorFrame: The frame of the source view to anchor the popup to (in global coordinates). + - anchorFrameProvider: A closure that returns the frame of the source view (in global coordinates). + The closure is called each time the popup position needs to be recalculated. + - customID: Optional custom identifier for the popup. - popupStackID: The identifier registered in one of the application windows in which the popup is to be displayed. - - Important: The **anchorFrame** should be in global screen coordinates. Use `.frameReader` or `GeometryReader` to obtain the frame. + - Important: The frame returned by **anchorFrameProvider** should be in global screen coordinates. + Use `.frameReader` or `GeometryReader` to obtain the frame. ## Usage ```swift @State private var buttonFrame: CGRect = .zero Button("Show") { - Task { await MyAnchoredPopup().present(anchoredTo: buttonFrame) } + Task { await MyAnchoredPopup().present(anchoredTo: { buttonFrame }) } } .frameReader { frame in buttonFrame = frame } ``` */ - @MainActor func present(anchoredTo anchorFrame: CGRect, customID: String? = nil, popupStackID: PopupStackID = .shared) async { + @MainActor func present(anchoredTo anchorFrameProvider: @escaping () -> CGRect, customID: String? = nil, popupStackID: PopupStackID = .shared) async { var popup = await AnyPopup(self) if let customID = customID { popup = await popup.updatedID(customID) } - popup = popup.updatedAnchorFrame(anchorFrame) + popup = popup.updatedAnchorFrameProvider(anchorFrameProvider) await PopupStack.fetch(id: popupStackID)?.modify(.insertPopup(popup)) } } From 4f71e3111c5e4ce9de86543f6a2cbd69a7c701cc Mon Sep 17 00:00:00 2001 From: vidy Date: Wed, 17 Dec 2025 19:53:32 +0800 Subject: [PATCH 08/15] feat(AnchoredPopup): Add trackAnchor API for simplified popup positioning - Add AnchorRegistry for global frame storage - Add .trackAnchor() modifier to track view frames - Add present(anchoredTo: String) for registry-based positioning - Add present(anchoredTo: CGRect) for static frame positioning - Separate anchorID (positioning) from customID (dismiss management) - Auto-cleanup registry on view disappear --- Sources/Internal/Models/AnyPopup.swift | 3 +- Sources/Internal/Models/AnyPopupConfig.swift | 25 ++++--- .../View Models/ViewModel+AnchoredStack.swift | 2 +- .../Public/Present/Public+Present+Popup.swift | 65 ++++++++++++++----- Sources/Public/Setup/AnchorRegistry.swift | 31 +++++++++ Sources/Public/Setup/View+TrackAnchor.swift | 42 ++++++++++++ 6 files changed, 135 insertions(+), 33 deletions(-) create mode 100644 Sources/Public/Setup/AnchorRegistry.swift create mode 100644 Sources/Public/Setup/View+TrackAnchor.swift diff --git a/Sources/Internal/Models/AnyPopup.swift b/Sources/Internal/Models/AnyPopup.swift index de3dd49a4..5587b02f7 100644 --- a/Sources/Internal/Models/AnyPopup.swift +++ b/Sources/Internal/Models/AnyPopup.swift @@ -61,7 +61,8 @@ extension AnyPopup { func updatedEnvironmentObject(_ environmentObject: some ObservableObject) -> AnyPopup { updated { $0._body = .init(_body.environmentObject(environmentObject)) }} func updatedKeyboardDismissal(_ shouldDismiss: Bool) -> AnyPopup { updated { $0.shouldDismissKeyboardOnPopupToggle = shouldDismiss }} func startDismissTimerIfNeeded(_ popupStack: PopupStack) -> AnyPopup { updated { $0._dismissTimer?.schedule { popupStack.modify(.removePopup(self)) }}} - func updatedAnchorFrameProvider(_ provider: @escaping () -> CGRect) -> AnyPopup { updated { $0.config.anchorFrameProvider = .init(closure: provider) }} + func updatedAnchorID(_ anchorID: String) -> AnyPopup { updated { $0.config.anchorID = anchorID }} + func updatedAnchorFrame(_ frame: CGRect) -> AnyPopup { updated { $0.config.anchorFrame = frame }} } private extension AnyPopup { func updated(_ customBuilder: (inout AnyPopup) -> ()) -> AnyPopup { diff --git a/Sources/Internal/Models/AnyPopupConfig.swift b/Sources/Internal/Models/AnyPopupConfig.swift index 2cd995f8a..87a731349 100644 --- a/Sources/Internal/Models/AnyPopupConfig.swift +++ b/Sources/Internal/Models/AnyPopupConfig.swift @@ -11,18 +11,6 @@ import SwiftUI -/// Wrapper to bypass Sendable compiler checks for anchor frame closure. -/// -/// This is safe because: -/// 1. The closure only captures @State variables which are main-thread isolated in SwiftUI -/// 2. The closure is only called from UI layer (main thread) in calculatePopupPosition() -/// 3. No actual cross-thread access occurs - the Sendable requirement is only for -/// passing AnyPopup through async boundaries, not for concurrent execution -struct AnchorFrameProvider: @unchecked Sendable { - let closure: () -> CGRect - func callAsFunction() -> CGRect { closure() } -} - struct AnyPopupConfig: LocalConfig, Sendable { init() {} // MARK: Content var alignment: PopupAlignment = .center @@ -40,8 +28,17 @@ struct AnyPopupConfig: LocalConfig, Sendable { init() {} var dragGestureAreaSize: CGFloat = 0 // MARK: Anchored-specific - var anchorFrameProvider: AnchorFrameProvider? = nil - var anchorFrame: CGRect { anchorFrameProvider?() ?? .zero } + var anchorID: String? = nil // ID for AnchorRegistry lookup + var anchorFrame: CGRect? = nil // Static anchor frame (alternative to anchorID) + /// Returns the anchor frame - uses static frame if set, otherwise fetches from registry + func getAnchorFrame() -> CGRect { + if let staticFrame = anchorFrame { + return staticFrame + } else if let anchorID = anchorID { + return AnchorRegistry.frame(forKey: anchorID) + } + return .zero + } var originAnchor: PopupAnchorPoint = .bottom var popupAnchor: PopupAnchorPoint = .top var anchorOffset: CGPoint = .zero diff --git a/Sources/Internal/View Models/ViewModel+AnchoredStack.swift b/Sources/Internal/View Models/ViewModel+AnchoredStack.swift index e3aeec2fe..92b316563 100644 --- a/Sources/Internal/View Models/ViewModel+AnchoredStack.swift +++ b/Sources/Internal/View Models/ViewModel+AnchoredStack.swift @@ -94,7 +94,7 @@ extension VM.AnchoredStack { /// Calculates the position for the popup based on anchor frame and anchor points func calculatePopupPosition(for popup: AnyPopup, popupSize: CGSize, containerSize: CGSize = .zero) -> CGPoint { let config = popup.config - let anchorFrame = config.anchorFrame + let anchorFrame = config.getAnchorFrame() // Calculate origin point on the anchor view let originPoint = calculateAnchorPoint(for: config.originAnchor, in: anchorFrame) diff --git a/Sources/Public/Present/Public+Present+Popup.swift b/Sources/Public/Present/Public+Present+Popup.swift index 164db20a4..2913b33a0 100644 --- a/Sources/Public/Present/Public+Present+Popup.swift +++ b/Sources/Public/Present/Public+Present+Popup.swift @@ -38,35 +38,66 @@ public extension Popup { // MARK: Anchored Popup Present public extension AnchoredPopup { /** - Presents the anchored popup positioned relative to a source view frame. + Presents the anchored popup positioned relative to a tracked anchor view. + + Use this with `.trackAnchor(_:)` modifier to track the source view's frame. - Parameters: - - anchorFrameProvider: A closure that returns the frame of the source view (in global coordinates). - The closure is called each time the popup position needs to be recalculated. - - customID: Optional custom identifier for the popup. - - popupStackID: The identifier registered in one of the application windows in which the popup is to be displayed. + - anchorID: The ID used in `.trackAnchor(_:)` on the source view. + - customID: Optional custom identifier for the popup (for dismiss management). Defaults to anchorID. + - popupStackID: The identifier registered in one of the application windows. + + ## Usage + ```swift + // Track the button + Button("Pen") { ... } + .trackAnchor("pen") + + // Present popup anchored to it + MyPopup().present(anchoredTo: "pen") + + // With custom ID for dismiss management + MyPopup().present(anchoredTo: "pen", customID: "penPopup") + ``` + */ + @MainActor func present( + anchoredTo anchorID: String, + customID: String? = nil, + popupStackID: PopupStackID = .shared + ) async { + var popup = await AnyPopup(self) + // Use customID if provided, otherwise default to anchorID + popup = await popup.updatedID(customID ?? anchorID) + popup = popup.updatedAnchorID(anchorID) + await PopupStack.fetch(id: popupStackID)?.modify(.insertPopup(popup)) + makePopupWindowKey() + } - - Important: The frame returned by **anchorFrameProvider** should be in global screen coordinates. - Use `.frameReader` or `GeometryReader` to obtain the frame. + /** + Presents the anchored popup positioned relative to a static anchor frame. + + Use this for dynamic scenarios where you have a computed frame (e.g., list items). + + - Parameters: + - anchorFrame: The frame of the anchor view in global coordinates. + - customID: Optional custom identifier for the popup (for dismiss management). + - popupStackID: The identifier registered in one of the application windows. ## Usage ```swift - @State private var buttonFrame: CGRect = .zero - - Button("Show") { - Task { await MyAnchoredPopup().present(anchoredTo: { buttonFrame }) } - } - .frameReader { frame in - buttonFrame = frame - } + MyPopup().present(anchoredTo: buttonFrame, customID: "menu") ``` */ - @MainActor func present(anchoredTo anchorFrameProvider: @escaping () -> CGRect, customID: String? = nil, popupStackID: PopupStackID = .shared) async { + @MainActor func present( + anchoredTo anchorFrame: CGRect, + customID: String? = nil, + popupStackID: PopupStackID = .shared + ) async { var popup = await AnyPopup(self) if let customID = customID { popup = await popup.updatedID(customID) } - popup = popup.updatedAnchorFrameProvider(anchorFrameProvider) + popup = popup.updatedAnchorFrame(anchorFrame) await PopupStack.fetch(id: popupStackID)?.modify(.insertPopup(popup)) } } diff --git a/Sources/Public/Setup/AnchorRegistry.swift b/Sources/Public/Setup/AnchorRegistry.swift new file mode 100644 index 000000000..f473be630 --- /dev/null +++ b/Sources/Public/Setup/AnchorRegistry.swift @@ -0,0 +1,31 @@ +// +// AnchorRegistry.swift of MijickPopups +// +// Created by Vidy. Global anchor frame registry for anchored popups. +// +// Copyright 2024 Mijick. All rights reserved. + + +import SwiftUI + +/// Global registry for anchor frames, keyed by string ID. +/// Used internally by `trackAnchor(_:)` and `present(anchoredTo:)`. +@MainActor +public enum AnchorRegistry { + private static var frames: [String: CGRect] = [:] + + /// Register or update an anchor frame + public static func setFrame(_ frame: CGRect, forKey key: String) { + frames[key] = frame + } + + /// Get anchor frame by key + public static func frame(forKey key: String) -> CGRect { + frames[key] ?? .zero + } + + /// Remove anchor frame + public static func removeFrame(forKey key: String) { + frames.removeValue(forKey: key) + } +} diff --git a/Sources/Public/Setup/View+TrackAnchor.swift b/Sources/Public/Setup/View+TrackAnchor.swift new file mode 100644 index 000000000..baef99199 --- /dev/null +++ b/Sources/Public/Setup/View+TrackAnchor.swift @@ -0,0 +1,42 @@ +// +// View+TrackAnchor.swift of MijickPopups +// +// Created by Vidy. Track view frames for anchored popups. +// +// Copyright 2024 Mijick. All rights reserved. + + +import SwiftUI + +public extension View { + /// Tracks this view's global frame in the anchor registry. + /// Use this with `present(anchoredTo:)` to anchor popups to this view. + /// + /// ## Usage + /// ```swift + /// Button("Show Popup") { ... } + /// .trackAnchor("myButton") + /// + /// // Then present popup anchored to this button + /// MyPopup().present(anchoredTo: "myButton") + /// ``` + /// + /// - Parameter id: A unique identifier for this anchor. Use the same ID in `present(anchoredTo:)`. + /// - Returns: A view that tracks its frame in the global anchor registry. + func trackAnchor(_ id: String) -> some View { + self.background( + GeometryReader { geo in + Color.clear + .onAppear { + AnchorRegistry.setFrame(geo.frame(in: .global), forKey: id) + } + .onChange(of: geo.frame(in: .global)) { newFrame in + AnchorRegistry.setFrame(newFrame, forKey: id) + } + .onDisappear { + AnchorRegistry.removeFrame(forKey: id) + } + } + ) + } +} From ae4be7b06c473db99cf6a29a81c08860dd584811 Mon Sep 17 00:00:00 2001 From: vidy Date: Wed, 17 Dec 2025 20:31:52 +0800 Subject: [PATCH 09/15] fix(anchored): correct tap outside handling with passThrough config - hitTest now forwards to hostingController instead of returning self - SwiftUI overlay receives events when passThrough=false - Overlay checks dismiss config before closing popup - Events properly pass through when passThrough=true Fixes: clicking outside AnchoredPopup no longer affects parent popups --- .../Internal/UI/PopupAnchoredStackView.swift | 42 +++++++++++++++++-- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/Sources/Internal/UI/PopupAnchoredStackView.swift b/Sources/Internal/UI/PopupAnchoredStackView.swift index 00b412492..a4d0f77b6 100644 --- a/Sources/Internal/UI/PopupAnchoredStackView.swift +++ b/Sources/Internal/UI/PopupAnchoredStackView.swift @@ -30,24 +30,36 @@ class AnchoredPopupsContainer: UIView { } } - /// Returns true only if touch is inside a popup frame + /// Returns true if touch should be handled by this container override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + // Inside popup frame for popup in popupModel.popups { if let frame = popupModel.frame(for: popup), frame.contains(point) { return true } } - return false + // Outside popup - if not pass through, intercept event + if let lastPopup = popupModel.popups.last, + !lastPopup.config.isTapOutsidePassThroughEnabled { + return true + } + return false // pass through } - /// Returns hit view only if touch is inside a popup frame, otherwise passes through + /// Returns hit view for touch handling override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + // Inside popup frame - forward to hosting controller for popup in popupModel.popups { if let frame = popupModel.frame(for: popup), frame.contains(point) { return hostingController?.view.hitTest(point, with: event) } } - return nil + // Outside popup - if not pass through, forward to hosting controller (SwiftUI overlay handles) + if let lastPopup = popupModel.popups.last, + !lastPopup.config.isTapOutsidePassThroughEnabled { + return hostingController?.view.hitTest(point, with: event) + } + return nil // pass through to shared overlay } /// Updates popups in the container @@ -127,11 +139,33 @@ private class AnchoredPopupModel: ObservableObject { private struct AnchoredPopupContainerView: View { @ObservedObject var model: AnchoredPopupModel + private var shouldShowOverlay: Bool { + guard let lastPopup = model.popups.last else { return false } + // Show overlay when not pass through (to receive forwarded events from UIKit) + return !lastPopup.config.isTapOutsidePassThroughEnabled + } + var body: some View { // Reference containerSize to trigger re-render when it changes let _ = model.containerSize ZStack(alignment: .topLeading) { + // Overlay for handling tap outside (when not pass through) + if shouldShowOverlay { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + // Only dismiss if dismiss is enabled + if let lastPopup = model.popups.last, + lastPopup.config.isTapOutsideToDismissEnabled { + Task { @MainActor in + await PopupStack.dismissLastPopup() + } + } + // If dismiss = false, event is consumed but popup stays open + } + } + ForEach(model.popups, id: \.self) { popup in let popupId = popup.id.rawValue let frame = model.frame(for: popup) From 12f2d8ddf8ca13f281414791cfea4c7eaac2c0a1 Mon Sep 17 00:00:00 2001 From: vidy Date: Fri, 19 Dec 2025 10:07:07 +0800 Subject: [PATCH 10/15] feat: expose containerSize to AnchoredPopup via Environment - Add Public+PopupContainerSize.swift with popupContainerSize Environment key - Inject containerSize into popup content environment in PopupContentView - Enables downstream popups to respond to window size changes (e.g. iPad split screen) --- .../Internal/UI/PopupAnchoredStackView.swift | 4 +++- .../Setup/Public+PopupContainerSize.swift | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 Sources/Public/Setup/Public+PopupContainerSize.swift diff --git a/Sources/Internal/UI/PopupAnchoredStackView.swift b/Sources/Internal/UI/PopupAnchoredStackView.swift index a4d0f77b6..f0fb9356d 100644 --- a/Sources/Internal/UI/PopupAnchoredStackView.swift +++ b/Sources/Internal/UI/PopupAnchoredStackView.swift @@ -170,7 +170,7 @@ private struct AnchoredPopupContainerView: View { let popupId = popup.id.rawValue let frame = model.frame(for: popup) - PopupContentView(popup: popup, viewModel: model.viewModel) + PopupContentView(popup: popup, viewModel: model.viewModel, containerSize: model.containerSize) .opacity(frame != nil ? 1 : 0) .sizeReader { size in if model.popupSizes[popupId] != size { @@ -189,9 +189,11 @@ private struct AnchoredPopupContainerView: View { private struct PopupContentView: View { let popup: AnyPopup var viewModel: VM.AnchoredStack? + var containerSize: CGSize var body: some View { popup.body + .environment(\.popupContainerSize, containerSize) .compositingGroup() .fixedSize(horizontal: false, vertical: viewModel?.activePopupProperties.verticalFixedSize ?? true) .background(backgroundColor: popup.config.backgroundColor, overlayColor: .clear, corners: viewModel?.activePopupProperties.corners ?? [:]) diff --git a/Sources/Public/Setup/Public+PopupContainerSize.swift b/Sources/Public/Setup/Public+PopupContainerSize.swift new file mode 100644 index 000000000..f0461f132 --- /dev/null +++ b/Sources/Public/Setup/Public+PopupContainerSize.swift @@ -0,0 +1,23 @@ +// +// Public+PopupContainerSize.swift of MijickPopups +// +// Created by Vidy. Exposing container size to popup content. +// +// Copyright 2024 Mijick. All rights reserved. + +import SwiftUI + +// MARK: - Environment Key + +private struct PopupContainerSizeKey: EnvironmentKey { + static let defaultValue: CGSize = .zero +} + +public extension EnvironmentValues { + /// The size of the popup container (window size). + /// Available in AnchoredPopup body, updates when window resizes. + var popupContainerSize: CGSize { + get { self[PopupContainerSizeKey.self] } + set { self[PopupContainerSizeKey.self] = newValue } + } +} From f0f84a63c4a929792635ef47bb46b0bd0520b75e Mon Sep 17 00:00:00 2001 From: vidy Date: Fri, 19 Dec 2025 11:16:49 +0800 Subject: [PATCH 11/15] refactor: remove background handling from AnchoredPopup - Remove .background() and .mask() processing in PopupContentView - Remove .cornerRadius() and .backgroundColor() config methods for AnchoredPopup - Keep .overlayColor() for overlay customization AnchoredPopup now delegates all styling (background, corner radius, shadow) to the content view, avoiding conflicts with custom effects like glassEffect. --- Sources/Internal/UI/PopupAnchoredStackView.swift | 1 - Sources/Public/Popup/Public+Popup+Config.swift | 6 ------ 2 files changed, 7 deletions(-) diff --git a/Sources/Internal/UI/PopupAnchoredStackView.swift b/Sources/Internal/UI/PopupAnchoredStackView.swift index f0fb9356d..bd2c8ccc4 100644 --- a/Sources/Internal/UI/PopupAnchoredStackView.swift +++ b/Sources/Internal/UI/PopupAnchoredStackView.swift @@ -196,7 +196,6 @@ private struct PopupContentView: View { .environment(\.popupContainerSize, containerSize) .compositingGroup() .fixedSize(horizontal: false, vertical: viewModel?.activePopupProperties.verticalFixedSize ?? true) - .background(backgroundColor: popup.config.backgroundColor, overlayColor: .clear, corners: viewModel?.activePopupProperties.corners ?? [:]) .opacity(Double(viewModel?.calculateOpacity(for: popup) ?? 1)) } } diff --git a/Sources/Public/Popup/Public+Popup+Config.swift b/Sources/Public/Popup/Public+Popup+Config.swift index 093fb345e..5cf299482 100644 --- a/Sources/Public/Popup/Public+Popup+Config.swift +++ b/Sources/Public/Popup/Public+Popup+Config.swift @@ -168,12 +168,6 @@ public extension LocalConfigAnchored { /// Distance of the popup from its edges. func popupPadding(_ value: EdgeInsets) -> Self { self.popupPadding = value; return self } - /// Corner radius of the background of the active popup. - func cornerRadius(_ value: CGFloat) -> Self { self.cornerRadius = value; return self } - - /// Background color of the popup. - func backgroundColor(_ color: Color) -> Self { self.backgroundColor = color; return self } - /// The color of the overlay covering the view behind the popup. func overlayColor(_ color: Color) -> Self { self.overlayColor = color; return self } From 5fd282fb699d109512223fd626b27b0511673740 Mon Sep 17 00:00:00 2001 From: vidy Date: Fri, 19 Dec 2025 12:26:00 +0800 Subject: [PATCH 12/15] refactor: remove redundant overlay from AnchoredPopupContainerView AnchoredPopup's tap-outside handling is already controlled at UIKit level via AnchoredPopupsContainer.hitTest. The SwiftUI overlay was unnecessary. --- .../Internal/UI/PopupAnchoredStackView.swift | 23 ------------------- 1 file changed, 23 deletions(-) diff --git a/Sources/Internal/UI/PopupAnchoredStackView.swift b/Sources/Internal/UI/PopupAnchoredStackView.swift index bd2c8ccc4..eaeed55c5 100644 --- a/Sources/Internal/UI/PopupAnchoredStackView.swift +++ b/Sources/Internal/UI/PopupAnchoredStackView.swift @@ -139,37 +139,14 @@ private class AnchoredPopupModel: ObservableObject { private struct AnchoredPopupContainerView: View { @ObservedObject var model: AnchoredPopupModel - private var shouldShowOverlay: Bool { - guard let lastPopup = model.popups.last else { return false } - // Show overlay when not pass through (to receive forwarded events from UIKit) - return !lastPopup.config.isTapOutsidePassThroughEnabled - } - var body: some View { // Reference containerSize to trigger re-render when it changes let _ = model.containerSize ZStack(alignment: .topLeading) { - // Overlay for handling tap outside (when not pass through) - if shouldShowOverlay { - Color.clear - .contentShape(Rectangle()) - .onTapGesture { - // Only dismiss if dismiss is enabled - if let lastPopup = model.popups.last, - lastPopup.config.isTapOutsideToDismissEnabled { - Task { @MainActor in - await PopupStack.dismissLastPopup() - } - } - // If dismiss = false, event is consumed but popup stays open - } - } - ForEach(model.popups, id: \.self) { popup in let popupId = popup.id.rawValue let frame = model.frame(for: popup) - PopupContentView(popup: popup, viewModel: model.viewModel, containerSize: model.containerSize) .opacity(frame != nil ? 1 : 0) .sizeReader { size in From 1d3062685a239314b7ec1c7eebcae2403f1c519f Mon Sep 17 00:00:00 2001 From: vidy Date: Fri, 19 Dec 2025 14:15:43 +0800 Subject: [PATCH 13/15] Fix isTapOutsidePassThroughEnabled not working for AnchoredPopup - Add allowsHitTesting to overlay based on tapOutsidePassThrough config - Add tapOutsidePassThrough computed property to read last popup's config --- Sources/Internal/UI/PopupAnchoredStackView.swift | 2 +- Sources/Internal/UI/PopupView.swift | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/Internal/UI/PopupAnchoredStackView.swift b/Sources/Internal/UI/PopupAnchoredStackView.swift index eaeed55c5..9657fe585 100644 --- a/Sources/Internal/UI/PopupAnchoredStackView.swift +++ b/Sources/Internal/UI/PopupAnchoredStackView.swift @@ -32,7 +32,7 @@ class AnchoredPopupsContainer: UIView { /// Returns true if touch should be handled by this container override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { - // Inside popup frame + // Only handle touches inside popup frame for popup in popupModel.popups { if let frame = popupModel.frame(for: popup), frame.contains(point) { return true diff --git a/Sources/Internal/UI/PopupView.swift b/Sources/Internal/UI/PopupView.swift index 6b11c0912..c39a7fbc6 100644 --- a/Sources/Internal/UI/PopupView.swift +++ b/Sources/Internal/UI/PopupView.swift @@ -70,6 +70,7 @@ private extension PopupView { func createOverlayView() -> some View { getOverlayColor() .contentShape(Rectangle()) + .allowsHitTesting(!tapOutsidePassThrough) .zIndex(stack.priority.overlay) .animation(.linear, value: stack.popups) .onTapGesture(perform: onTap) @@ -131,4 +132,5 @@ private extension PopupView { } private extension PopupView { var tapOutsideClosesPopup: Bool { stack.popups.last?.config.isTapOutsideToDismissEnabled ?? false } + var tapOutsidePassThrough: Bool { stack.popups.last?.config.isTapOutsidePassThroughEnabled ?? false } } From c6a83b35fbf4e67c5d97829bfb5b9ecf6f3b3ed0 Mon Sep 17 00:00:00 2001 From: vidy Date: Fri, 19 Dec 2025 17:00:09 +0800 Subject: [PATCH 14/15] Merge tapOutsideToDismissPopup and tapOutsidePassThrough into tapOutsideBehavior enum - Add TapOutsideBehavior enum with .none, .dismiss, .passThrough cases - Remove isTapOutsidePassThroughEnabled from LocalConfigAnchored - Remove tapOutsideToDismissPopup from AnchoredPopup config - Update PopupView, PopupAnchoredStackView, SceneDelegate to use new enum --- .../Local/LocalConfig+Anchored.swift | 8 ++++---- Sources/Internal/Models/AnyPopupConfig.swift | 6 ++++-- .../Internal/Models/TapOutsideBehavior.swift | 19 +++++++++++++++++++ .../Internal/UI/PopupAnchoredStackView.swift | 10 +++++----- Sources/Internal/UI/PopupView.swift | 15 +++++++++++++-- .../Public/Popup/Public+Popup+Config.swift | 3 --- .../Setup/Public+Setup+SceneDelegate.swift | 2 +- 7 files changed, 46 insertions(+), 17 deletions(-) create mode 100644 Sources/Internal/Models/TapOutsideBehavior.swift diff --git a/Sources/Internal/Configurables/Local/LocalConfig+Anchored.swift b/Sources/Internal/Configurables/Local/LocalConfig+Anchored.swift index 404499704..5597b6875 100644 --- a/Sources/Internal/Configurables/Local/LocalConfig+Anchored.swift +++ b/Sources/Internal/Configurables/Local/LocalConfig+Anchored.swift @@ -21,7 +21,7 @@ public class LocalConfigAnchored: LocalConfig { required public init() {} public var originAnchor: PopupAnchorPoint = .bottom public var popupAnchor: PopupAnchorPoint = .top public var offset: CGPoint = .zero - public var isTapOutsidePassThroughEnabled: Bool = false + public var tapOutsideBehavior: TapOutsideBehavior = .dismiss public var edgePadding: CGFloat = 16 public var constrainedEdges: Edge.Set = .horizontal @@ -47,9 +47,9 @@ public extension LocalConfigAnchored { /// Sets additional offset from the calculated position func offset(x: CGFloat = 0, y: CGFloat = 0) -> Self { self.offset = CGPoint(x: x, y: y); return self } - /// Enables touch pass-through when tapping outside the popup - /// When enabled, touches outside the popup are passed to underlying views instead of being blocked - func tapOutsidePassThrough(_ enabled: Bool) -> Self { self.isTapOutsidePassThroughEnabled = enabled; return self } + /// Sets behavior when tapping outside the popup + /// - Parameter behavior: The tap outside behavior (.none, .dismiss, .passThrough) + func tapOutsideBehavior(_ behavior: TapOutsideBehavior) -> Self { self.tapOutsideBehavior = behavior; return self } /// Configures edge padding and which edges to constrain /// - Parameters: diff --git a/Sources/Internal/Models/AnyPopupConfig.swift b/Sources/Internal/Models/AnyPopupConfig.swift index 87a731349..929946252 100644 --- a/Sources/Internal/Models/AnyPopupConfig.swift +++ b/Sources/Internal/Models/AnyPopupConfig.swift @@ -42,7 +42,9 @@ struct AnyPopupConfig: LocalConfig, Sendable { init() {} var originAnchor: PopupAnchorPoint = .bottom var popupAnchor: PopupAnchorPoint = .top var anchorOffset: CGPoint = .zero - var isTapOutsidePassThroughEnabled: Bool = false + var tapOutsideBehavior: TapOutsideBehavior = .dismiss + // MARK: Transition + var transition: AnyTransition? = nil var edgePadding: CGFloat = 16 var constrainedEdges: Edge.Set = .horizontal } @@ -67,7 +69,7 @@ extension AnyPopupConfig { self.originAnchor = anchoredConfig.originAnchor self.popupAnchor = anchoredConfig.popupAnchor self.anchorOffset = anchoredConfig.offset - self.isTapOutsidePassThroughEnabled = anchoredConfig.isTapOutsidePassThroughEnabled + self.tapOutsideBehavior = anchoredConfig.tapOutsideBehavior self.edgePadding = anchoredConfig.edgePadding self.constrainedEdges = anchoredConfig.constrainedEdges } diff --git a/Sources/Internal/Models/TapOutsideBehavior.swift b/Sources/Internal/Models/TapOutsideBehavior.swift new file mode 100644 index 000000000..aa72daf5a --- /dev/null +++ b/Sources/Internal/Models/TapOutsideBehavior.swift @@ -0,0 +1,19 @@ +// +// TapOutsideBehavior.swift of MijickPopups +// +// Created by Vidy. Extending MijickPopups with anchored popup support. +// +// Copyright 2024 Mijick. All rights reserved. + + +import Foundation + +/// Defines behavior when tapping outside an AnchoredPopup +public enum TapOutsideBehavior { + /// Does not dismiss, does not pass through (blocks tap) + case none + /// Dismisses popup, does not pass through + case dismiss + /// Passes through to underlying views, does not dismiss + case passThrough +} diff --git a/Sources/Internal/UI/PopupAnchoredStackView.swift b/Sources/Internal/UI/PopupAnchoredStackView.swift index 9657fe585..b984bef89 100644 --- a/Sources/Internal/UI/PopupAnchoredStackView.swift +++ b/Sources/Internal/UI/PopupAnchoredStackView.swift @@ -38,9 +38,9 @@ class AnchoredPopupsContainer: UIView { return true } } - // Outside popup - if not pass through, intercept event + // Outside popup - if not passThrough, intercept event if let lastPopup = popupModel.popups.last, - !lastPopup.config.isTapOutsidePassThroughEnabled { + lastPopup.config.tapOutsideBehavior != .passThrough { return true } return false // pass through @@ -54,12 +54,12 @@ class AnchoredPopupsContainer: UIView { return hostingController?.view.hitTest(point, with: event) } } - // Outside popup - if not pass through, forward to hosting controller (SwiftUI overlay handles) + // Outside popup - if not passThrough, forward to hosting controller (SwiftUI overlay handles) if let lastPopup = popupModel.popups.last, - !lastPopup.config.isTapOutsidePassThroughEnabled { + lastPopup.config.tapOutsideBehavior != .passThrough { return hostingController?.view.hitTest(point, with: event) } - return nil // pass through to shared overlay + return nil // pass through to underlying views } /// Updates popups in the container diff --git a/Sources/Internal/UI/PopupView.swift b/Sources/Internal/UI/PopupView.swift index c39a7fbc6..dfe8014e2 100644 --- a/Sources/Internal/UI/PopupView.swift +++ b/Sources/Internal/UI/PopupView.swift @@ -131,6 +131,17 @@ private extension PopupView { } } private extension PopupView { - var tapOutsideClosesPopup: Bool { stack.popups.last?.config.isTapOutsideToDismissEnabled ?? false } - var tapOutsidePassThrough: Bool { stack.popups.last?.config.isTapOutsidePassThroughEnabled ?? false } + var tapOutsideClosesPopup: Bool { + guard let config = stack.popups.last?.config else { return false } + // For anchored popups, use tapOutsideBehavior + if config.alignment == .anchored { + return config.tapOutsideBehavior == .dismiss + } + // For other popups, use isTapOutsideToDismissEnabled + return config.isTapOutsideToDismissEnabled + } + var tapOutsidePassThrough: Bool { + guard let config = stack.popups.last?.config else { return false } + return config.tapOutsideBehavior == .passThrough + } } diff --git a/Sources/Public/Popup/Public+Popup+Config.swift b/Sources/Public/Popup/Public+Popup+Config.swift index 5cf299482..cd089ff39 100644 --- a/Sources/Public/Popup/Public+Popup+Config.swift +++ b/Sources/Public/Popup/Public+Popup+Config.swift @@ -170,7 +170,4 @@ public extension LocalConfigAnchored { /// The color of the overlay covering the view behind the popup. func overlayColor(_ color: Color) -> Self { self.overlayColor = color; return self } - - /// If enabled, dismisses the active popup when touched outside its area. - func tapOutsideToDismissPopup(_ value: Bool) -> Self { self.isTapOutsideToDismissEnabled = value; return self } } diff --git a/Sources/Public/Setup/Public+Setup+SceneDelegate.swift b/Sources/Public/Setup/Public+Setup+SceneDelegate.swift index 2683f018e..12f416b7d 100644 --- a/Sources/Public/Setup/Public+Setup+SceneDelegate.swift +++ b/Sources/Public/Setup/Public+Setup+SceneDelegate.swift @@ -155,7 +155,7 @@ private extension Window { // Touch outside AnchoredPopup - check if pass-through is enabled if let lastAnchored = anchoredPopups.last, - lastAnchored.config.isTapOutsidePassThroughEnabled { + lastAnchored.config.tapOutsideBehavior == .passThrough { return .passThrough } return .block From 65e42f0b8683e709e885e8d33671a66dcc1c7faf Mon Sep 17 00:00:00 2001 From: vidy Date: Fri, 19 Dec 2025 17:13:49 +0800 Subject: [PATCH 15/15] Fix tapOutsidePassThrough to only apply for anchored popups --- Sources/Internal/UI/PopupAnchoredStackView.swift | 16 ++++++++++------ Sources/Internal/UI/PopupView.swift | 2 ++ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/Sources/Internal/UI/PopupAnchoredStackView.swift b/Sources/Internal/UI/PopupAnchoredStackView.swift index b984bef89..1680c74bd 100644 --- a/Sources/Internal/UI/PopupAnchoredStackView.swift +++ b/Sources/Internal/UI/PopupAnchoredStackView.swift @@ -38,12 +38,14 @@ class AnchoredPopupsContainer: UIView { return true } } - // Outside popup - if not passThrough, intercept event + // Outside popup: + // - .none: intercept (block without dismiss) + // - .dismiss/.passThrough: pass through to SwiftUI overlay if let lastPopup = popupModel.popups.last, - lastPopup.config.tapOutsideBehavior != .passThrough { + lastPopup.config.tapOutsideBehavior == .none { return true } - return false // pass through + return false } /// Returns hit view for touch handling @@ -54,12 +56,14 @@ class AnchoredPopupsContainer: UIView { return hostingController?.view.hitTest(point, with: event) } } - // Outside popup - if not passThrough, forward to hosting controller (SwiftUI overlay handles) + // Outside popup: + // - .none: forward to hosting controller (blocks event) + // - .dismiss/.passThrough: return nil to pass through to SwiftUI overlay if let lastPopup = popupModel.popups.last, - lastPopup.config.tapOutsideBehavior != .passThrough { + lastPopup.config.tapOutsideBehavior == .none { return hostingController?.view.hitTest(point, with: event) } - return nil // pass through to underlying views + return nil } /// Updates popups in the container diff --git a/Sources/Internal/UI/PopupView.swift b/Sources/Internal/UI/PopupView.swift index dfe8014e2..f40ec83b8 100644 --- a/Sources/Internal/UI/PopupView.swift +++ b/Sources/Internal/UI/PopupView.swift @@ -142,6 +142,8 @@ private extension PopupView { } var tapOutsidePassThrough: Bool { guard let config = stack.popups.last?.config else { return false } + // Only anchored popups support passThrough + guard config.alignment == .anchored else { return false } return config.tapOutsideBehavior == .passThrough } }