Skip to content

feat: Add AnchoredPopup support for absolute positioning#198

Open
videni wants to merge 15 commits into
Mijick:mainfrom
videni:feature/anchored-popup
Open

feat: Add AnchoredPopup support for absolute positioning#198
videni wants to merge 15 commits into
Mijick:mainfrom
videni:feature/anchored-popup

Conversation

@videni
Copy link
Copy Markdown

@videni videni commented Dec 15, 2025

Summary

Add AnchoredPopup protocol and configuration for absolute positioning of popups, allowing popups to be anchored to specific screen coordinates.

Closes #197

Changes

  • 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

Usage

struct MyAnchoredPopup: AnchoredPopup {
    var body: some View {
        Text("Anchored content")
    }
    
    func configurePopup(config: AnchoredPopupConfig) -> AnchoredPopupConfig {
        config
            .originAnchor(.bottom)
            .popupAnchor(.top)
    }
}

// Present anchored to a specific frame
await MyAnchoredPopup().present(anchoredTo: buttonFrame)

AnchoredPopup Compatibility Limitations

AnchoredPopup is iOS/iPadOS only. Not available on macOS, tvOS, or watchOS.

Why This Limitation Exists

Core Reason: Touch Pass-Through Requires UIKit

AnchoredPopup's key feature is allowing touches outside the popup to pass through to underlying views. This requires:

Feature UIKit Implementation SwiftUI Alternative
Intercept touch events point(inside:with:) ❌ None
Forward touches to layers below hitTest(_:with:) ❌ None
Independent window layer UIWindow subclass ❌ No precise control
Access window reference UIViewRepresentable NSViewRepresentable (macOS)

Why Pure SwiftUI Won't Work

Popup Layer (Popup Window)
↓ tap outside
Main View Layer (App Window) ← needs to receive event

SwiftUI's overlay / ZStack cannot:

  • Intercept events in one area (inside popup)
  • Pass through events in another area (outside popup)

UIKit's hitTest override enables precise per-point event routing.

Design Trade-offs

UIKit implementation was chosen for:

  1. Feature completeness - Touch pass-through is essential for anchored popups in my case
  2. Primary platform - iOS/iPadOS is the main target
  3. UX requirements - Users need to interact with content beneath the popup without dismissing it

Future Options

Approach Effort Result
Conditional compilation (disable) Low No macOS support
Pure SwiftUI (simplified) Medium No touch pass-through
AppKit native implementation High Full functionality

- 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
@videni videni requested a review from FulcrumOne as a code owner December 15, 2025 13:37
  - Add isTapOutsidePassThroughEnabled config option
  - Update hitTest for iOS 17/18/26 to support pass-through
@videni videni force-pushed the feature/anchored-popup branch from 92f858a to d5474d2 Compare December 16, 2025 14:04
vidy added 5 commits December 17, 2025 10:53
…mID 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
…rough 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
  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.
  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.
  - 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 })
@videni videni force-pushed the feature/anchored-popup branch from b1f9adc to 62b6af8 Compare December 17, 2025 09:41
vidy added 8 commits December 17, 2025 19:53
…oning

  - 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
  - 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
  - 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)
  - 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.
  AnchoredPopup's tap-outside handling is already controlled at UIKit level
  via AnchoredPopupsContainer.hitTest. The SwiftUI overlay was unnecessary.
  - Add allowsHitTesting to overlay based on tapOutsidePassThrough config
  - Add tapOutsidePassThrough computed property to read last popup's config
…ideBehavior 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
@videni videni force-pushed the feature/anchored-popup branch from 07446f6 to 65e42f0 Compare December 19, 2025 09:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Absolute Positioning Support (anchor to specific view/coordinates)

1 participant