5.12 — Environment, PreferenceKey & GeometryReader

Opening scenario

Three problems that look unrelated until they aren’t:

  1. Data flowing down: every screen needs the user’s locale + theme + auth state. Passing them through every initializer is hell.
  2. Data flowing up: a tab bar at the bottom of the screen needs to know which tab is selected by a deeply nested child view, and animate an indicator to its frame.
  3. Layout that depends on geometry: a custom chart needs to position labels at calculated points; a card needs to know its own width to choose between layouts.

SwiftUI’s answer for each:

  • Down: Environment — implicit context that flows from parent to all descendants.
  • Up: PreferenceKey — children publish values, ancestors collect them.
  • Geometry: GeometryReader, coordinateSpace, alignmentGuide, onGeometryChange.

These three primitives unlock most of “this is hard to do” in SwiftUI. Use them, but don’t reach for GeometryReader first — it’s the most-abused tool in the kit.

NeedTool
Parent → all descendantsEnvironment (@Entry, EnvironmentValues)
Child → ancestor (single or aggregated values)PreferenceKey
Read view’s size/positiononGeometryChange(for:of:action:)
Calculate layout from container sizeGeometryReader (sparingly)
Align views across a stackalignmentGuide
Coordinate frames across the hierarchycoordinateSpace(name:) + GeometryProxy.frame(in:)

Concept → Why → How → Code

Environment — implicit downward data flow

We covered the basics in chapters 5.3 and 5.4. The full picture:

Built-in environment values:

@Environment(\.colorScheme) var scheme           // .light / .dark
@Environment(\.horizontalSizeClass) var hSize    // .compact / .regular
@Environment(\.dynamicTypeSize) var dyn
@Environment(\.locale) var locale
@Environment(\.timeZone) var tz
@Environment(\.calendar) var cal
@Environment(\.layoutDirection) var dir          // .leftToRight / .rightToLeft
@Environment(\.scenePhase) var phase             // .active / .inactive / .background
@Environment(\.isEnabled) var enabled
@Environment(\.editMode) var editMode
@Environment(\.dismiss) var dismiss              // action
@Environment(\.openURL) var openURL              // action
@Environment(\.openWindow) var openWindow        // action (Mac/iPad)
@Environment(\.refresh) var refresh              // action (in refreshable scope)
@Environment(\.modelContext) var ctx             // SwiftData
@Environment(MyObservable.self) var store        // Observable type

Custom environment values (Swift 6 @Entry macro):

extension EnvironmentValues {
    @Entry var theme: Theme = .default
    @Entry var analytics: Analytics = .noop
}

// Inject
ContentView()
    .environment(\.theme, currentTheme)
    .environment(\.analytics, AppAnalytics())

// Read
@Environment(\.theme) var theme

Before @Entry (iOS 17 and earlier), you wrote a verbose EnvironmentKey conformance. @Entry collapses it to one line.

When to use Environment vs preferences vs explicit parameters

Environment: values used by many descendants, often cross-cutting (theme, locale, analytics, services, current user).

Explicit parameters: values used by one specific child, especially business data. Pass note: Note to NoteEditor — don’t put it in environment.

Preferences: values flowing up from children to ancestors.

A common abuse: putting domain models in environment (“the current selected note”). Use explicit binding or routing for that; environment for cross-cutting concerns.

PreferenceKey — child → ancestor

A PreferenceKey defines a type-keyed value that children write and ancestors read:

struct TabFrameKey: PreferenceKey {
    static var defaultValue: [Int: CGRect] = [:]

    static func reduce(value: inout [Int: CGRect], nextValue: () -> [Int: CGRect]) {
        value.merge(nextValue(), uniquingKeysWith: { _, new in new })
    }
}

// Child publishes
TabButton(index: 0)
    .background {
        GeometryReader { proxy in
            Color.clear
                .preference(
                    key: TabFrameKey.self,
                    value: [0: proxy.frame(in: .named("tabbar"))]
                )
        }
    }

// Ancestor reads
HStack { ... }
    .coordinateSpace(name: "tabbar")
    .onPreferenceChange(TabFrameKey.self) { frames in
        self.tabFrames = frames
    }

Use cases:

  • Tab indicator that animates to selected tab’s frame
  • Synchronized heights across columns (matching tallest)
  • Scroll position aggregation
  • Custom badge/popover anchor points
  • Title published from inner views (navigationTitle uses this internally)

reduce — combining multiple children’s values

If multiple subviews write the same key, reduce merges them. For dictionary keys, merge by id. For single values, use min/max/sum:

struct MaxHeightKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = max(value, nextValue())
    }
}

GeometryReader — read container size

GeometryReader { proxy in
    let width = proxy.size.width
    HStack(spacing: 0) {
        Rectangle().frame(width: width * 0.3)
        Rectangle().frame(width: width * 0.7)
    }
}

GeometryReader’s catch: it claims all available space in both dimensions (greedy), which breaks intrinsic sizing. You wrap content in a GeometryReader and suddenly the parent thinks it wants the whole screen.

Patterns that work:

  • GeometryReader inside .background { ... } or .overlay { ... } — these don’t affect the host view’s size
  • GeometryReader filling a known-size container (full-screen views, fixed-frame containers)

Patterns that break:

  • GeometryReader as the root of a reusable component — it greedily expands
  • GeometryReader inside a List row — wreaks havoc

onGeometryChange(for:of:action:) — modern replacement

iOS 17.1+/macOS 14.1+: prefer onGeometryChange over GeometryReader for many cases:

@State private var width: CGFloat = 0

ContentView()
    .onGeometryChange(for: CGFloat.self) { proxy in
        proxy.size.width
    } action: { newWidth in
        self.width = newWidth
    }

No layout-greediness; callback fires when value changes. Use this whenever you only need geometry as data to drive state, not as direct layout.

coordinateSpace(name:) and frame(in:)

Coordinate spaces let you measure positions/sizes in the frame of an ancestor:

ScrollView {
    LazyVStack {
        ForEach(items) { item in
            ItemRow(item: item)
                .background {
                    GeometryReader { proxy in
                        Color.clear
                            .preference(
                                key: ItemFrameKey.self,
                                value: proxy.frame(in: .named("scroll"))
                            )
                    }
                }
        }
    }
}
.coordinateSpace(name: "scroll")
.onPreferenceChange(ItemFrameKey.self) { frame in
    // y is relative to ScrollView's content origin
}

Common coordinate spaces:

  • .local — view’s own
  • .global — screen
  • .named("…") — custom

Modern (iOS 17+): .coordinateSpace(.named("scroll")) and proxy.frame(in: .named("scroll")). Earlier: same API with string "scroll".

alignmentGuide — custom alignment

HStack(alignment: .myAlignment) {
    Text("Label")
        .alignmentGuide(.myAlignment) { d in d[VerticalAlignment.center] }
    Image(systemName: "star")
        .alignmentGuide(.myAlignment) { d in d[.bottom] }
}

extension VerticalAlignment {
    private struct MyAlignment: AlignmentID {
        static func defaultValue(in d: ViewDimensions) -> CGFloat { d[.center] }
    }
    static let myAlignment = VerticalAlignment(MyAlignment.self)
}

Use when you need precise alignment that built-in .top/.center/.bottom/.firstTextBaseline/.lastTextBaseline don’t cover (e.g., align checkmark of a checkbox with first line of a multi-line label).

matchedGeometryEffect — synchronized geometry (recap from 5.7)

Cross-references geometry between two views with the same id+namespace. Internally uses preferences and the rendering pipeline; you don’t need to manage preferences manually.

Layout protocol — custom layouts (iOS 16+)

For when stacks don’t fit and you need full control:

struct EqualWidthHStack: Layout {
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        guard !subviews.isEmpty else { return .zero }
        let maxWidth = subviews.map { $0.sizeThatFits(.unspecified).width }.max() ?? 0
        let totalWidth = maxWidth * CGFloat(subviews.count)
        let maxHeight = subviews.map { $0.sizeThatFits(.unspecified).height }.max() ?? 0
        return CGSize(width: totalWidth, height: maxHeight)
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        let width = bounds.width / CGFloat(subviews.count)
        for (index, subview) in subviews.enumerated() {
            let x = bounds.minX + CGFloat(index) * width + width / 2
            subview.place(at: CGPoint(x: x, y: bounds.midY), anchor: .center, proposal: .init(width: width, height: bounds.height))
        }
    }
}

// Use
EqualWidthHStack {
    Button("Yes") { }
    Button("No") { }
    Button("Maybe") { }
}

Layout protocol is the answer for custom containers (flow layouts, radial menus, masonry grids). Animatable via AnimatableData.

Worked example: tab indicator that follows selected tab

struct TabBar: View {
    @Binding var selection: Int
    @State private var frames: [Int: CGRect] = [:]
    @Namespace private var ns

    var body: some View {
        HStack(spacing: 0) {
            ForEach(0..<3) { idx in
                Button(action: { selection = idx }) {
                    Text(tab(for: idx).title)
                        .padding()
                }
                .background {
                    GeometryReader { proxy in
                        Color.clear
                            .preference(key: TabFrameKey.self, value: [idx: proxy.frame(in: .named("tabbar"))])
                    }
                }
            }
        }
        .coordinateSpace(name: "tabbar")
        .onPreferenceChange(TabFrameKey.self) { frames = $0 }
        .overlay(alignment: .bottomLeading) {
            if let frame = frames[selection] {
                Rectangle()
                    .frame(width: frame.width, height: 2)
                    .offset(x: frame.minX)
                    .animation(.spring, value: selection)
            }
        }
    }
}

The indicator reads each tab’s frame via preference, then renders an underline at the selected tab’s position. Animates because selection change drives .animation.

In the wild

  • Apple’s navigationTitle uses PreferenceKey internally — the title set inside the destination flows up to the container.
  • TabView indicator in iOS 17+ uses preference-based geometry for the underline.
  • Apple’s Charts framework uses extensive PreferenceKey to position axis labels, gridlines, and annotations relative to the chart area.
  • Pointer-style hover effects in Mac SwiftUI use onGeometryChange to track hover bounds.
  • Custom date pickers with calendar grids use Layout protocol for week/month arrangements.

Common misconceptions

  1. GeometryReader is the answer to all sizing problems.” No — it’s greedy and breaks intrinsic sizes. Prefer onGeometryChange for size-as-data needs.
  2. PreferenceKey is obscure.” It’s how half of SwiftUI’s internals work (navigationTitle, toolbar, tab, searchable). Worth understanding.
  3. “Environment is for any shared data.” No — environment for cross-cutting concerns, explicit params for view-specific data. Domain models often don’t belong in environment.
  4. alignmentGuide is for spacing.” No — it’s for defining a custom alignment line that children align to. Use padding/spacing for spacing.
  5. Layout protocol is too complex; just nest stacks.” Sometimes — but for non-orthogonal layouts (flow, radial, masonry), Layout is cleaner and more performant than 5 levels of conditional stacks.

Seasoned engineer’s take

Environment is leverage — design your app to inject services/state from the top once. Test by injecting mock environments. The dependency injection story in SwiftUI is Environment; embrace it.

PreferenceKey reads as scary the first time; after 5 uses, it’s another tool. Common pattern: a child needs to publish “I have computed this value” to an ancestor. Examples: dynamic content height (auto-sizing sheets), custom anchor points, sync layouts.

GeometryReader is overused. Reach for it last. Almost always, the better answer is: a smarter layout (use Layout protocol), onGeometryChange (for state-driven needs), or a PreferenceKey (for sibling/ancestor coordination). I’ve inherited codebases where every other view starts with GeometryReader { proxy in ... } and the apps are unusably slow and unmaintainable.

alignmentGuide is niche but powerful — when you need it, nothing else works.

Layout protocol is severely underused. Most teams keep building nested HStack/VStack/ZStack pyramids when a 30-line Layout would be cleaner, more performant, and more flexible.

TIP: When debugging preferences, set a .onPreferenceChange with a print(value) to see what flows up. Often the issue is reduce being wrong or the child not firing at the expected time.

WARNING: GeometryReader returns a proxy.size that’s the proposal SwiftUI passed it. If parent geometry is wrong (e.g., from a misuse upstream), GeometryReader propagates the wrong value. Read sizes via onGeometryChange and verify them.

Interview corner

Junior-level: “What’s the difference between Environment and @State?”

@State is private to a view (and its body’s reads). @Environment reads values injected by an ancestor — implicit dependency injection from any height of the view tree. Both trigger re-render on change. @State for “data only this view owns”; @Environment for “cross-cutting context provided by parents”.

Mid-level: “Walk me through implementing a tab bar where a colored indicator slides to the selected tab.”

Wrap the tab bar in HStack with .coordinateSpace(name: "tabbar"). Each tab button publishes its frame (in the tabbar coordinate space) via a PreferenceKey whose value is [TabID: CGRect] (reduce by merging dicts). The container reads the aggregated preference in .onPreferenceChange and stores it in @State frames: [TabID: CGRect]. An .overlay(alignment: .bottomLeading) renders an indicator at frames[selection]?.minX with width frames[selection]?.width. Wrap the indicator in .animation(.spring, value: selection) for smooth movement.

Senior-level: “A team’s app uses GeometryReader everywhere and is slow + janky. Plan to fix it.”

  1. Identify GeometryReader usages: greedy frame impact, hot-path uses (inside List/ForEach rows), nested usages.
  2. Categorize:
    • State-driven needs (“react to size changes”) → replace with onGeometryChange(for:of:action:) (iOS 17.1+). No greediness, fires only on change.
    • Layout-driven needs (“place children based on container”) → replace with Layout protocol (custom container) or built-in containers (Grid, GridRow, ViewThatFits, ZStack with anchored alignment).
    • Sibling coordination (“child A wants to know B’s size”)PreferenceKey from each child, aggregated by ancestor.
    • Cross-hierarchy positioning (“anchor a popover to a deep child”)anchorPreference + overlayPreferenceValue (the anchor-based preference APIs).
    • Genuine geometry calculations (e.g., charts) → keep GeometryReader but isolate inside .background or .overlay to avoid greediness.
  3. Replace GeometryReader { proxy in proxy.size.width * 0.3 } patterns with proper layout (HStack with frame(maxWidth:) proportional sizing using layoutPriority or Layout protocol).
  4. Audit List rows: any GeometryReader inside cells should be removed — measure outside or use onGeometryChange.
  5. Benchmark: Instruments → SwiftUI template → look at “View body” calls. After refactor, body counts should drop dramatically.

Red flag in candidates: Saying “GeometryReader is fine, just use it everywhere.” Or never having heard of PreferenceKey.

Lab preview

Lab 5.2 uses PreferenceKey for chart axis labels, and onGeometryChange for the dashboard layout. Lab 5.4 uses Environment for theme injection in the component library.


Next: Accessibility