Greetings, traveler!
SwiftUI gives you .popover, but sometimes it is the unsuitable tool. For instance, design might require a popover with no arrow. In those cases we can reach for matchedGeometryEffect. It lets you “attach” an overlay to a view without dealing with manual frames and coordinate spaces.
We can also make it reusable. A ViewModifier is a clean way to turn it into a tiny library.
This article walks through a universal approach: a matchedPopover(selection:) container, and a matchedPopoverSource(id:) anchor you can add to any view.
The idea
Every anchor view and the popover overlay share the same Namespace. The anchor view is the geometry source, the popover is the destination, and a single selection: Binding<ID?> controls what is currently shown.
Step 1. Define the public API
The usage can look like sheet(item:) with the one source of truth.
enum Target: String {
case first, second
var anchor: UnitPoint {
switch self {
case .first: .top
case .second: .bottom
}
}
}
@State private var selection: Target?
VStack {
Button("First") { selection = .first }
.matchedPopoverSource(id: Target.first, anchor: .bottom)
Button("Second") { selection = .second }
.matchedPopoverSource(id: Target.second, anchor: .top)
}
.matchedPopover(selection: $selection, anchor: { $0.anchor }) { _ in
PopoverView()
}matchedPopoverSource does only one job: mark a view as an anchor for a given id.
matchedPopover does the rest: draws the overlay, drives presentation, and dismisses on background tap.
Step 2. Create a shared namespace via Environment
A Namespace is created inside the container modifier, but the anchor views live deeper in the tree. You need a way to pass the namespace down without threading it through every initializer.
SwiftUI gives us @Entry to define custom environment values with almost no boilerplate.
private extension EnvironmentValues {
@Entry var matchedPopoverNamespace: Namespace.ID?
}The container will inject it, and each source modifier will read it.
Step 3. Implement the source modifier
The source modifier attaches the anchor view to the shared namespace using matchedGeometryEffect:
private struct MatchedPopoverSourceModifier<ID: Hashable>: ViewModifier {
let id: ID
let sourceAnchor: UnitPoint
@Environment(\.matchedPopoverNamespace) private var ns
func body(content: Content) -> some View {
content.matchedGeometryEffect(
id: id,
in: ns ?? Namespace().wrappedValue,
anchor: sourceAnchor
)
}
}This modifier does not show anything. It only defines the geometry “source” for that id.
Step 4. Implement the container modifier
The container owns:
- the namespace
- the overlay popover view
- the dismiss layer (tap anywhere to close)
- a small internal state to make switching deterministic
The key trick is having two states:
selectionis external and drives intentpresentedis internal and drives what is currently rendered
When selection changes from one id to another, we hide first, then show the new one. That avoids the “travel” animation and makes switching feel crisp.
private struct MatchedPopoverContainerModifier<ID: Hashable, Popover: View>: ViewModifier {
@Binding var selection: ID?
let sourceAnchor: (ID) -> UnitPoint
@ViewBuilder var popover: (ID) -> Popover
@State private var presented: ID? = nil
@Namespace private var ns
func body(content: Content) -> some View {
ZStack {
content
.overlay {
if presented != nil {
Color.clear
.ignoresSafeArea()
.contentShape(.rect)
.onTapGesture {
withAnimation { selection = nil }
}
}
}
if let id = presented {
popover(id)
.matchedGeometryEffect(
id: id,
in: ns,
properties: .position,
anchor: sourceAnchor(id).opposite,
isSource: false
)
.transition(.opacity.combined(with: .scale))
}
}
.environment(\.matchedPopoverNamespace, ns)
.onAppear {
presented = selection
}
.onChange(of: selection) { _, newValue in
applySelection(newValue)
}
}
private func applySelection(_ newValue: ID?) {
guard let newValue else {
withAnimation { presented = nil }
return
}
guard let current = presented else {
withAnimation { presented = newValue }
return
}
if current == newValue { return }
withAnimation {
presented = nil
} completion: {
withAnimation {
presented = newValue
}
}
}
}
Putting the dismiss layer into an .overlay with ignoresSafeArea() makes dismissal work from anywhere on the screen.
Step 5. Use one anchor instead of two
We need to create an anchor for the source view and an anchor for the popover view. However, those two anchors are often just opposites: source .bottom means popover .top.
So we can accept one anchor (the source anchor) and derives the popover anchor automatically:
private extension UnitPoint {
var opposite: UnitPoint {
switch self {
case .top: .bottom
case .bottom: .top
case .leading: .trailing
case .trailing: .leading
case .topLeading: .bottomTrailing
case .topTrailing: .bottomLeading
case .bottomLeading: .topTrailing
case .bottomTrailing: .topLeading
case .center: .center
default: .center
}
}
}This keeps the API small while still covering the common cases.
Step 6. Expose it as view extensions
Now we wrap both modifiers into a neat API:
public extension View {
func matchedPopover<ID: Hashable, Popover: View>(
selection: Binding<ID?>,
anchor: @escaping (ID) -> UnitPoint = { _ in .top },
@ViewBuilder popover: @escaping (ID) -> Popover
) -> some View {
modifier(
MatchedPopoverContainerModifier(
selection: selection,
sourceAnchor: anchor,
popover: popover
)
)
}
func matchedPopoverSource<ID: Hashable>(
id: ID,
anchor: UnitPoint = .bottom
) -> some View {
modifier(
MatchedPopoverSourceModifier(
id: id,
sourceAnchor: anchor
)
)
}
}That’s the point where our “custom popover hack” becomes a reusable building block.
Example of usage
struct PopoverView: View {
enum Target: String {
case view1
case view2
case view3
var anchor: UnitPoint {
switch self {
case .view1: .leading
case .view2: .top
case .view3: .bottom
}
}
}
@State private var selection: Target?
var body: some View {
VStack(spacing: 40) {
HStack {
Spacer()
button(.view1)
.matchedPopoverSource(
id: Target.view1,
anchor: Target.view1.anchor
)
.padding()
}
button(.view2)
.matchedPopoverSource(
id: Target.view2,
anchor: Target.view2.anchor
)
button(.view3)
.matchedPopoverSource(
id: Target.view3,
anchor: Target.view3.anchor
)
}
.frame(maxHeight: .infinity)
.ignoresSafeArea()
.gradientBackground()
.matchedPopover(
selection: $selection,
anchor: { $0.anchor }
) { id in
Text("Popover: \(String(describing: id))")
.fontWeight(.semibold)
.fontDesign(.rounded)
.padding()
.background(.ultraThinMaterial)
.clipShape(.rect(cornerRadius: 16))
.padding()
}
}
private func button(_ target: Target) -> some View {
Button(target.rawValue) {
selection = (selection == target) ? nil : target
}
.buttonStyle(.borderedProminent)
}
}Closing thoughts
This approach becomes boring once extracted: you drive everything through selection, anchors are opt-in, and the popover itself is just a view builder. And this is a good part.
