SwiftUI - Position an overlay relative to its anchor

I have a ZStack containing 2 views:

  • referenceContent - has some content and a Divider. This is the main content across the screen

  • popoverContent - is a conditional popup window that only takes up a tiny portion of the screen.

var body: some View {
  ZStack {
    referenceContent

    if popoverCondition {
      popoverContent
    }
  }
}

I want the popoverContent's top edge to line up with the bottom of referenceContent

Anyone know how to make this happen? Or is there just a much better way to view this popup window than I'm doing now? Thanks!


Solution 1:

You can do this using the overlay(alignment:content:) modifier (previously overlay(_:alignment:)) in combination with custom alignment guides.

The basic idea is that you align the bottom of your reference view with the top of your popover view.

The annoying thing is that the overlay modifier only lets you specify one alignment guide (for the two views). So if you write stack1.overlay(alignment: .bottom) { stack2 } it will align the bottom of your reference with the bottom of your overlay. A quick way to overcome this is to overwrite the bottom alignment guide of your overlay and return the top instead.

referenceView
  .overlay(alignment: .bottom) {
    popoverContent
      // overwrites bottom alignment of the popover with its top alignment guide.
      .alignmentGuide(.bottom) {$0[.top]}
  }

Overlay vs ZStack

You might ask: "why don't you use a ZStack instead of an overlay?". Well the difference between the two is that the ZStack will take the size of your popover into consideration when laying out your entire view (reference + popover). That is the opposite of what a popover should do. For a popover, the layout system should only take the size of your reference view into consideration and draw the popover on top of it (without affecting the layout of your reference). That is exactly what the overlay(...) modifier does.

Old API (prior to iOS 15, macOS 12)

In older versions of SwiftUI the arguments of the overlay modifier were in reverse order. So the code example for these older systems is:

referenceView
  .overlay(
    popoverContent.alignmentGuide(.bottom) {$0[.top]},
    alignment: .bottom
  )

Custom alignment guides

When you don't want to overwrite an existing alignment guide (because you need it somewhere else for example) you can also use a custom alignment guide. Here is a more generic example using a custom alignment guide named Alignment.TwoSided

extension View {
    @available(iOS 15.0, *)
    func overlay<Target: View>(align originAlignment: Alignment, to targetAlignment: Alignment, of target: Target) -> some View {
        let hGuide = HorizontalAlignment(Alignment.TwoSided.self)
        let vGuide = VerticalAlignment(Alignment.TwoSided.self)
        return alignmentGuide(hGuide) {$0[originAlignment.horizontal]}
            .alignmentGuide(vGuide) {$0[originAlignment.vertical]}
            .overlay(alignment: Alignment(horizontal: hGuide, vertical: vGuide)) {
                target
                    .alignmentGuide(hGuide) {$0[targetAlignment.horizontal]}
                    .alignmentGuide(vGuide) {$0[targetAlignment.vertical]}
            }
    }
}

extension Alignment {
    enum TwoSided: AlignmentID {
        static func defaultValue(in context: ViewDimensions) -> CGFloat { 0 }
    }
}

You would use that like this:

referenceView
  .overlay(align: .bottom, to: .top, of: popoverContent)