Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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.
.claude/skills/rshankras-layout/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 30% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 17% | 0% |
| case-09 | ✗→✓ | ▲ Improved | -3% | 0% |
| case-02 | ✓→✓ | = Same ✓ | 15% | 0% |
| case-03 | ✓→✓ | = Same ✓ | 93% | 0% |
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.
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 arewhat make layouts composable); place(at:anchor:proposal:) with a proposal that may differ from the ideal size (that's how equal widths happen).
subviews[i].spacing.distance(to:along:), taking the largerof conflicting preferences — matching built-in containers. No hardcoded 8s.
LayoutValueKey (+ a layoutValue convenience modifier), read assubview[Key.self].
AnyLayout(HStackLayout()) ↔ custom layout with.animation(_:value:) — SwiftUI sees one changing view, so state survives and it animates.
ViewThatFits.| 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 |
Make containers that compose like List does:
@ViewBuilder var content: Content — callers can then mix staticviews, ForEach, and conditionals.
ForEach(subviews: content); need the whole collection(count/chunking)? Group(subviews: content) { subviews in … }.
its children; EmptyView to zero; if conditionally. Counting declared views is a bug.
ForEach(sections: content), reading section.header /section.content; check header.isEmpty before rendering the slot.
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.
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:
if inside a row (0-or-1 views) forces thestack 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.
onScrollGeometryChange seesestimates) — use onScrollTargetVisibilityChange(threshold: 0.8) for visibility triggers.
init, not onAppear (_model = State(initialValue:)): body runs duringprefetch; onAppear fires only on-screen, throwing prefetch work away and causing post-appearance size jumps. Start async loads in init/task.
@State — off-screen views are eventuallyreleased. Hoist (@State var highlighted: Set<ID> outside, @Binding down).
scrollTransition transforms must stay inside the original frame (scale ✅; rotationsescaping the frame make views vanish early).
onGeometryChange height feedback (content shoves, targetingbreaks) — that's the custom Layout case above.
LazyHStack inside LazyVStack freely (unscrolled rows stay unloaded) — but fix childheights (lineLimit, explicit frames) in the horizontal stacks.
pinnedViews: [.sectionHeaders] pins headers; infinite scroll = trailingProgressView().onAppear { fetchNextPage() } after the ForEach.
scrollTargetLayout() + scrollTargetBehavior(.viewAligned/.paging).scrollPosition binding; programmatic ScrollPosition +scrollTo(id:) — works for unloaded targets if IDs map to stable one-subview elements.
scrollTransition (enter/leave viewport) and visualEffect(geometry without GeometryReader) — details in design/animation-patterns.
onScrollGeometryChange (fine outside lazy estimation),onScrollVisibilityChange (autoplay/analytics).
100k+ rows, and lazy loading works in nested ScrollView+LazyVStack; profile with the SwiftUI instrument (performance/swiftui-debugging).
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.
swiftui/data-flow (identity/ForEach IDs), performance/swiftui-debugging, design/animation-patterns (scroll-linked effects)Other measured skills in the registry, with their headline benchmark lift.