SwiftUI onGeometryChange in iOS 26: Replace GeometryReader for Efficient Size and Frame Tracking

onGeometryChange replaces GeometryReader in iOS 26 for observing view size, frames, safe-area insets, and scroll offsets. Fewer layout passes, no preference-key sandwich, and no full-frame container swallowing your view.

SwiftUI onGeometryChange Guide (iOS 26)

Updated: August 5, 2026

onGeometryChange is a SwiftUI view modifier introduced in iOS 18 and refined in iOS 26 that reports a view's size, position, or safe-area insets to you only when the value you actually care about has changed. It replaces GeometryReader for most layout-observation tasks without the layout-loop, sizing, and re-rendering costs that made GeometryReader such a well-known SwiftUI footgun. Honestly, if you've been reaching for GeometryReader just to grab a width or track a scroll offset, this is the modern replacement Apple wants you to use.

I hit this exact issue shipping an iPad app last year: a single GeometryReader wrapped around a card view was quietly stretching it to fill the screen and firing its closure on every single layout pass. Swapping in onGeometryChange shaved noticeable frame-drops off the scroll perf and, better yet, deleted about 25 lines of preference-key glue code.

  • onGeometryChange(for:of:action:) ships on iOS 18+, iPadOS 18+, macOS 15+, watchOS 11+, tvOS 18+, and visionOS 2+, and is the recommended replacement for GeometryReader in iOS 26.
  • Unlike GeometryReader, onGeometryChange doesn't change your view hierarchy or force a container to fill all available space. It observes geometry without imposing layout.
  • The transform closure extracts an Equatable value from a GeometryProxy, and the action fires only when that value actually changes, avoiding wasted work on every layout pass.
  • Pair it with onScrollGeometryChange(for:of:action:) for scroll offset, content size, and visible-bounds tracking inside a ScrollView.
  • Use named coordinate spaces to translate frames between ancestors, and watch proxy.safeAreaInsets when adapting to Dynamic Island, keyboard, or hardware bezels.
  • GeometryReader isn't deprecated. Keep it when you truly need to lay out children based on the parent's size (custom charts, radial layouts), but reach for onGeometryChange for pure observation.

What is onGeometryChange in SwiftUI?

onGeometryChange is a View modifier that takes an Equatable type, a transform closure that extracts a value of that type from a GeometryProxy, and an action closure that fires whenever the extracted value changes. You get the same view-geometry information a GeometryReader would give you (size, frame in a coordinate space, safe-area insets, container-relative frames), but without wrapping the view in a proposal-swallowing container, and without invoking your callback on every layout pass.

The signature you'll use nearly every time looks like this:

func onGeometryChange<T: Equatable>(
    for type: T.Type,
    of transform: @escaping (GeometryProxy) -> T,
    action: @escaping (T) -> Void
) -> some View

A minimal example that reports the view's rendered size to a @State variable:

import SwiftUI

struct SizeReporter: View {
    @State private var size: CGSize = .zero

    var body: some View {
        Text("Rendered size: \(Int(size.width)) x \(Int(size.height))")
            .padding()
            .frame(maxWidth: .infinity)
            .background(.thinMaterial, in: .rect(cornerRadius: 12))
            .onGeometryChange(for: CGSize.self) { proxy in
                proxy.size
            } action: { newSize in
                size = newSize
            }
    }
}

Because the transform returns CGSize (which is Equatable), the action only fires when width or height actually changes. Rotating the device or resizing on iPad triggers exactly one update, not one per layout pass. That's the entire mental model: extract the smallest Equatable value you need, and let SwiftUI diff it for you.

Why GeometryReader was a performance problem

GeometryReader is a container view. When you wrap something in it, three things happen that most developers don't want. First, it accepts the largest size its parent will offer, then it lays out its children in the top-left corner using its own coordinate space. So an unwrapped Text or Image stops sizing itself and instead gets pinned to a corner of a full-frame container. Second, because it's a real view in the hierarchy, it participates in every layout pass and its closure re-runs on each of them, whether the numbers changed or not. Third, because layout results are consumed synchronously inside the same pass that produces them, using proxy.size to drive @State triggers the classic "Modifying state during view update" runtime warning unless you carefully hop out with an onAppear or a preference key.

The pre-iOS 18 workaround was a preference-key sandwich: define a PreferenceKey, publish the size from a background GeometryReader, and read it with onPreferenceChange. It worked, but it required roughly 15 lines of boilerplate per observed value. Apple's official onGeometryChange documentation is explicit: use this modifier when you want to observe geometry, and keep GeometryReader only for cases where the parent's size must drive layout of children. For a deeper look at SwiftUI's layout system and where measurement fits in, our SwiftUI Custom Layout protocol guide walks through when a genuine layout container beats a measurement modifier.

onGeometryChange vs GeometryReader: side-by-side

The table below summarizes the practical differences most teams care about when migrating. Both APIs still exist and are supported, so the choice is about intent, not deprecation.

Dimension onGeometryChange GeometryReader
AvailabilityiOS 18 / iPadOS 18 / macOS 15 / watchOS 11 / tvOS 18 / visionOS 2 and lateriOS 13+ (all platforms)
Affects layout?No, pure modifier, keeps the view sizing itselfYes, fills the offered space and top-left-anchors children
When action firesOnly when the extracted Equatable value changesClosure re-runs on every layout pass
Preferred useObserving size, frames, safe areas, container metricsCustom drawings, radial layouts, math-driven child positioning
State update safetySafe, action runs outside the layout passRequires preference key or async dispatch
Boilerplate1 modifier, 1 closure, no extra types~15 lines with PreferenceKey for safe observation
Coordinate spacesSupports named, global, and local via proxy.frame(in:)Same, GeometryProxy is identical
Companion for scroll viewsonScrollGeometryChange for offset/content sizeRequires manual offset tracking

How do you get view size in SwiftUI without GeometryReader?

The most common reason to reach for GeometryReader is reading a view's width or height so you can compute a font size, layout ratio, or animation distance. That's exactly what onGeometryChange was designed for. Here's a slightly richer example that renders a rounded card whose corner radius scales with its width, driven entirely by observation:

struct AdaptiveCard: View {
    let title: String
    @State private var width: CGFloat = 0

    private var cornerRadius: CGFloat {
        max(12, width * 0.06)
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text(title).font(.headline)
            Text("Corner radius: \(Int(cornerRadius))")
                .font(.caption)
                .foregroundStyle(.secondary)
        }
        .padding(20)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(.regularMaterial, in: .rect(cornerRadius: cornerRadius))
        .onGeometryChange(for: CGFloat.self) { proxy in
            proxy.size.width
        } action: { newWidth in
            width = newWidth
        }
    }
}

Two subtle wins here. First, the transform returns a CGFloat rather than the whole CGSize, so a height change alone won't retrigger the action (SwiftUI's Equatable diff sees no change). Second, the modifier is applied after the background, which means the observed size is exactly the visible rendered size of the card. Applying onGeometryChange before a .padding() or .frame() would report the pre-modified size, which is occasionally what you want but rarely what you expect. If your card contains animated text and you want to react to typography changes, our SwiftUI contentTransition guide covers the animation side of the coin.

Track a view's frame in a named coordinate space

For anything more complex than "how wide am I", you almost certainly need a frame (origin plus size) expressed relative to some ancestor. GeometryProxy exposes frame(in:), which accepts .global, .local, or a .named(...) coordinate space that you declare on an ancestor with the coordinateSpace(name:) modifier. This is how you build sticky headers, snap-to-position UIs, and any interaction that depends on where a view sits inside its scrolling parent.

struct ScrollAwareRow: View {
    let index: Int
    @State private var yOffset: CGFloat = 0

    var body: some View {
        HStack {
            Text("Row \(index)")
            Spacer()
            Text("y: \(Int(yOffset))")
                .monospacedDigit()
                .foregroundStyle(.secondary)
        }
        .padding()
        .background(.background.secondary, in: .rect(cornerRadius: 10))
        .onGeometryChange(for: CGFloat.self) { proxy in
            proxy.frame(in: .named("feed")).minY
        } action: { newY in
            yOffset = newY
        }
    }
}

struct Feed: View {
    var body: some View {
        ScrollView {
            LazyVStack(spacing: 12) {
                ForEach(0..<30, id: \.self) { i in
                    ScrollAwareRow(index: i)
                }
            }
            .padding()
        }
        .coordinateSpace(.named("feed"))
    }
}

Because the transform returns a scalar CGFloat, the action fires per-row exactly once per unique offset value. Compare that to a GeometryReader-based approach where every row's proxy re-runs on every scroll tick regardless of whether that row moved. The throughput difference is easy to measure in Instruments once you have more than a screenful of rows.

Watch safe-area insets and container size

GeometryProxy exposes safeAreaInsets and, on iOS 17+, container-relative sizing. Both are excellent Equatable candidates for onGeometryChange. A common use case is adapting a bottom action bar to the presence of the software keyboard or the Dynamic Island's cutout without wiring up NotificationCenter observers:

struct ActionBar: View {
    @State private var bottomInset: CGFloat = 0

    var body: some View {
        HStack {
            Button("Save") { }
            Button("Cancel", role: .cancel) { }
        }
        .padding(.vertical, 12)
        .padding(.horizontal, 16)
        .padding(.bottom, bottomInset > 0 ? 0 : 12)
        .background(.bar)
        .onGeometryChange(for: CGFloat.self) { proxy in
            proxy.safeAreaInsets.bottom
        } action: { newInset in
            bottomInset = newInset
        }
    }
}
struct Metrics: Equatable {
    let size: CGSize
    let safeArea: EdgeInsets
}

.onGeometryChange(for: Metrics.self) { proxy in
    Metrics(size: proxy.size, safeArea: proxy.safeAreaInsets)
} action: { m in
    // one atomic update
    self.metrics = m
}

onScrollGeometryChange for ScrollView offsets

In iOS 18 Apple added a scroll-view-specific sibling: onScrollGeometryChange(for:of:action:). It hangs off a ScrollView and reports a ScrollGeometry value that includes contentOffset, contentSize, containerSize, visibleRect, and the current content insets. This is the officially blessed way to react to scroll offset, with no more chained preference keys and no more coordinate-space math for the common cases.

struct ScrollProgressExample: View {
    @State private var progress: CGFloat = 0

    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(0..<100, id: \.self) { i in
                    Text("Item \(i)").padding()
                }
            }
        }
        .onScrollGeometryChange(for: CGFloat.self) { geometry in
            let maxOffset = geometry.contentSize.height - geometry.containerSize.height
            guard maxOffset > 0 else { return 0 }
            return min(1, max(0, geometry.contentOffset.y / maxOffset))
        } action: { _, newProgress in
            progress = newProgress
        }
        .overlay(alignment: .top) {
            ProgressView(value: progress)
                .padding(.horizontal)
        }
    }
}

Note the action closure receives both the old and new values, which is handy for computing deltas without shadow state. If you're new to the modern ScrollView APIs, our SwiftUI ScrollView in iOS 26 guide covers scrollPosition, scrollTransition, and scrollTargetBehavior, all of which pair well with onScrollGeometryChange for building polished feeds and paged carousels.

Real-world patterns: sticky headers, parallax, adaptive layouts

Three patterns cover the majority of real-world uses. Each one used to require a preference key. Each one is now a few lines of onGeometryChange.

Parallax hero image

Read the hero's frame relative to the scroll container and offset the image proportionally:

struct ParallaxHero: View {
    let image: String
    @State private var offset: CGFloat = 0

    var body: some View {
        Image(image)
            .resizable()
            .scaledToFill()
            .frame(height: 280)
            .clipped()
            .offset(y: offset * 0.35)
            .onGeometryChange(for: CGFloat.self) { proxy in
                proxy.frame(in: .named("scroll")).minY
            } action: { y in
                offset = y < 0 ? y : 0
            }
    }
}

Sticky section header

Pin a header when its top would otherwise scroll past a threshold. Pure observation, no GeometryReader:

struct StickyHeader: View {
    let title: String
    @State private var isPinned = false

    var body: some View {
        Text(title)
            .font(.title2.bold())
            .padding(.horizontal)
            .padding(.vertical, 10)
            .frame(maxWidth: .infinity, alignment: .leading)
            .background(isPinned ? .bar : .clear)
            .onGeometryChange(for: Bool.self) { proxy in
                proxy.frame(in: .named("scroll")).minY <= 0
            } action: { pinned in
                withAnimation(.easeInOut(duration: 0.15)) {
                    isPinned = pinned
                }
            }
    }
}

Container-relative adaptive layout

Switch between one column and two based on measured width. Cleaner than a hard breakpoint on horizontal size class:

struct AdaptiveGrid<Content: View>: View {
    @ViewBuilder var content: Content
    @State private var columns = 1

    var body: some View {
        LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: columns)) {
            content
        }
        .onGeometryChange(for: Int.self) { proxy in
            proxy.size.width > 600 ? 2 : 1
        } action: { newColumnCount in
            columns = newColumnCount
        }
    }
}

Returning an Int is the trick. The action only fires when you actually cross the breakpoint, not every millisecond during a live resize on iPad or macOS. That single line is why onGeometryChange pays for itself in resize-heavy UIs. When you eventually need to render measured data into an image (screenshots, PDFs), our SwiftUI ImageRenderer guide shows how to combine observation with view-to-image conversion.

Common pitfalls and how to avoid them

The API is small enough that most mistakes come from misunderstanding when the transform runs or what geometry it sees. I've made every one of these myself at least once.

Returning a non-Equatable value

If your extracted type isn't Equatable, the code won't compile. If it is Equatable but has floating-point noise (like a computed vector), every micro-change re-fires the action. Quantize when appropriate: round to the nearest integer, snap to a step, or bucket into ranges.

Reading geometry too early in the pipeline

Modifier order matters. onGeometryChange sees the geometry of the view at the point it is applied. Placing it before layout-altering modifiers reports the pre-modifier size, which is rarely what you want when you're trying to observe the final rendered frame.

// Wrong - reports the intrinsic text size
Text("Hi")
    .onGeometryChange(for: CGSize.self, of: \.size, action: update)
    .padding(40)
    .background(.red)

// Right - reports the padded, backgrounded size
Text("Hi")
    .padding(40)
    .background(.red)
    .onGeometryChange(for: CGSize.self, of: \.size, action: update)

Using it in place of a real Layout

If you're computing child positions from the parent's size (radial menus, ring charts, custom flow layouts), onGeometryChange is the wrong tool. A real Layout implementation (see the Layout protocol reference) both measures and places children in a single pass. Chasing a layout with an observation modifier will introduce a one-frame lag and can jitter during animations.

Forgetting to name the coordinate space

proxy.frame(in: .named("feed")) returns .zero, silently, if no ancestor has declared that coordinate space. When your action fires with all-zero values on first render, the missing .coordinateSpace(.named("feed")) on the ScrollView is almost always the cause.

Observing inside a lazy container without expecting recycling

In a LazyVStack or List, offscreen rows are torn down and rebuilt. Any @State you update from onGeometryChange resets when the row scrolls away and comes back. Move persistent state up to the parent, or store it in a source-of-truth model, if you need it to survive recycling.

Frequently Asked Questions

Is GeometryReader deprecated in iOS 26?

No. GeometryReader is still fully supported in iOS 26 and Xcode won't warn you for using it. Apple's guidance is to prefer onGeometryChange when you only need to observe geometry, and to keep GeometryReader for cases where the parent's size must actively drive child layout.

What is the difference between onGeometryChange and GeometryReader?

onGeometryChange is a pure view modifier. It doesn't change layout, doesn't force the view to fill its parent, and fires its action only when the extracted Equatable value changes. GeometryReader is a container view that takes all offered space, top-left-anchors its children, and re-runs its content closure on every layout pass.

What iOS version is required for onGeometryChange?

onGeometryChange(for:of:action:) requires iOS 18, iPadOS 18, macOS 15, watchOS 11, tvOS 18, or visionOS 2. For older deployment targets, fall back to the GeometryReader + PreferenceKey + onPreferenceChange pattern.

Can onGeometryChange replace GeometryReader for scroll offset tracking?

Yes, either directly by reading proxy.frame(in: .named(...)) on a child, or (even better) via onScrollGeometryChange, which is a scroll-view-specific modifier that reports content offset, content size, container size, and visible bounds without any coordinate-space plumbing.

Why is my onGeometryChange action not firing?

The most common reasons: the extracted value hasn't actually changed (SwiftUI diffs it and skips the action), you placed the modifier before the layout modifiers whose result you wanted, or you're reading frame(in: .named(...)) without declaring that coordinate space on an ancestor, which silently returns .zero.

Editorial Team
About the Author Editorial Team

Our team of expert writers and editors.