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
onGeometryChangedoes 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.