Swiftui performance
Skill Tyr0/agent-skills/plugins/swiftui-expert/skills/swiftui-performance
Use this skill whenever the user asks about SwiftUI performance, slow scrolling, dropped frames, view re-rendering cost, body invalidation, view identity, lazy containers, large lists, image performance, drawing performance, `EquatableView`, `@ViewBuilder` cost, `.task` lifecycle, `MainActor` interaction with SwiftUI, or any "why is my SwiftUI view slow" question. Targets iOS 17+. Triggers on questions like "why does my list lag", "how do I avoid recomputing body", "should I use `LazyVStack`", "is `GeometryReader` expensive", "how do I profile SwiftUI", or "view is rendering too often".From its SKILL.md
npx -y skills add Tyr0/agent-skills --skill swiftui-performanceAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
10.9 KB, ~2.6k tokens by cl100k_base, as published. Nobody here has run it
SwiftUI Performance
How SwiftUI evaluates and renders views, where the costs hide, and how to keep frame time under 16.6 ms.
The Cost Model
A SwiftUI frame breaks into:
- Body evaluation — re-running
var bodyon dirty views. Cheap individually (microseconds) but multiplicative. - Diffing — comparing the new view tree to the previous one to decide what changed.
- Layout — proposed-size / required-size negotiation across the changed subtree.
- Render — handing to Core Animation / Metal.
Most performance problems live in (1) and (3). (2) is rarely the bottleneck on its own. (4) is the floor — once you bottom out there, you've optimized correctly.
Profile in Release with Instruments → SwiftUI template (Xcode 15+). Debug builds are wildly unrepresentative — the optimizer eliminates a lot of allocation and dispatch.
Optimization Priority
Work top to bottom; stop when Instruments says you're done.
- Reduce the set of views invalidated per change — push
@Statedown, narrow@Observablereads, split big views. - Make invalidation cheaper —
Equatableconformance on data,EquatableView, stable identity. - Use lazy containers correctly —
LazyVStack/LazyHGrid/Listfor unbounded content. - Avoid
GeometryReaderin hot paths; prefercontainerRelativeFrame,onGeometryChange(iOS 18), orLayout. - Drop into
CanvasordrawingGroup()for heavy drawing. - Move work off
body— pre-compute, cache in the model, use.task(id:).
Body Invalidation Rules
A view's body is recomputed when any tracked dependency it accessed last time changes. The Observation framework tracks per-property; legacy ObservableObject invalidates every observer on any @Published change.
Practical implications:
- A view that reads
model.titlere-renders only on title changes — not onmodel.bodychanges. (@Observableonly.) - A view that reads nothing observable re-renders only when its parent passes new stored values.
- A view's stored properties are compared field-by-field. If the field is
Equatable, SwiftUI skips re-render when equal. If not, it conservatively re-renders.
Make data Equatable
struct Row: View, Equatable {
let item: Item // Item: Equatable
var body: some View { ... }
}
Or wrap a problematic subview:
EquatableView(content: Row(item: item))
EquatableView short-circuits diffing when == returns true. Use sparingly — == runs every diff.
Inspect re-renders
let _ = Self._printChanges() // logs which dependency invalidated this body
Place at the top of body. Available since iOS 15. Invaluable for diagnosing churn.
View Identity and Stability
SwiftUI keys state and animation by view identity. Unstable identity causes:
- Lost
@State - Animations that don't animate
- Lazy containers re-creating cells unnecessarily
Rules:
ForEach(items, id: \.stableID)— use a real stable id. Don't use\.selffor value types that can collide. Don't use array index for mutable arrays.- Prefer
Identifiableconformance:ForEach(items). - Avoid
.id(UUID())— it forces full re-creation every render. if/elsebranches have distinct identity; switching branches resets state in either branch.
Lazy Containers
| Container | When to use |
|---|---|
VStack/HStack | Small, bounded content. Renders all children eagerly. |
LazyVStack/LazyHStack | Long scrollable content where you want every-row layout |
LazyVGrid/LazyHGrid | Grids of unknown length |
List | Plain or styled lists. Internally lazy + reuse. Prefer for table-like UI. |
ScrollView + LazyVStack | When List styling fights you |
List reuses cells and integrates with system gestures (swipe actions, separators, selection). Reach for it first.
LazyVStack does not reuse cells the way UIKit UITableView does — it instantiates on demand and keeps them while in view + a small over-scan. For tens of thousands of rows that's still fine; for millions you need a windowing strategy.
Pinned headers (sections)
LazyVStack(pinnedViews: [.sectionHeaders]) {
ForEach(sections) { section in
Section(header: SectionHeader(section)) { ... }
}
}
GeometryReader Pitfalls
GeometryReader consumes all available space in its parent and ignores its child's preferences for that frame. Common bugs:
- A
GeometryReaderinside aVStackexpands to fill, breaking layout. - Reading geometry to drive a
@Statewrite causes layout-during-update warnings.
Prefer:
- iOS 17+:
containerRelativeFramefor sizing relative to a scroll/window container. - iOS 18+:
.onGeometryChange(for:of:action:)reads geometry without distorting layout. - iOS 16+: Custom
Layoutprotocol for measurement-driven layouts.
Image Performance
Image("foo") and Image(uiImage:) are cheap descriptors, but decoding can be expensive at scroll time. Best practices:
- Pre-decode large images on a background queue and cache
CGImage/UIImage. AsyncImageis convenient but has no caching; for production lists prefer Nuke / Kingfisher / your own.- Use
.resizable().interpolation(.medium)only when needed;.noneis fastest for pixel art / icons. - Match the image's pixel size to its display size — a 4K image rendered at 100×100 burns memory and bandwidth.
- For SVG/PDF assets in asset catalogs, prefer the "Preserve Vector Data" + "Single Scale" combination so the asset rasterizes once at the right size.
drawingGroup() and Canvas
drawingGroup() flattens a subtree into a single Metal layer. Useful when a complex composition (many shapes, blurs, blends) is animated as a unit. Cost: an offscreen render pass, so don't slap it on small static views.
Canvas (iOS 15+) is a direct draw API — use it for charts, particle effects, custom drawings of N elements. Cheaper than N Shape views once N grows past a few hundred.
Canvas { ctx, size in
for p in points { ctx.fill(Path(...), with: .color(.blue)) }
}
Animation Cost
Animating layout (frame, padding) is cheaper than animating things that force a re-render every frame (e.g., a @State Double driving text content). To animate visual transforms cheaply, prefer:
.scaleEffect,.rotationEffect,.offset,.opacity— render-thread transforms..matchedGeometryEffectfor shared element transitions.withAnimationaround state mutation, not.animation(_:)which is implicit and easy to over-apply.
.animation(_:value:) (the new value-bound form) avoids the broad implicit form and is preferred.
.task and Concurrency
.task(id: itemID) { await load(itemID) }
- Runs on
MainActorby default since the enclosing view is@MainActor. id:re-runs when the id changes; cancels the previous task automatically..taskis cancelled on view disappearance (structured concurrency)..onAppearis not.
For background work, hop to a non-main actor explicitly: await Task.detached { ... }.value or call into an actor-isolated method on your model.
In Swift 6 / strict concurrency:
Viewis@MainActorby default.- Don't pass non-
Sendablereferences across isolation boundaries. - Models touched by SwiftUI views should usually be
@MainActor(read-write on main) or expose onlySendablesnapshots.
Profiling Workflow
- Build & profile Release scheme on device.
- Instruments → SwiftUI template.
- Look at:
- View Body track — which views ran
body, how often, how long. - View Properties — what changed and triggered the update.
- Core Animation Commits — how often the render server commits.
- View Body track — which views ran
- Cross-reference with Time Profiler for actual CPU.
- For
.task-driven work, also enable the Swift Concurrency instrument.
In code: Self._printChanges() for quick triage. signpost (os_signpost) inside expensive computed properties to attribute time.
Common Speed Wins
| Win | When |
|---|---|
Push @State down to leaf views | Top-level state forces tree-wide re-render |
Split big body into subviews | Lets SwiftUI skip un-changed subviews via Equatable check |
Make data models Equatable | Skips re-render when stored values match |
LazyVStack / List for long content | Eager stacks instantiate everything |
Replace GeometryReader with containerRelativeFrame | Avoids parent-distortion + layout-pass cost |
| Cache pre-decoded images | Removes per-row decode |
.drawingGroup() on complex animated composition | Flattens to one Metal layer |
Canvas over many Shape siblings | Direct draw beats N views |
| Equatable-conform leaf views | EquatableView short-circuits diffing |
Avoid AnyView in hot paths | Erases types and disables structural diffing optimizations |
Anti-Patterns
| Anti-pattern | Problem | Fix |
|---|---|---|
AnyView everywhere | Disables type-driven diffing; forces re-creation | Return some View; use Group, @ViewBuilder, or if/else |
GeometryReader wrapping every screen | Distorts layout, reads on every frame | containerRelativeFrame, onGeometryChange, or Layout |
Top-level @State for deep input field | Whole screen re-renders per keystroke | Push @State into the field's view |
ObservableObject for a 50-field model | Any change invalidates every observer | Migrate to @Observable (per-property tracking) |
.id(UUID()) to force refresh | Pathological re-creation | Use stable IDs; reset state explicitly |
AsyncImage in fast scroll list | No cache; re-fetches on scroll | Use a real image pipeline with cache |
Heavy work inside body (sort, filter, format) | Re-runs every invalidation | Pre-compute in model; cache; .task(id:) |
.animation(.default) blanket modifier | Animates unrelated changes | .animation(_:value:) scoped to one value |
ForEach(array, id: \.self) on mutable structs | Identity churns on edit | Use a real id (UUID, db pk, etc.) |
Mutating @State inside body | Layout-during-update warning, infinite invalidation | Move into action closures, .task, or .onChange |
Treating Self._printChanges() as production logging | It's debug-only | Use only while profiling |
Reaching for @StateObject on iOS 17+ | Legacy; no per-property tracking | @State + @Observable |
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.
Gives 0 of the 12 instructions most performance cost skills give in ~2.6k tokens
Counted across 803 of the 1,058 authors here whose files we hold, read 2026-08-07
- Keep skill files under 500 lines or tokensin 82 of 803, across 16 files
- Use imperative form in instructionsin 80 of 803, across 9 files
- Draft assertions while test runs are in progressin 75 of 803, across 9 files
- Create two to three realistic test promptsin 74 of 803, across 9 files
- Write skill descriptions to be pushyin 72 of 803, across 7 files
- Save test cases to evals JSONin 72 of 803, across 6 files
- Ask questions about edge cases and input formatsin 72 of 803, across 7 files
- Save timing data immediately when runs completein 70 of 803, across 5 files
- Include all trigger conditions in the skill descriptionin 69 of 803, across 3 files
- Launch all test runs in a single turn or simultaneouslyin 69 of 803, across 3 files
- Capture intent before writing a skillin 67 of 803, across 1 file
- Import directly instead of barrel filesin 52 of 803, across 15 files
Said here and by no other author read
- profile release builds using the Instruments SwiftUI template
- push @State down to leaf views
- make data models Equatable
- use lazy containers for unbounded content
- prefer containerRelativeFrame over GeometryReader
- drop into Canvas or drawingGroup for heavy drawing
Grouped from the skills themselves: near-identical wordings counted once, and counted by distinct author, so one author publishing three of these counts once. Length counted with cl100k_base; the agent that loads this file may tokenize it differently.