SwiftUI Layout, Identity & Rendering
Layout protocol, GeometryReader, PreferenceKey, view identity, Canvas, lazy containers, insets
- 01
Cards in a horizontal ScrollView refuse to spread out and the
Spacer()between them collapses to nothing — what is the ScrollView proposing?MediumA
ScrollViewproposesnilalong its scroll axis, and aSpacerhanded anilwidth answers with its minimum length — so there is no slack for it to take.// Symptom: nothing spreads apart ScrollView(.horizontal) { HStack { Card(first) Spacer() // proposed nil width -> answers minLength. There is no slack to absorb. Card(second) … - 02
A row's price truncates while the title next to it has slack — which of
frame,fixedSizeandlayoutPrioritychanges the offer, and which changes the reply?MediumlayoutPriorityandframechange what the child is offered;fixedSizechanges what the child answers, by making it report its ideal size no matter what it was offered.// Symptom: the price truncates while the title still has room HStack { Text(product.title) Spacer() Text(product.price) // "$1,299.00" renders as "$1,2..." } … - 03
You dropped a
GeometryReaderinto a VStack to read a card's width and every sibling jumped to the top-left — what did it just do to the layout?MediumGeometryReaderaccepts the whole proposal it is given and reports it back as its own size, so it swallows the stack's leftover space, and it lays its content out with.topLeadingalignment instead of centring it.// Wrong: the reader eats the stack's leftover height and pins its content to the top-left VStack { Title() GeometryReader { proxy in Chart(width: proxy.size.width) // drawn at the top-left of a very tall box } … - 04
A toolbar should show labelled buttons on a big phone and icons only on a small one, without a size-class ladder — what does
ViewThatFitsactually measure?MediumViewThatFitsproposesnilin the axes you name to each candidate in turn, asks for its ideal size, and renders the first candidate whose ideal fits inside the size it was itself offered.// Candidates in order; the last one is the fallback whether it fits or not ViewThatFits(in: .horizontal) { HStack { ForEach(actions) { Label($0.title, systemImage: $0.icon) } } // labels + icons HStack { ForEach(actions) { Image(systemName: $0.icon) } } // icons only Menu("More") { ForEach(actions) { Button($0.title) {} } } // fallback } … - 05
Design wants the trailing badge level with the first line of a wrapping title rather than with the middle of the block — how do alignment guides get it there?
MediumGive the stack an alignment that both children can answer —
HStack(alignment: .firstTextBaseline)— and where a child has no sensible answer, override its guide withalignmentGuide(_:computeValue:).// The badge centres on the whole wrapped block, which is not what was drawn HStack { Text(article.title) // three lines Badge("NEW") } … - 06
You need tags to wrap onto new lines like text — write it as a
Layoutand tell me whatsizeThatFitsandplaceSubviewseach owe the framework?MediumsizeThatFitsanswers a proposal with the size your container wants;placeSubviewsis then handed the bounds it actually got and must place every subview exactly once — and the two have to agree, or children draw outside the frame you claimed.struct FlowLayout: Layout { var spacing: CGFloat = 8 struct Cache { var width: CGFloat = .nan; var rows: [[Int]] = []; var size: CGSize = .zero } func makeCache(subviews: Subviews) -> Cache { Cache() } … - 07
A teammate fixed a stale row by adding
.id(item.updatedAt)and now the screen flashes and loses its scroll position — what does changing an id actually do?MediumChanging an
.id()does not update a view — it ends one view's life and starts another's, so everything bound to that lifetime inside the subtree is thrown away.// Wrong: the id tracks the data, so every edit destroys and rebuilds the row List(items) { item in RowEditor(item: item) .id(item.updatedAt) // new lifetime per keystroke: state gone, task restarted } … - 08
Tapping a different item in a master-detail screen leaves the previous item's text sitting in the editor — where is that state actually living?
MediumIt lives in a view whose identity never changed:
@Statestorage belongs to the view's lifetime, not to the struct, and it is seeded exactly once — so a detail view reused for a new item keeps the old value.// Wrong: @State seeded from a prop. The initialiser runs once per lifetime, never again. struct DetailEditor: View { let item: Item @State private var draft: String init(item: Item) { … - 09
The console warns that an ID occurs multiple times inside a ForEach, rows start losing their state and a delete animates the wrong row — what broke?
MediumTwo elements are handing
ForEachthe same identifier, so SwiftUI can no longer pair old children with new ones and the diff attaches state, edits and animations to the wrong row.struct Tag: Hashable { let name: String } // Wrong: \.self over values that can repeat — two "swift" tags claim one identity struct BadTagList: View { let tags: [Tag] var body: some View { … - 10
Your LazyVStack fires the last row's onAppear three screens early and again on the way back, and the scroll indicator keeps resizing — what is laziness doing?
HardA lazy container builds a row when it enters the build window, not when it becomes visible, and it can only measure the rows it has built — so
onAppearmeans "created", and the content height is an estimate that keeps being revised.// Wrong: onAppear means "built", not "on screen" — fires early and more than once struct BadFeed: View { @State private var items: [Item] = [] var body: some View { ScrollView { … - 11
You append a message and call proxy.scrollTo(newID) in the same action, and the list either does not move or stops short — why, and what replaces the proxy?
HardscrollTocan only reach a child that already exists inside the scroll view, so scrolling in the same update that appended the row asks the proxy to find something the tree has not built yet.// Wrong: the row does not exist yet when the proxy is asked to find it Button("Send") { messages.append(draft) proxy.scrollTo(draft.id, anchor: .bottom) // resolved against the old tree } … - 12
A live waveform built from 2,000 Rectangles in an HStack pegs the CPU at 60 fps — when do you stop composing views and start drawing?
HardWhen the elements are pixels rather than things the user lays out or touches: each of those rectangles is a real view with identity, a layout pass and hit testing, while
Canvasis one view that draws all 2,000 bars imperatively into aGraphicsContext.// Wrong: 2,000 views, each with layout, identity and hit testing, rebuilt every tick struct BadWaveform: View { let samples: [Float] var body: some View { HStack(alignment: .center, spacing: 1) { … - 13
You put one .shadow on a card's VStack and got a shadow around every element inside it, and .opacity(0.5) made the overlaps look wrong — what is SwiftUI doing?
Hardshadow,opacityandblendModeare applied to every rendered leaf below the modifier rather than to a flattened picture of the group, andcompositingGroup()is what makes the group render as one image first.// Wrong: one modifier, three shadows — the effect reaches each leaf, not the group VStack(alignment: .leading, spacing: 8) { Text("Weekly report").font(.headline) Image("chart").resizable().scaledToFit() Text("Updated 5 minutes ago").font(.caption) } … - 14
You added .ignoresSafeArea() to a screen's root so the gradient reaches the edges, and now the header hides under the status bar and fields stop lifting for the keyboard — why?
HardBecause the modifier is scoped to a subtree: it hands the view it is attached to, and everything inside it, a proposal that covers the ignored region — so the content moved under the bars along with the gradient.
// Wrong: the whole screen ignores the safe area, so the header and the fields go with it struct BadScreen: View { @State private var name = "" var body: some View { VStack { … - 15
Your Card must draw a divider between its children, but when the caller passes a ForEach it draws one divider for the whole loop — how do you reach the real children?
HardA generic
Content: Viewis a single opaque value, not a list —Group(subviews:)andForEach(subviewOf:), added in iOS 18, resolve that value into aSubviewsCollectionwhose elements are the individual children, with anyForEachin the caller's builder already expanded.// Wrong: `content` is one view, so the divider is drawn once for the whole ForEach struct BadCard<Content: View>: View { @ViewBuilder var content: Content var body: some View { VStack(alignment: .leading) { … - 16
Bodies are few and cheap, the SwiftUI instrument puts the time into layout rather than into body, and the screen still hitches — what makes a layout pass expensive?
HardLayout cost is counted in size proposals: every container asks each child what it wants, and a tree that answers "it depends" — nested flexible stacks, ideal-size queries, a measurement that feeds back into state — turns one pass into several, every frame.
// Wrong: the child measures itself, the parent stores it, and the resize re-measures struct HeightKey: PreferenceKey { static let defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } } …