SwiftUI Layout & Containers
The layer between "stacks and spacers" and "it scrolls like butter with 100k rows" — Apple's
Layout protocol, container composition, and the lazy-stack rules from the WWDC26 deep dive.
View identity/data-flow questions route to swiftui/data-flow.
When This Skill Activates
- "Make these buttons equal width" / measurement-dependent layout
- Building a reusable container (custom List/board/carousel) that should accept ForEach + sections
- Lazy stack jank, memory growth, scroll-position bugs, broken scroll targeting
- Programmatic scrolling, paging/snapping, scroll-linked effects
- GeometryReader causing layout loops or mangled sizing
Custom Layout protocol (not GeometryReader)
Reach for a custom Layout whenever you must measure subviews and feed the measurement back
into layout — GeometryReader only measures its container and can't influence the engine.
Canonical case: equal-width buttons.
sizeThatFits: propose .unspecified to read each subview's ideal size
(subviews.map { $0.sizeThatFits(.unspecified) }); guard empty subviews;
replacingUnspecifiedDimensions() for nil proposal dimensions.
placeSubviews: never assume origin (0,0) — use bounds.minX/midX (non-zero origins are
what make layouts composable); place(at:anchor:proposal:) with a proposal that may differ
from the ideal size (that's how equal widths happen).
- Respect spacing preferences:
subviews[i].spacing.distance(to:along:), taking the larger
of conflicting preferences — matching built-in containers. No hardcoded 8s.
- Per-subview data via
LayoutValueKey (+ a layoutValue convenience modifier), read as
subview[Key.self].
- Cache only after Instruments shows layout cost — it's an optimization, not a requirement.
- Switch layouts without killing identity:
AnyLayout(HStackLayout()) ↔ custom layout with
.animation(_:value:) — SwiftUI sees one changing view, so state survives and it animates.
- Don't build fallbacks into the layout — wrap alternatives in
ViewThatFits.
Grid decisions
| Need |
Use |
| Static 2D with cross-row alignment |
Grid/GridRow (+ gridCellColumns to span, gridColumnAlignment per column) |
| Scrollable, large content |
LazyVGrid/LazyHGrid (only visible views load; one axis fixed up front) |
| "First arrangement that fits" |
ViewThatFits |
Custom containers (Demystify Containers)
Make containers that compose like List does:
- API shape: a trailing
@ViewBuilder var content: Content — callers can then mix static
views, ForEach, and conditionals.
- Iterate resolved children with
ForEach(subviews: content); need the whole collection
(count/chunking)? Group(subviews: content) { subviews in … }.
- Internalize declared vs resolved: one declared ForEach resolves to N subviews; Group to
its children; EmptyView to zero;
if conditionally. Counting declared views is a bug.
- Sections are opt-in:
ForEach(sections: content), reading section.header /
section.content; check header.isEmpty before rendering the slot.
- Per-child customization via container values:
extension ContainerValues { @Entry var … },
set with a convenience modifier, read via subview.containerValues. Scoping model:
Environment flows down · Preferences flow up · container values reach only the direct
container. Setting one on a Section styles the whole section.
Lazy stacks & scrolling performance (WWDC26 rules)
LazyVStack builds views only until the viewport fills; totals and offsets are estimated
from average placed-view size and corrected as you scroll. Everything below follows from that:
- One subview per ForEach element, always. An
if inside a row (0-or-1 views) forces the
stack to keep off-screen views + their @State alive to preserve indices — and environment
changes then re-evaluate off-screen bodies. Filter at the data layer (@Query predicate);
gate auth-type conditions outside the stack.
- Never key logic off absolute scroll offset in a lazy stack (
onScrollGeometryChange sees
estimates) — use onScrollTargetVisibilityChange(threshold: 0.8) for visibility triggers.
- Set up in
init, not onAppear (_model = State(initialValue:)): body runs during
prefetch; onAppear fires only on-screen, throwing prefetch work away and causing
post-appearance size jumps. Start async loads in init/task.
- Don't persist meaningful state in row
@State — off-screen views are eventually
released. Hoist (@State var highlighted: Set<ID> outside, @Binding down).
scrollTransition transforms must stay inside the original frame (scale ✅; rotations
escaping the frame make views vanish early).
- Don't drive layout from
onGeometryChange height feedback (content shoves, targeting
breaks) — that's the custom Layout case above.
- Nest
LazyHStack inside LazyVStack freely (unscrolled rows stay unloaded) — but fix child
heights (lineLimit, explicit frames) in the horizontal stacks.
pinnedViews: [.sectionHeaders] pins headers; infinite scroll = trailing
ProgressView().onAppear { fetchNextPage() } after the ForEach.
The scroll API map
- Snapping/paging:
scrollTargetLayout() + scrollTargetBehavior(.viewAligned/.paging).
- Track/control position:
scrollPosition binding; programmatic ScrollPosition +
scrollTo(id:) — works for unloaded targets if IDs map to stable one-subview elements.
- Scroll-linked effects:
scrollTransition (enter/leave viewport) and visualEffect
(geometry without GeometryReader) — details in design/animation-patterns.
- Reactions:
onScrollGeometryChange (fine outside lazy estimation),
onScrollVisibilityChange (autoplay/analytics).
- Performance floor: list/scroll internals were rewritten (WWDC25) — macOS lists ~6× faster at
100k+ rows, and lazy loading works in nested
ScrollView+LazyVStack; profile with the
SwiftUI instrument (performance/swiftui-debugging).
Output Format
Layout review: Symptom | Rule violated | Fix — check the one-subview-per-element rule first
in any lazy-stack complaint; it explains most jank, memory growth, and targeting bugs.
References
1---
2name: layout
3description: SwiftUI layout beyond stacks — the Layout protocol (when custom layout beats GeometryReader), Grid vs lazy grids, custom containers with sections and container values, and lazy-stack/ScrollView performance rules (what breaks laziness, prefetch discipline, scroll APIs). Use when building custom layouts or containers, fixing lazy-stack jank or memory growth, or wiring programmatic/snapping scrolling.
4allowed-tools: [Read, Write, Edit, Glob, Grep]
5last_verified: 2026-07-16
6review_by: 2027-06-22
7---
8
9# SwiftUI Layout & Containers
10
11The layer between "stacks and spacers" and "it scrolls like butter with 100k rows" — Apple's
12Layout protocol, container composition, and the lazy-stack rules from the WWDC26 deep dive.
13View identity/data-flow questions route to `swiftui/data-flow`.
14
15## When This Skill Activates
16
17- "Make these buttons equal width" / measurement-dependent layout
18- Building a reusable container (custom List/board/carousel) that should accept ForEach + sections
19- Lazy stack jank, memory growth, scroll-position bugs, broken scroll targeting
20- Programmatic scrolling, paging/snapping, scroll-linked effects
21- GeometryReader causing layout loops or mangled sizing
22
23## Custom Layout protocol (not GeometryReader)
24
25Reach for a custom `Layout` whenever you must **measure subviews and feed the measurement back
26into layout** — GeometryReader only measures its container and can't influence the engine.
27Canonical case: equal-width buttons.
28
29- `sizeThatFits`: propose `.unspecified` to read each subview's ideal size
30 (`subviews.map { $0.sizeThatFits(.unspecified) }`); guard empty subviews;
31 `replacingUnspecifiedDimensions()` for nil proposal dimensions.
32- `placeSubviews`: never assume origin (0,0) — use `bounds.minX/midX` (non-zero origins are
33 what make layouts composable); `place(at:anchor:proposal:)` with a proposal that may differ
34 from the ideal size (that's how equal widths happen).
35- **Respect spacing preferences**: `subviews[i].spacing.distance(to:along:)`, taking the larger
36 of conflicting preferences — matching built-in containers. No hardcoded 8s.
37- Per-subview data via `LayoutValueKey` (+ a `layoutValue` convenience modifier), read as
38 `subview[Key.self]`.
39- Cache only after Instruments shows layout cost — it's an optimization, not a requirement.
40- **Switch layouts without killing identity**: `AnyLayout(HStackLayout())` ↔ custom layout with
41 `.animation(_:value:)` — SwiftUI sees one changing view, so state survives and it animates.
42- Don't build fallbacks into the layout — wrap alternatives in `ViewThatFits`.
43
44## Grid decisions
45
46| Need | Use |
47|---|---|
48| Static 2D with cross-row alignment | `Grid`/`GridRow` (+ `gridCellColumns` to span, `gridColumnAlignment` per column) |
49| Scrollable, large content | `LazyVGrid`/`LazyHGrid` (only visible views load; one axis fixed up front) |
50| "First arrangement that fits" | `ViewThatFits` |
51
52## Custom containers (Demystify Containers)
53
54Make containers that compose like `List` does:
55
56- API shape: a trailing `@ViewBuilder var content: Content` — callers can then mix static
57 views, `ForEach`, and conditionals.
58- Iterate **resolved** children with `ForEach(subviews: content)`; need the whole collection
59 (count/chunking)? `Group(subviews: content) { subviews in … }`.
60- Internalize **declared vs resolved**: one declared ForEach resolves to N subviews; Group to
61 its children; EmptyView to zero; `if` conditionally. Counting declared views is a bug.
62- Sections are opt-in: `ForEach(sections: content)`, reading `section.header` /
63 `section.content`; check `header.isEmpty` before rendering the slot.
64- Per-child customization via container values: `extension ContainerValues { @Entry var … }`,
65 set with a convenience modifier, read via `subview.containerValues`. Scoping model:
66 **Environment flows down · Preferences flow up · container values reach only the direct
67 container.** Setting one on a `Section` styles the whole section.
68
69## Lazy stacks & scrolling performance (WWDC26 rules)
70
71LazyVStack builds views only until the viewport fills; totals and offsets are **estimated**
72from average placed-view size and corrected as you scroll. Everything below follows from that:
73
74- **One subview per ForEach element, always.** An `if` inside a row (0-or-1 views) forces the
75 stack to keep off-screen views + their `@State` alive to preserve indices — and environment
76 changes then re-evaluate off-screen bodies. Filter at the data layer (`@Query` predicate);
77 gate auth-type conditions *outside* the stack.
78- **Never key logic off absolute scroll offset** in a lazy stack (`onScrollGeometryChange` sees
79 estimates) — use `onScrollTargetVisibilityChange(threshold: 0.8)` for visibility triggers.
80- **Set up in `init`, not `onAppear`** (`_model = State(initialValue:)`): body runs during
81 prefetch; `onAppear` fires only on-screen, throwing prefetch work away and causing
82 post-appearance size jumps. Start async loads in `init`/`task`.
83- **Don't persist meaningful state in row `@State`** — off-screen views are eventually
84 released. Hoist (`@State var highlighted: Set<ID>` outside, `@Binding` down).
85- `scrollTransition` transforms must stay inside the original frame (scale ✅; rotations
86 escaping the frame make views vanish early).
87- Don't drive layout from `onGeometryChange` height feedback (content shoves, targeting
88 breaks) — that's the custom `Layout` case above.
89- Nest `LazyHStack` inside `LazyVStack` freely (unscrolled rows stay unloaded) — but fix child
90 heights (`lineLimit`, explicit frames) in the horizontal stacks.
91- `pinnedViews: [.sectionHeaders]` pins headers; infinite scroll = trailing
92 `ProgressView().onAppear { fetchNextPage() }` after the ForEach.
93
94## The scroll API map
95
96- Snapping/paging: `scrollTargetLayout()` + `scrollTargetBehavior(.viewAligned/.paging)`.
97- Track/control position: `scrollPosition` binding; programmatic `ScrollPosition` +
98 `scrollTo(id:)` — works for unloaded targets *if* IDs map to stable one-subview elements.
99- Scroll-linked effects: `scrollTransition` (enter/leave viewport) and `visualEffect`
100 (geometry without GeometryReader) — details in `design/animation-patterns`.
101- Reactions: `onScrollGeometryChange` (fine outside lazy estimation),
102 `onScrollVisibilityChange` (autoplay/analytics).
103- Performance floor: list/scroll internals were rewritten (WWDC25) — macOS lists ~6× faster at
104 100k+ rows, and lazy loading works in nested `ScrollView`+`LazyVStack`; profile with the
105 SwiftUI instrument (`performance/swiftui-debugging`).
106
107## Output Format
108
109Layout review: `Symptom | Rule violated | Fix` — check the one-subview-per-element rule first
110in any lazy-stack complaint; it explains most jank, memory growth, and targeting bugs.
111
112## References
113
114- https://developer.apple.com/videos/play/wwdc2022/10056/ (Compose custom layouts)
115- https://developer.apple.com/videos/play/wwdc2024/10146/ (Demystify SwiftUI containers)
116- https://developer.apple.com/videos/play/wwdc2026/321/ (Dive into lazy stacks and scrolling)
117- Related skills: `swiftui/data-flow` (identity/ForEach IDs), `performance/swiftui-debugging`, `design/animation-patterns` (scroll-linked effects)