A horizontal ScrollView proposes a nil width to its content, which means "tell me your ideal width". A Spacer asked for its ideal answers with its minimum length, so there is no extra space for it to fill.
Three kinds of proposal: a number ("this is what I can offer"), nil ("what is your ideal size?") and .infinity ("take as much as you want"). A scroll container proposes nil along its scroll axis because it has no fixed length there.
Intrinsic vs flexible views: Text and a non-resizable() Image have an ideal size and report it. Spacer, Color and shapes have none, so under nil they shrink to a minimum: a Spacer to its minLength, a Color to 10 pt.
The fix: commit to a number somewhere. Give each card a .frame(width:), or size it against the visible scroll area with containerRelativeFrame(.horizontal). Gaps between cards belong in HStack(spacing:), not in a Spacer.
When a layout collapses to nothing or grows to infinity, ask what the nearest parent proposed.
iOSSenior
SwiftUI Layout, Identity & Rendering
Layout protocol, GeometryReader, PreferenceKey, view identity, Canvas, lazy containers, insets
Questions closed
0 / 21
- 0Review
- 4Asked often
Questions
GeometryReader hands its closure the size it was proposed, but it takes all of that space for itself and pins its content to the top-leading corner, so as a normal child it pushes its siblings away. Use it inside a background or overlay, or use a newer API.
What it does to layout
Greedy: it has no ideal size, accepts the whole proposal and reports it as its own size. In a VStack it takes all the leftover height; along the scroll axis of a ScrollView, where it is proposed nil, it collapses to a default 10 pt.
Top-leading: its content is placed at the top-leading corner, not centred as in a stack.
One pass late: copying proxy.size into @State draws the view a second time with the new value: a flicker at best, an endless layout loop if that value changes the size it was measured from.
Where it is still right
Inside .background { } or .overlay { }, where it gets exactly the host's size and changes nothing.
When you need proxy[anchor] or proxy.frame(in:) to relate the frames of two views.
Newer alternatives
onGeometryChange(for:of:action:) (Xcode 16, back-deploys to iOS 16) reads a value without taking space and fires only when that value changes.
visualEffect { content, proxy in } (iOS 17): scale, offset or fade from geometry with no state at all.
containerRelativeFrame (iOS 17) sizes a view relative to the scroll view or window without measuring.
A PreferenceKey is a typed channel for sending values from descendants up the view tree: children set it with .preference(key:value:), SwiftUI merges the values from all children with the key's reduce, and an ancestor reads the result in onPreferenceChange.
**defaultValue: used when no child sets the key.
reduce(value:nextValue:)**: called once per contributing view in tree order; typical merges are max, sum or append.
Usual pairing: a GeometryReader inside a background writes a size, which is the clean way to measure a view without disturbing layout.
**onPreferenceChange**: needs an Equatable value, so it fires only when the merged value actually changes.
Without a state round-trip: backgroundPreferenceValue, overlayPreferenceValue and anchorPreference read the value directly in the view that needs it.
ViewThatFits asks each child, in order, for its ideal size along the axes you name and shows the first one that fits in the space it was offered. If none fits, it shows the last child.
Order matters: list candidates from richest to most compact. The last one is the fallback and is used even when it does not fit.
**in: picks the axes**: ViewThatFits(in: .horizontal) ignores height, so a candidate may be taller without being rejected. By default both axes are checked.
Ideal size: under a nil proposal a Text reports its full one-line width, so a candidate is rejected as soon as its text would need to wrap or truncate.
State resets on a switch: when a different candidate wins, the old subtree is removed and its @State is lost. Keep state in the view that owns the ViewThatFits and pass bindings down.
Cost: candidates are measured on every layout pass, so keep them cheap.
A size class describes the window, not the space this view actually got. The same toolbar in a sidebar, a split-view pane or at a large Dynamic Type size gets the right answer from ViewThatFits and a wrong one from horizontalSizeClass.
UIViewRepresentable wraps a UIKit view for SwiftUI, and UIHostingController wraps a SwiftUI view as a view controller. Size crosses the border through intrinsicContentSize by default, through sizeThatFits(_:uiView:context:) into SwiftUI and through sizingOptions into Auto Layout.
UIKit inside SwiftUI
The contract: makeUIView runs once per identity, updateUIView after every input change, and a Coordinator holds delegates; SwiftUI owns frame, bounds and center, so never set them.
Default sizing: SwiftUI reads intrinsicContentSize and the hugging and compression-resistance priorities. A view with no intrinsic size takes the whole proposal; a multi-line UILabel asks for its one-line width, so it can end up wider than its column instead of wrapping.
**sizeThatFits(_:uiView:context:)** (iOS 16): receives the ProposedViewSize; measure at the proposed width and return the height, or nil for the default.
SwiftUI inside UIKit
Embed it as a child: addChild, add and constrain the view, didMove(toParent:), so appearance callbacks, traits and the safe area reach the content.
**sizingOptions = .intrinsicContentSize** (iOS 16): the hosting view invalidates its intrinsic size whenever the content's ideal size changes. Without it, a banner given longer text kept its old 20 pt height.
A stack asks each child for one value, its alignment guide, and shifts the children so those values line up. alignmentGuide(_:computeValue:) overrides the value for one child; a custom AlignmentID lets views in different rows share one line.
Built-in guides: .leading, .center, .firstTextBaseline and others. HStack(alignment: .firstTextBaseline) puts a badge level with the first line of a wrapping title instead of the middle of the block.
Overriding: the closure receives ViewDimensions, so the value is computed from the child's own size or other guides, such as d[.firstTextBaseline] - 4, not from a magic number. Alignment only moves views; it never resizes them.
Custom alignment: conform a type to AlignmentID, wrap it in HorizontalAlignment, give it to the outer VStack and set the guide on one view in each row. Values in every row then start at the same x.
The limit: only children of one stack align with each other. Views in two unrelated HStacks cannot align unless the alignment lives on their common ancestor; for a real table, Grid is simpler.
Identity is how SwiftUI tells "the same view, updated" from "a different view": structural identity comes from a view's type and position in body, explicit identity from .id(_:) and ForEach ids. When it changes, the old view is destroyed and a new one starts with fresh state.
Two kinds
Structural: @ViewBuilder turns body into a nested generic type, so every view has a fixed slot. The two branches of an if/else are two different slots; AnyView hides the slot from SwiftUI.
Explicit: .id(value) and the id of each ForEach element override the slot. A new value means a new view, not a changed one.
Identity, lifetime, dependencies
Lifetime is the span during which identity stays the same. @State, @StateObject, @FocusState and a running .task live exactly that long.
Dependencies decide when body re-runs; they never change identity. A new item passed from the parent updates the view in place.
When identity changes
@State restarts from its initial value, @StateObject is rebuilt, .task is cancelled and started again, onAppear fires again, focus and the keyboard are dropped.
The old view leaves with its removal transition while the new one enters: the flash people see after adding .id(item.updatedAt) "to refresh a row".
Use .id on purpose for a reset, such as a clean form per record with .id(record.id), never with a value derived from data that changes.
SwiftUI stores @State against the view's identity and uses the initial value only once. Selecting another item rebuilds the DetailEditor struct in the same slot, so identity is unchanged, State(initialValue:) is ignored and draft keeps the old title.
Why: the struct is a throwaway description recreated on every parent update; the storage behind @State is not. init runs again, but SwiftUI keeps the existing value.
Fix 1, preferred: do not copy the model into local state. Bind to it: @Bindable var item: Item for an @Observable class, or a Binding from the owner. One source of truth.
Fix 2: if a scratch copy is really needed (edit, then Save or Cancel), tie identity to the data: DetailEditor(item: selected).id(selected.id), using the original struct from the question. Each record gets a fresh editor.
Fix 3: .onChange(of: item.id) { draft = item.title }. It works, but you keep two sources of truth in sync by hand, and every new field is a chance to forget one.
Two elements give ForEach the same id, so SwiftUI cannot match old rows to new ones: state, focus and animations attach to the wrong row, and one duplicate may not render at all. The fix belongs in the data; ids must be unique and stable.
Where a row's id comes from
ForEach(items) uses Identifiable.id; ForEach(items, id: \.key) uses that key path; ForEach(0..<n) uses the number and expects a constant range.
The id must be unique within the ForEach and stable across updates.
Typical sources of duplicates
id: \.self over values that can repeat, such as two "swift" tags in a [String].
A retried page request that appends the same page twice, or two feeds merged without prefixing their ids.
A backend whose ids are unique only within one page or one type of object.
Why indices are not the fix
id: \.offset from enumerated() is unique but not stable: deleting row 0 renumbers every row below it, so their state and the delete animation move to the wrong rows.
Assign a stable id where the data enters the app (a server id, "\(source)-\(remoteID)", or a UUID created once), and deduplicate before the array reaches the view.
The if/else inside the helper produces _ConditionalContent<T, Self>, so the highlighted and the plain Counter live in two different branches: two structural identities. Flipping the flag destroys one Counter with its @State and creates the other from scratch.
What else breaks: the switch animates as a removal plus an insertion (a cross-fade instead of a colour change), .task inside restarts, and focus is lost.
The fix: keep a single branch and make the modifier value conditional instead: Counter().foregroundStyle(isHighlighted ? Color.red : Color.primary). The same pattern works for .opacity(isHidden ? 0 : 1), .disabled(flag) or .padding(compact ? 4 : 12).
**When an if is fine**: when the two branches really are different views, or when the condition never changes during the view's lifetime, such as an if #available check.
The helper looks like a modifier but behaves like a switch between two views.
reduce is called once per child that sets the preference, and value = nextValue() overwrites: only the last row's width survives, so labelWidth becomes the width of "Phone" and every label is squeezed into it.
Why it happens: a PreferenceKey is a fold: SwiftUI walks the subtree and calls reduce to combine each child's value into the running one. The default implementation of many tutorials, value = nextValue(), means "last one wins", which is only right when a single view sets the key.
The fix: merge instead of replace. For a shared width that is value = max(value, nextValue()); for a total, value += nextValue(); for several items, append to an array or merge a dictionary keyed by id.
Why it slips through: with one row, or with rows whose last label happens to be the longest, it looks fine. It breaks when the order of the data changes.
Catching it: print the value in onPreferenceChange with the rows in different orders; if it follows the order and not the content, reduce is overwriting.
A lazy stack builds rows a little before they are visible (the exact distance is not documented), and onAppear fires on creation, so it means "built", not "visible". Make the load trigger idempotent or use a real visibility API.
Rebuilds: onAppear can fire again when a row is rebuilt after scrolling away and back.
The pagination bug: "load the next page in the last row's onAppear" fires early and can fire twice, so the same page is requested and appended twice, which then breaks ForEach with duplicate ids.
Make the trigger idempotent: guard it with an isLoading flag and the next page number in the model, so an early or repeated call does nothing.
Real visibility: on iOS 18 use onScrollVisibilityChange(threshold:) on a view, or onScrollTargetVisibilityChange(idType:threshold:) on the scroll view; both report what is actually on screen.
For a few dozen cheap rows an eager VStack is simpler: everything is built and measured up front.
scrollTo looks for the id in the current view tree, and the row you just appended exists only after SwiftUI applies the state change, so the call resolves against the old content. The state-driven alternative is to make the scroll position state with .scrollPosition and scrollTargetLayout().
**.scrollPosition(id:) (iOS 17)**: with .scrollTargetLayout() on the lazy stack, SwiftUI writes the id of the child at the anchor into the binding and scrolls when you write to it.
**ScrollPosition (iOS 18)**: @State var position = ScrollPosition(idType: Message.ID.self) with .scrollPosition($position) scrolls to an id, a point or an edge: position.scrollTo(edge: .bottom).
**.defaultScrollAnchor(.bottom)**: opens a chat at the newest message and keeps it there as content grows, which removes most manual scrolling.
If a scrollTo only works behind DispatchQueue.main.asyncAfter, that is the signal to move the position into state.
ignoresSafeArea offers a larger area to the subtree it is attached to, so on the root it moves all content under the status bar and the home indicator. With no arguments it also ignores the keyboard region, which turns off keyboard avoidance.
Scope it to the layer that should bleed: .background { gradient.ignoresSafeArea() } lets the gradient fill the screen while the content keeps its insets.
Pick regions and edges: .container covers bars, notch and home indicator; .keyboard covers the keyboard. .ignoresSafeArea(.container, edges: .top) is what a hero image wants; .ignoresSafeArea(.keyboard) keeps a background still while the focused field lifts.
**safeAreaInset(edge:)**: adds a bar, such as a Save button or a message composer, to the safe area, so it rides up with the keyboard and the scroll content behind it gets a matching inset; an overlay leaves the last rows hidden under the bar.
**safeAreaPadding(_:) (iOS 17)**: adds your own padding to the safe area itself, so a ScrollView still scrolls edge to edge but its content rests inside the padding.
Never hard-code 34 or 47 points; if you really need the number, read GeometryProxy.safeAreaInsets.
sizeThatFits answers a proposal with the size the container needs, measuring children through LayoutSubview; placeSubviews gets the final bounds and must place every subview exactly once. Both must use the same arithmetic, or children draw outside the size you reported.
sizeThatFits(proposal:subviews:cache:)
You are the parent now: ask each child for its ideal size with subview.sizeThatFits(.unspecified), or pass a concrete proposal to constrain it.
It is called several times per pass with different proposals, including nil and .infinity widths. Never force-unwrap proposal.width; treat nil as "lay everything out in one row".
placeSubviews(in:proposal:subviews:cache:)
bounds does not start at zero: offset every position by bounds.minX and bounds.minY.
Pass place(at:anchor:proposal:) the size you measured, so each child renders exactly as planned.
Cache and extras
makeCache(subviews:) holds work shared by both methods, such as the row breakdown. The default updateCache rebuilds it when the subviews change; if you also key it on the width it was computed for, repeated proposals cost nothing.
subview.priority, subview.spacing and a custom LayoutValueKey let children pass hints to the layout.
AnyLayout switches between, say, FlowLayout() and VStackLayout() inside withAnimation while keeping each child's identity and state.
placeSubviews places the tags starting from (0, 0), but bounds is the region the parent gave the layout, in the parent's coordinate space, and its origin is rarely zero. place(at:) uses that same space, so the tags ignore the header and padding above them.
Why it happens: sizeThatFits only reports a size; where the layout sits is decided by its parent and arrives as bounds.origin. place(at:) is not relative to the layout's own corner, so an offset that is never added is simply lost.
Why it slips through: the wrapping math is correct, because comparing x + width with bounds.width works from zero. The layout looks right whenever its origin happens to be near zero, and breaks as soon as anything sits above it or beside it.
The fix: start at bounds.minX and bounds.minY, wrap when x + width passes bounds.maxX, and reset each row to bounds.minX, not to 0. sizeThatFits stays as it is: it computes a size, so no origin is involved.
Catching it: preview or snapshot-test a custom layout inside .padding(40) with a view above it; a layout that ignores its origin shows up immediately.
compositingGroup() makes a subtree render as one layer before the effects above it (opacity, blend mode, shadow) are applied, which fixes correctness. drawingGroup() goes further and rasterises the subtree offscreen with Metal into one image, a performance tool for heavy vector drawing.
Why grouping is needed
Effects such as .shadow, .opacity and .blendMode applied to a container reach each rendered child separately. One .shadow on a VStack draws a shadow for every text and image inside it; .opacity(0.5) on two overlapping circles lets the overlap show through.
.compositingGroup() placed between the children and the effect composes the children first, so the shadow or fade applies once to the result.
When drawingGroup() helps
Many overlapping shapes, gradients and paths (charts, particle effects, complex animated vector art) that are slow to composite as separate layers.
Confirm it with Instruments first: it moves the cost rather than removing it.
When it hurts
It adds an offscreen render pass and texture memory on every frame where the content changes.
Views backed by UIKit, such as TextField, Map or a UIViewRepresentable, cannot be drawn into it and show a placeholder instead.
For a handful of simple views it is usually slower than normal rendering.
For a card shadow, the cheapest option is to shadow only the background shape: .background { RoundedRectangle(cornerRadius: 12).fill(.background).shadow(radius: 8) }.
Switch when the elements are just pixels: nothing the user taps, focuses or animates one by one. Each Rectangle is a view with identity, layout and hit testing; Canvas is a single view that draws everything into a GraphicsContext in one pass.
What Canvas gives you
One node in the view tree: you get a context and a size and fill Paths, draw images and text. Nothing inside has identity, so there is nothing to diff or lay out.
context.resolve(Text(...)) prepares text once for repeated drawing; the symbols: closure with context.resolveSymbol(id:) lets you draw real SwiftUI views inside.
What you give up
Per-element hit testing: compute it yourself from the tap location.
Per-element transitions and animations: animate the data, not the views.
Accessibility: the canvas is one element, so add a label, or accessibilityChildren for meaningful parts.
Driving it over time
TimelineView(.animation) re-runs its content on the display's refresh and passes context.date. That is much cheaper than a Timer writing @State 60 times a second and invalidating a branch of the tree.
Use views for things that are laid out, tapped or read by VoiceOver; use Canvas for dense drawing.
Resolve the content into its subviews: Group(subviews:) and ForEach(subviewOf:) (iOS 18) turn a @ViewBuilder value into a collection of individual children, with any ForEach inside already expanded.
The problem: a generic Content: View is one opaque value. Before iOS 18 you either took [Data] plus a row builder or used the private _VariadicView API.
**Group(subviews: content) { subviews in }**: gives the whole SubviewsCollection: count, isEmpty, first, last, slicing. Use it when position matters, such as no divider after the last child or an empty container drawing nothing.
**ForEach(subviewOf: content) { subview in }: iterates when every child gets the same decoration.
Identity is kept**: each Subview is Identifiable and keeps the caller's identity, so wrapping it does not reset its @State.
Container values: declare @Entry var isPinned: Bool = false in an extension on ContainerValues, set it from a child with .containerValue(\.isPinned, true), and read subview.containerValues.isPinned in the container.
Once a container needs to know how many children it has, resolve the subviews instead of passing content through.
Expensive layout means the same subtree is measured many times per frame: nested flexible stacks, fixedSize and ViewThatFits ideal-size queries, text re-wrapped for every proposed width, and measurements fed back into state.
Confirm it is layout
Self._printChanges() quiet but frames still missed means the time is not in body.
The SwiftUI instrument shows long body updates; if those are short, use Time Profiler on the main thread to see time inside SwiftUI layout. The Hitches instrument names the frame that missed its deadline. Profile a release build on a device.
Quick test: replace the suspect subtree with Color.clear.frame(width:height:) of the same size. If the hitch disappears, the cost is inside it.
Usual causes
Measure-then-store loops: a GeometryReader writes a PreferenceKey, the parent stores it in @State and resizes the child, which triggers another pass. If the sizes disagree, it never settles.
Repeated ideal-size queries: fixedSize, ViewThatFits measuring every candidate, stacks measuring flexible children more than once, all multiplied by nesting depth.
Long unbounded text: multi-line Text is line-broken again for each width it is offered.
**An eager VStack in a ScrollView: it measures every row before the first frame.
A custom Layout without a cache**: the arrangement is recomputed for every proposal.
Fixes
Fixed or bounded row sizes, lineLimit on long text, GeometryReader only inside a background, alignment guides or a custom Layout instead of measure-and-store, lazy containers for long lists, and a cache in custom layouts.
The AttributeGraph is SwiftUI's private dependency graph: state, environment values, view inputs, body results and layout are nodes, and edges record who read what. A change marks its dependents dirty, and the next update recomputes only those.
How a change travels
Writing @State or an @Observable property invalidates only the views whose body read it during the last evaluation. @Observable tracks reads per property; ObservableObject invalidates every observer on any @Published change.
Recomputation is lazy: dirty nodes are recomputed when the next frame needs them, so many changes in one transaction cost one update.
When a child's body runs
The parent's body re-runs and produces a new child struct. SwiftUI compares it with the previous one field by field; if nothing changed, the child's body is skipped.
Closure properties usually count as changed because SwiftUI cannot compare them, so passing a fresh closure tends to re-run the child.
Conform the view to Equatable and wrap it in .equatable() to make SwiftUI use your ==, for example to ignore a callback.
What it means in practice
Keep views small and pass them only the values they read, so a change invalidates a small part of the graph.
Stable identity keeps nodes alive; lost identity throws a subgraph away and builds a new one.
In Xcode 26 the SwiftUI instrument's Cause & Effect graph shows which change invalidated which view.