SwiftUI's layout system is declarative, adaptive, and proposal-driven. Most of the time, stacks and spacers are enough. But the moment you need to:
You enter the world of geometry.
Understanding geometry in SwiftUI isn't about memorizing APIs. It's about understanding how layout works and where geometry fits inside that system.
In this article, we’ll cover:
GeometryReaderGeometryProxy (the core abstraction)onGeometryChange (modern observation API)Layout protocolThis is a long-form, architectural deep dive — not just an API tour.
SwiftUI layout follows a three-phase process:
Unlike Auto Layout, SwiftUI does not solve constraints. It negotiates size.
For example:
VStack {
Text("Hello")
Text("World")
}What happens internally?
VStack receives a size proposal from its parent.Text returns its ideal height.VStack stacks them vertically and reports its own size upward.Geometry APIs allow us to inspect the result of that negotiation.
They do not control layout — they observe it.
That distinction is critical.
GeometryReaderGeometryReader is the original way to access layout information in SwiftUI.
GeometryReader { proxy in
Text("Width: \(proxy.size.width)")
}It gives you a GeometryProxy, which contains information about:
GeometryReader expands to fill all available space.
Example:
VStack {
GeometryReader { proxy in
Color.red
}
}Output:
The red view will take all vertical space.
This happens because GeometryReader is itself a layout container.
That’s why it’s powerful — and why it can be intrusive.
GeometryProxy - The Core AbstractionBoth GeometryReader and onGeometryChange expose the same type:
GeometryProxyThis is the heart of SwiftUI geometry.
Think of
GeometryProxyas a read-only snapshot of resolved layout information.
It does not control layout. It reflects it.
Let’s examine its key APIs.
sizeproxy.sizeReturns the resolved size of the view during that layout pass.
Example:
GeometryReader { proxy in
Rectangle()
.fill(.blue)
.frame(height: proxy.size.width * 0.5)
}Output:
Here we’re building a responsive rectangle whose height is half its width.
Important nuance:
frame(in:)This is where things get powerful.
proxy.frame(in: .global)Returns a CGRect representing the view’s position in a specific coordinate space.
Available coordinate spaces:
.local.global.named("custom")Example:
GeometryReader { proxy in
let frame = proxy.frame(in: .global)
Text("Y: \(frame.minY)")
}Output:
This enables:
safeAreaInsetsproxy.safeAreaInsetsReturns safe area values at that layout location.
Example:
GeometryReader { proxy in
VStack {
Text("Top inset: \(proxy.safeAreaInsets.top)")
}
}Output:
Useful for:
Understanding coordinate spaces is essential.
SwiftUI supports three types.
.localproxy.frame(in: .local)Coordinates relative to the GeometryReader.
.globalproxy.frame(in: .global)Coordinates relative to the entire screen.
.named("custom")You can define custom spaces.
ScrollView {
VStack {
GeometryReader { proxy in
let frame = proxy.frame(in: .named("scroll"))
Text("Offset: \(frame.minY)")
}
.frame(height: 100)
}
}
.coordinateSpace(name: "scroll")Output:
Named spaces are ideal for scroll-based effects.
GeometryReader measures its own container.
But what if you need to measure a child view?
This is where PreferenceKey comes in.
struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}Text("Measure me")
.background(
GeometryReader { proxy in
Color.clear
.preference(key: SizePreferenceKey.self,
value: proxy.size)
}
).onPreferenceChange(SizePreferenceKey.self) { size in
print("Size:", size)
}This pattern is foundational for advanced layout techniques.
.onGeometryChange - The Modern WayGeometryReader participates in layout.
.onGeometryChange does not.
It allows you to observe geometry changes without wrapping your view.
struct GeometryChangeExample: View {
@State private var size: CGSize = .zero
var body: some View {
Text("Measure me")
.onGeometryChange(for: CGSize.self) { proxy in
proxy.size
} action: { newSize in
size = newSize
}
}
}API breakdown:
.onGeometryChange(for: Value.Type) { proxy in
// extract value
} action: { value in
// react to change
}Why this matters:
struct ModernScrollOffset: View {
@State private var offset: CGFloat = 0
var body: some View {
ScrollView {
VStack {
Text("Header")
.font(.largeTitle)
.opacity(offset < -50 ? 0 : 1)
.onGeometryChange(for: CGFloat.self) { proxy in
proxy.frame(in: .global).minY
} action: { value in
offset = value
}
ForEach(0..<50) { index in
Text("Row \(index)")
.padding()
.frame(maxWidth: .infinity)
}
}
}
}
}Output:
Notice:
GeometryReader vs .onGeometryChange| Feature | GeometryReader |
.onGeometryChange |
|---|---|---|
| Participates in layout | Yes | No |
| Expands to fill space | Yes | No |
| Custom layout logic | Yes | No |
| Pure observation | Not ideal | Ideal |
| Modern direction | Legacy-heavy | Preferred |
Rule of thumb:
GeometryReader when building layout..onGeometryChange when observing layout.Layout ProtocolSince iOS 16, SwiftUI provides the Layout protocol for custom layout containers.
Instead of geometry hacks, you can write:
struct EqualWidthLayout: Layout {
func sizeThatFits(proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()) -> CGSize {
let width = proposal.width ?? 0
let height = subviews
.map { $0.sizeThatFits(.unspecified).height }
.reduce(0, +)
return CGSize(width: width, height: height)
}
func placeSubviews(in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()) {
var y = bounds.minY
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
subview.place(
at: CGPoint(x: bounds.minX, y: y),
proposal: ProposedViewSize(width: bounds.width,
height: size.height)
)
y += size.height
}
}
}
struct LayoutTestView: View {
var body: some View {
EqualWidthLayout {
Text("Short")
.padding()
.background(.blue.opacity(0.3))
.border(.blue)
Text("This is a much longer piece of text")
.padding()
.background(.green.opacity(0.3))
.border(.blue)
Text("Medium length")
.padding()
.background(.orange.opacity(0.3))
.border(.blue)
}
.padding()
.border(.red)
}
}Output:
Modern SwiftUI encourages:
Layout.onGeometryChangeGeometryReader → edge casesGeometryReaderLeads to unintended expansion.
If geometry updates state that affects layout, you can trigger infinite re-layout cycles.
Be cautious with:
.onGeometryChange { value in
self.size = value
}If size affects layout, you may cause continuous updates.
Stacks + Spacer solve most layout needs.
Geometry is not the default tool.
Here’s the refined mental model:
GeometryProxy gives you a snapshot of the result.GeometryReader embeds geometry into layout..onGeometryChange observes layout changes.Layout customizes layout behavior.Geometry is not about controlling layout.
It’s about understanding it.
Most SwiftUI layout bugs don’t come from SwiftUI.
They come from misunderstanding:
Once you internalize:
GeometryProxy as read-only snapshotYou stop fighting SwiftUI — and start working with it.
Geometry in SwiftUI is powerful.
Thank you for reading. If you have any questions, feel free to follow me on X and send me a DM. If you enjoyed this article and would like to support my work, Buy me a coffee ☕️