Skip to content
advanced Phase 11 · Performance & Optimization

Layout Performance

Optimize SwiftUI rendering: lazy stacks, drawing group, and reducing view recomputation.

50m
2 problems
Topic Progress 0%

Lazy Stacks

The Problem with Eager Loading

SwiftUI VStack and HStack create all their child views immediately. For large collections this causes massive memory usage and slow rendering because every view is instantiated even if it is off screen.

LazyVStack and LazyHStack

Lazy stacks only create views when they are about to appear on screen. This is similar to how UITableView recycles cells:

// Bad: Creates all 10000 views at once
ScrollView {
    VStack {
        ForEach(0..<10000) { i in
            CardView(item: items[i])
        }
    }
}

// Good: Only creates visible views
ScrollView {
    LazyVStack {
        ForEach(0..<10000) { i in
            CardView(item: items[i])
        }
    }
}

Pinned Headers with LazyVStack

LazyVStack supports pinned headers that stay visible while scrolling:

ScrollView {
    LazyVStack(pinnedViews: [.sectionHeaders]) {
        Section(header: SectionHeader(title: "Section 1")) {
            ForEach(items) { item in
                ItemRow(item: item)
            }
        }
    }
}

Performance Considerations

  • Lazy stacks are slower than List for very large datasets because List recycles views while LazyVStack keeps them in memory once created
  • Use LazyVStack inside ScrollView when you need custom layout that List cannot provide
  • For simple vertical scrolling with standard row appearance, prefer List as it recycles views automatically
  • Set explicit frame heights on lazy stack children for best performance since SwiftUI cannot calculate height lazily

When to Use Each

  • List: Standard rows, large datasets, built-in swipe actions, search
  • LazyVStack in ScrollView: Custom layouts, mixed content types, pinned headers
  • VStack in ScrollView: Small fixed collections (under 50 items)
  • HStack: Small horizontal collections, never for large datasets

Drawing Group & Graphics Optimization

The drawingGroup Modifier

The drawingGroup modifier renders SwiftUI vector graphics using Metal instead of Core Graphics. This is significantly faster for complex paths and shapes:

ComplexShapeView()
    .drawingGroup()

When to Use drawingGroup

Use it when you have:

  • Many overlapping shapes
  • Complex path operations
  • Animated gradients or filters
  • Custom Shape implementations with many control points

Flattening the Render Tree

Without drawingGroup, each shape is a separate render node. With it, the entire view hierarchy is flattened into a single Metal render pass:

struct WaveformView: View {
    var samples: [Float]
    
    var body: some View {
        Path { path in
            for (index, sample) in samples.enumerated() {
                let x = CGFloat(index) / CGFloat(samples.count)
                let y = CGFloat(sample)
                if index == 0 {
                    path.move(to: CGPoint(x: x, y: y))
                } else {
                    path.addLine(to: CGPoint(x: x, y: y))
                }
            }
        }
        .stroke(Color.blue, lineWidth: 2)
        .drawingGroup() // Render all lines as single Metal pass
    }
}

Image Rendering Performance

Optimize image loading and display:

// Use resizable with aspectRatio for consistent sizing
Image(uiImage: image)
    .resizable()
    .aspectRatio(contentMode: .fill)
    .frame(width: 100, height: 100)
    .clipped()

// Downsample large images before display
class ImageLoader {
    static func downsample(imageAt url: URL, to pointSize: CGSize) -> UIImage? {
        let maxDimensionInPixels = max(pointSize.width, pointSize.height) * UIScreen.main.scale
        let options: [CFString: Any] = [
            kCGImageSourceCreateThumbnailFromImageAlways: true,
            kCGImageSourceShouldCacheImmediately: true,
            kCGImageSourceCreateThumbnailWithTransform: true,
            kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels
        ]
        guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
              let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
            return nil
        }
        return UIImage(cgImage: cgImage)
    }
}

Avoiding Unnecessary Redraws

Use EquatableView or Equatable conformance to prevent unnecessary view updates:

struct ExpensiveView: View, Equatable {
    let data: DataModel
    
    static func == (lhs: ExpensiveView, rhs: ExpensiveView) -> Bool {
        lhs.data.id == rhs.data.id && lhs.data.version == rhs.data.version
    }
    
    var body: some View {
        // Complex rendering
    }
}

// Usage: only redraws when data actually changes
ExpensiveView(data: model)
    .equatable()

View Recycling & Optimization

View Identity in SwiftUI

SwiftUI uses view identity to determine when to reuse vs recreate views. The .id modifier and ForEach with stable identifiers control this:

// Bad: Using index as identifier causes view recreation
ForEach(0..<items.count, id: \.self) { index in
    ItemRow(item: items[index])
}

// Good: Using stable identifiers enables view recycling
ForEach(items, id: \.id) { item in
    ItemRow(item: item)
}

Minimizing Body Recomputation

SwiftUI calls body whenever any published property changes. Reduce recomputation by:

// Break into smaller views
struct ParentView: View {
    @State private var count = 0
    
    var body: some View {
        VStack {
            StaticHeaderView()  // Never recreated
            CounterView(count: count)  // Only this redraws
            FooterView()  // Never recreated
        }
    }
}

// Use @StateObject and @ObservedObject carefully
class Model: ObservableObject {
    @Published var items: [Item] = []
    @Published var searchText = ""
    
    var filteredItems: [Item] {
        items.filter { $0.name.contains(searchText) }
    }
}

The onAppear Optimization

Use onAppear to load data lazily rather than loading everything upfront:

ScrollView {
    LazyVStack {
        ForEach(items) { item in
            ItemRow(item: item)
                .onAppear {
                    if item.id == items.last?.id {
                        loadNextPage()
                    }
                }
        }
    }
}

Performance Profiling

Use the SwiftUI Performance template in Instruments to measure:

  • View body call frequency
  • View creation and destruction
  • Rendering time per frame
  • Hierarchy depth

Target 60fps (16.67ms per frame) or 120fps on ProMotion displays (8.33ms per frame).

Quiz

1. What is the main difference between VStack and LazyVStack?

Question 1 options

2. When should you use the drawingGroup modifier?

Question 2 options

3. Why is using index as ForEach identifier bad for performance?

Question 3 options

4. What frame time should you target for smooth 60fps rendering?

Question 4 options

Flashcards

Question

When should you use LazyVStack vs List?

Answer

Use List for standard rows with large datasets since it recycles views. Use LazyVStack for custom layouts, mixed content, or when you need more layout control.

Question

What does drawingGroup do?

Answer

It flattens the SwiftUI view hierarchy into a single Metal render pass, making complex vector graphics rendering much faster.

Question

How do you prevent unnecessary view redraws?

Answer

Use EquatableView, break views into smaller components, and ensure stable identifiers in ForEach to minimize body recomputation.

Revision Notes

Key Takeaways

  • 1. Lazy stacks defer view creation until needed
  • 2. drawingGroup optimizes complex vector graphics with Metal
  • 3. List recycles views while LazyVStack keeps them in memory
  • 4. Use stable identifiers for efficient view matching
  • 5. Profile with Instruments SwiftUI template for bottlenecks

Interview Tips

  • Explain the difference between eager and lazy view creation
  • Describe when to use drawingGroup and its trade-offs
  • Discuss how SwiftUI determines which views to redraw
  • Walk through optimizing a slow-scrolling list

Cheat Sheet

Layout Performance Quick Reference

  • LazyVStack/LazyHStack: create views on demand
  • List: recycles views for large datasets
  • drawingGroup: Metal rendering for complex graphics
  • Use stable IDs in ForEach
  • Target 16.67ms per frame for 60fps
  • Break views into smaller components