5.12 — Environment, PreferenceKey & GeometryReader
Opening scenario
Three problems that look unrelated until they aren’t:
- Data flowing down: every screen needs the user’s locale + theme + auth state. Passing them through every initializer is hell.
- 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.
- 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.
| Need | Tool |
|---|---|
| Parent → all descendants | Environment (@Entry, EnvironmentValues) |
| Child → ancestor (single or aggregated values) | PreferenceKey |
| Read view’s size/position | onGeometryChange(for:of:action:) |
| Calculate layout from container size | GeometryReader (sparingly) |
| Align views across a stack | alignmentGuide |
| Coordinate frames across the hierarchy | coordinateSpace(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:
GeometryReaderinside.background { ... }or.overlay { ... }— these don’t affect the host view’s sizeGeometryReaderfilling a known-size container (full-screen views, fixed-frame containers)
Patterns that break:
GeometryReaderas the root of a reusable component — it greedily expandsGeometryReaderinside aListrow — 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
navigationTitleusesPreferenceKeyinternally — 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
PreferenceKeyto position axis labels, gridlines, and annotations relative to the chart area. - Pointer-style hover effects in Mac SwiftUI use
onGeometryChangeto track hover bounds. - Custom date pickers with calendar grids use
Layoutprotocol for week/month arrangements.
Common misconceptions
- “
GeometryReaderis the answer to all sizing problems.” No — it’s greedy and breaks intrinsic sizes. PreferonGeometryChangefor size-as-data needs. - “
PreferenceKeyis obscure.” It’s how half of SwiftUI’s internals work (navigationTitle,toolbar,tab,searchable). Worth understanding. - “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.
- “
alignmentGuideis for spacing.” No — it’s for defining a custom alignment line that children align to. Usepadding/spacingfor spacing. - “
Layoutprotocol is too complex; just nest stacks.” Sometimes — but for non-orthogonal layouts (flow, radial, masonry),Layoutis 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
.onPreferenceChangewith aprint(value)to see what flows up. Often the issue is reduce being wrong or the child not firing at the expected time.
WARNING:
GeometryReaderreturns aproxy.sizethat’s the proposal SwiftUI passed it. If parent geometry is wrong (e.g., from a misuse upstream),GeometryReaderpropagates the wrong value. Read sizes viaonGeometryChangeand 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.”
- Identify
GeometryReaderusages: greedy frame impact, hot-path uses (insideList/ForEachrows), nested usages. - 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
Layoutprotocol (custom container) or built-in containers (Grid,GridRow,ViewThatFits,ZStackwith anchored alignment). - Sibling coordination (“child A wants to know B’s size”) →
PreferenceKeyfrom 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
GeometryReaderbut isolate inside.backgroundor.overlayto avoid greediness.
- State-driven needs (“react to size changes”) → replace with
- Replace
GeometryReader { proxy in proxy.size.width * 0.3 }patterns with proper layout (HStackwithframe(maxWidth:)proportional sizing usinglayoutPriorityorLayoutprotocol). - Audit
Listrows: anyGeometryReaderinside cells should be removed — measure outside or useonGeometryChange. - 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