Home

Stop reaching for GeometryReader

GeometryReader is the first tool most people find when a SwiftUI layout needs a measurement, and it is usually the wrong one. It has two properties that make it a poor default, and most of its traditional uses now have purpose-built replacements.

Why it is a bad default

It is greedy. GeometryReader accepts whatever size it is offered, in both dimensions. Wrap a Text in one and the text no longer sizes itself — the reader expands to fill, and the text sits in the top-left corner of a much larger box.

VStack {
    GeometryReader { proxy in
        Text("Hello")          // now positioned top-leading in all available space
    }
    Text("World")              // pushed to the bottom
}

This is the source of nearly every “why is there a huge gap in my layout” question.

It participates in the sizing pass. Its content is measured and laid out during layout, and layout runs multiple times per frame — a stack queries its children with several different proposals before deciding anything. Inside a List row, that means the reader’s body is evaluated repeatedly while scrolling, which is a reliable way to make scrolling stutter.

What to use instead

For “fill the container, or a fraction of it”: containerRelativeFrame.

// old
GeometryReader { proxy in
    Card().frame(width: proxy.size.width * 0.8)
}

// new
Card().containerRelativeFrame(.horizontal) { width, _ in width * 0.8 }

No greediness, no measurement pass, and it reads as what it means.

For “line things up across rows”: alignment guides.

This is the case people build with GeometryReader plus a PreferenceKey plus @State, which is three moving parts, a frame of lag, and a possible layout loop. Custom alignment guides do it inside the normal layout pass:

extension HorizontalAlignment {
    private enum ValueColumn: AlignmentID {
        static func defaultValue(in context: ViewDimensions) -> CGFloat { context[.leading] }
    }
    static let valueColumn = HorizontalAlignment(ValueColumn.self)
}

VStack(alignment: .valueColumn) {
    HStack {
        Text("Name")
        Text(user.name).alignmentGuide(.valueColumn) { $0[.leading] }
    }
    HStack {
        Text("Email address")
        Text(user.email).alignmentGuide(.valueColumn) { $0[.leading] }
    }
}

Every value column lines up regardless of label length, with no state and no extra frame.

For “I need to know this view’s size”: onGeometryChange.

Card()
    .onGeometryChange(for: CGSize.self) { proxy in
        proxy.size
    } action: { newSize in
        cardSize = newSize
    }

This reads geometry without changing layout at all — the view sizes itself normally and reports afterwards. That is precisely the thing GeometryReader could never do, and it removes the greedy behaviour entirely.

For scroll position and scroll-driven effects: the scroll APIs.

ScrollView {
    …
}
.scrollPosition(id: $scrolledID)

// per-item effects
Card()
    .scrollTransition { content, phase in
        content.opacity(phase.isIdentity ? 1 : 0.5)
    }

The parallax-header pattern that used to require a GeometryReader reporting a frame in .global coordinates is now visualEffect plus a geometry proxy, with none of the state.

Tip

visualEffect is the underrated one. It gives you a geometry proxy for applying transforms, offsets and opacity, but the effects do not participate in layout — so nothing moves the views around it and nothing reruns the layout pass.

When it is still right

I do not want to overstate this. GeometryReader remains correct for:

  • Drawing that genuinely needs the full size — a custom chart, a canvas, a background shape that must know its bounds.
  • Reading a frame in a specific coordinate space where onGeometryChange does not fit, though that gap keeps narrowing.
  • Anything that should fill its container anyway, where greediness is what you wanted.

The distinction: GeometryReader is a container that fills space and tells you about it. Use it when you want a filling container. Do not use it when you want a measurement, because a measurement is now a different API.

The rule of thumb

Before writing GeometryReader, ask what you actually need:

I need Use
A fraction of the container containerRelativeFrame
Alignment across separate rows custom AlignmentID
This view’s size, after layout onGeometryChange
A scroll-driven visual effect scrollTransition / visualEffect
Which item is scrolled to scrollPosition(id:)
A custom arrangement of children the Layout protocol
To fill the space and draw in it GeometryReader

Only the last row is the tool everyone reaches for first.