SwiftUI Custom Popover


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:

  • selection is external and drives intent
  • presented is 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.