diff --git a/Sources/Internal/Configurables/Local/LocalConfig+Anchored.swift b/Sources/Internal/Configurables/Local/LocalConfig+Anchored.swift new file mode 100644 index 000000000..5597b6875 --- /dev/null +++ b/Sources/Internal/Configurables/Local/LocalConfig+Anchored.swift @@ -0,0 +1,66 @@ +// +// 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 + public var tapOutsideBehavior: TapOutsideBehavior = .dismiss + public var edgePadding: CGFloat = 16 + public var constrainedEdges: Edge.Set = .horizontal + + // 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 } + + /// 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: + /// - 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 +public typealias AnchoredPopupConfig = LocalConfigAnchored 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/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..5587b02f7 100644 --- a/Sources/Internal/Models/AnyPopup.swift +++ b/Sources/Internal/Models/AnyPopup.swift @@ -61,6 +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 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 f2f64d8ce..929946252 100644 --- a/Sources/Internal/Models/AnyPopupConfig.swift +++ b/Sources/Internal/Models/AnyPopupConfig.swift @@ -26,6 +26,27 @@ struct AnyPopupConfig: LocalConfig, Sendable { init() {} var isTapOutsideToDismissEnabled: Bool = false var isDragGestureEnabled: Bool = false var dragGestureAreaSize: CGFloat = 0 + + // MARK: Anchored-specific + 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 + var tapOutsideBehavior: TapOutsideBehavior = .dismiss + // MARK: Transition + var transition: AnyTransition? = nil + var edgePadding: CGFloat = 16 + var constrainedEdges: Edge.Set = .horizontal } // MARK: Initialize @@ -42,5 +63,15 @@ 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 + self.tapOutsideBehavior = anchoredConfig.tapOutsideBehavior + self.edgePadding = anchoredConfig.edgePadding + self.constrainedEdges = anchoredConfig.constrainedEdges + } } } diff --git a/Sources/Internal/Models/StackPriority.swift b/Sources/Internal/Models/StackPriority.swift index 68e67ac80..29cd9d4df 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 overlay: CGFloat { 1 } + var anchored: CGFloat { values[3] } + var overlay: CGFloat { (values.min() ?? 0) - 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/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 new file mode 100644 index 000000000..1680c74bd --- /dev/null +++ b/Sources/Internal/UI/PopupAnchoredStackView.swift @@ -0,0 +1,260 @@ +// +// PopupAnchoredStackView.swift of MijickPopups +// +// Created by Vidy. Extending MijickPopups with anchored popup support. +// +// Copyright 2024 Mijick. All rights reserved. + + +import SwiftUI +import UIKit + +// MARK: - Anchored Popups Container + +/// UIKit container with single UIHostingController for all popups +class AnchoredPopupsContainer: UIView { + static var shared: AnchoredPopupsContainer? + + 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 if touch should be handled by this container + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + // Only handle touches inside popup frame + for popup in popupModel.popups { + if let frame = popupModel.frame(for: popup), frame.contains(point) { + return true + } + } + // Outside popup: + // - .none: intercept (block without dismiss) + // - .dismiss/.passThrough: pass through to SwiftUI overlay + if let lastPopup = popupModel.popups.last, + lastPopup.config.tapOutsideBehavior == .none { + return true + } + return false + } + + /// 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) + } + } + // 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 == .none { + return hostingController?.view.hitTest(point, with: event) + } + return nil + } + + /// Updates popups in the container + func updatePopups(_ popups: [AnyPopup], viewModel: VM.AnchoredStack) { + // Create hosting controller if needed (sync, only once) + 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 + } + + 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) + static func install(on window: UIWindow) { + guard shared == nil else { return } + let container = AnchoredPopupsContainer() + container.backgroundColor = .clear + container.translatesAutoresizingMaskIntoConstraints = false + + 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) + ]) + container.setNeedsLayout() + window.layoutIfNeeded() + shared = container + } +} + +// MARK: - Popup Model (ObservableObject for SwiftUI) + +@MainActor +private class AnchoredPopupModel: ObservableObject { + @Published var popups: [AnyPopup] = [] + @Published var popupSizes: [String: CGSize] = [:] + @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 + +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 frame = model.frame(for: popup) + PopupContentView(popup: popup, viewModel: model.viewModel, containerSize: model.containerSize) + .opacity(frame != nil ? 1 : 0) + .sizeReader { size in + if model.popupSizes[popupId] != size { + model.popupSizes[popupId] = size + } + } + .offset(x: frame?.origin.x ?? 0, y: frame?.origin.y ?? 0) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .edgesIgnoringSafeArea(.all) + } +} + +/// SwiftUI content for a single popup +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) + .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() + } +} + +// MARK: - Popup Anchored Stack View (Triggers installation and updates) + +struct PopupAnchoredStackView: View { + @ObservedObject var viewModel: VM.AnchoredStack + + var body: some View { + AnchoredStackInstaller(viewModel: viewModel) + .frame(width: 1, height: 1) + } +} + +/// 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() + } +} + +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 c80dd5294..f40ec83b8 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,12 +62,15 @@ private extension PopupView { createTopPopupStackView() createCenterPopupStackView() createBottomPopupStackView() + createAnchoredPopupStackView() } } } private extension PopupView { func createOverlayView() -> some View { getOverlayColor() + .contentShape(Rectangle()) + .allowsHitTesting(!tapOutsidePassThrough) .zIndex(stack.priority.overlay) .animation(.linear, value: stack.popups) .onTapGesture(perform: onTap) @@ -80,6 +84,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 } @@ -90,7 +97,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) } @@ -119,9 +127,23 @@ 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 { - var tapOutsideClosesPopup: Bool { stack.popups.last?.config.isTapOutsideToDismissEnabled ?? 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 } + // Only anchored popups support passThrough + guard config.alignment == .anchored else { return false } + return config.tapOutsideBehavior == .passThrough + } } 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..92b316563 --- /dev/null +++ b/Sources/Internal/View Models/ViewModel+AnchoredStack.swift @@ -0,0 +1,199 @@ +// +// 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 { + // AnchoredPopup supports multiple popups displayed simultaneously, so all popups have opacity 1 + 1 + } +} + +// 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, containerSize: CGSize = .zero) -> CGPoint { + let config = popup.config + let anchorFrame = config.getAnchorFrame() + + // 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) + + // Initial position + var popoverFrame = CGRect( + x: originPoint.x - popupOffset.x + config.anchorOffset.x, + 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 + 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/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) } } diff --git a/Sources/Public/Popup/Public+Popup+Config.swift b/Sources/Public/Popup/Public+Popup+Config.swift index 54855e1d3..cd089ff39 100644 --- a/Sources/Public/Popup/Public+Popup+Config.swift +++ b/Sources/Public/Popup/Public+Popup+Config.swift @@ -153,12 +153,21 @@ 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 } + + /// The color of the overlay covering the view behind the popup. + func overlayColor(_ color: Color) -> Self { self.overlayColor = color; 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..2913b33a0 100644 --- a/Sources/Public/Present/Public+Present+Popup.swift +++ b/Sources/Public/Present/Public+Present+Popup.swift @@ -29,12 +29,79 @@ 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 tracked anchor view. + + Use this with `.trackAnchor(_:)` modifier to track the source view's frame. + + - Parameters: + - 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() + } + + /** + 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 + MyPopup().present(anchoredTo: buttonFrame, customID: "menu") + ``` + */ + @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)) + } +} + // MARK: Configure Popup public extension 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/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 } + } +} diff --git a/Sources/Public/Setup/Public+Setup+SceneDelegate.swift b/Sources/Public/Setup/Public+Setup+SceneDelegate.swift index 6cc35c3e7..12f416b7d 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,96 @@ 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 { + // 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 + } + + 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 + if let lastAnchored = anchoredPopups.last, + lastAnchored.config.tapOutsideBehavior == .passThrough { + return .passThrough + } + 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? { - 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: + // 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 + } + + // Check other popups (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: + // 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 } + + 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: + // 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 + } + guard let hit = super.hitTest(point, with: event) else { return nil } return rootViewController?.view == hit ? nil : hit } 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) + } + } + ) + } +}