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?
2. When should you use the drawingGroup modifier?
3. Why is using index as ForEach identifier bad for performance?
4. What frame time should you target for smooth 60fps rendering?
Flashcards
Question
When should you use LazyVStack vs List?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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