SwiftUI Layout, Identity & Rendering
Irbisa · cheatsheetSeptember 13, 2026

SwiftUI Layout, Identity & Rendering

Layout protocol, GeometryReader, PreferenceKey, view identity, Canvas, lazy containers, insets

Senior Developer16 itemscompressed for a skim
  1. 01

    Cards in a horizontal ScrollView refuse to spread out and the Spacer() between them collapses to nothing — what is the ScrollView proposing?

    Medium

    A ScrollView proposes nil along its scroll axis, and a Spacer handed a nil width 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)
    …
  2. 02

    A row's price truncates while the title next to it has slack — which of frame, fixedSize and layoutPriority changes the offer, and which changes the reply?

    Medium

    layoutPriority and frame change what the child is offered; fixedSize changes 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..."
    }
    …
  3. 03

    You dropped a GeometryReader into a VStack to read a card's width and every sibling jumped to the top-left — what did it just do to the layout?

    Medium

    GeometryReader accepts 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 .topLeading alignment 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
      }
    …
  4. 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 ViewThatFits actually measure?

    Medium

    ViewThatFits proposes nil in 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
    }
    …
  5. 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?

    Medium

    Give the stack an alignment that both children can answer — HStack(alignment: .firstTextBaseline) — and where a child has no sensible answer, override its guide with alignmentGuide(_:computeValue:).

    // The badge centres on the whole wrapped block, which is not what was drawn
    HStack {
      Text(article.title)     // three lines
      Badge("NEW")
    }
    …
  6. 06

    You need tags to wrap onto new lines like text — write it as a Layout and tell me what sizeThatFits and placeSubviews each owe the framework?

    Medium

    sizeThatFits answers a proposal with the size your container wants; placeSubviews is 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() }
    …
  7. 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?

    Medium

    Changing 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
    }
    …
  8. 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?

    Medium

    It lives in a view whose identity never changed: @State storage 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) {
    …
  9. 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?

    Medium

    Two elements are handing ForEach the 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. 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?

    Hard

    A 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 onAppear means "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. 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?

    Hard

    scrollTo can 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. 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?

    Hard

    When 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 Canvas is one view that draws all 2,000 bars imperatively into a GraphicsContext.

    // 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. 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?

    Hard

    shadow, opacity and blendMode are applied to every rendered leaf below the modifier rather than to a flattened picture of the group, and compositingGroup() 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. 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?

    Hard

    Because 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. 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?

    Hard

    A generic Content: View is a single opaque value, not a list — Group(subviews:) and ForEach(subviewOf:), added in iOS 18, resolve that value into a SubviewsCollection whose elements are the individual children, with any ForEach in 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. 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?

    Hard

    Layout 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() }
    }
    …