agentsclimarketplace

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

Install
npx -y skills add Tyr0/agent-skills --skill swiftui-performance

Assembled 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:

  1. Body evaluation — re-running var body on dirty views. Cheap individually (microseconds) but multiplicative.
  2. Diffing — comparing the new view tree to the previous one to decide what changed.
  3. Layout — proposed-size / required-size negotiation across the changed subtree.
  4. 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.

  1. Reduce the set of views invalidated per change — push @State down, narrow @Observable reads, split big views.
  2. Make invalidation cheaperEquatable conformance on data, EquatableView, stable identity.
  3. Use lazy containers correctlyLazyVStack/LazyHGrid/List for unbounded content.
  4. Avoid GeometryReader in hot paths; prefer containerRelativeFrame, onGeometryChange (iOS 18), or Layout.
  5. Drop into Canvas or drawingGroup() for heavy drawing.
  6. 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.title re-renders only on title changes — not on model.body changes. (@Observable only.)
  • 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 \.self for value types that can collide. Don't use array index for mutable arrays.
  • Prefer Identifiable conformance: ForEach(items).
  • Avoid .id(UUID()) — it forces full re-creation every render.
  • if/else branches have distinct identity; switching branches resets state in either branch.

Lazy Containers

ContainerWhen to use
VStack/HStackSmall, bounded content. Renders all children eagerly.
LazyVStack/LazyHStackLong scrollable content where you want every-row layout
LazyVGrid/LazyHGridGrids of unknown length
ListPlain or styled lists. Internally lazy + reuse. Prefer for table-like UI.
ScrollView + LazyVStackWhen 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 GeometryReader inside a VStack expands to fill, breaking layout.
  • Reading geometry to drive a @State write causes layout-during-update warnings.

Prefer:

  • iOS 17+: containerRelativeFrame for sizing relative to a scroll/window container.
  • iOS 18+: .onGeometryChange(for:of:action:) reads geometry without distorting layout.
  • iOS 16+: Custom Layout protocol 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.
  • AsyncImage is convenient but has no caching; for production lists prefer Nuke / Kingfisher / your own.
  • Use .resizable().interpolation(.medium) only when needed; .none is 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.
  • .matchedGeometryEffect for shared element transitions.
  • withAnimation around 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 MainActor by default since the enclosing view is @MainActor.
  • id: re-runs when the id changes; cancels the previous task automatically.
  • .task is cancelled on view disappearance (structured concurrency). .onAppear is 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:

  • View is @MainActor by default.
  • Don't pass non-Sendable references across isolation boundaries.
  • Models touched by SwiftUI views should usually be @MainActor (read-write on main) or expose only Sendable snapshots.

Profiling Workflow

  1. Build & profile Release scheme on device.
  2. Instruments → SwiftUI template.
  3. 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.
  4. Cross-reference with Time Profiler for actual CPU.
  5. 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

WinWhen
Push @State down to leaf viewsTop-level state forces tree-wide re-render
Split big body into subviewsLets SwiftUI skip un-changed subviews via Equatable check
Make data models EquatableSkips re-render when stored values match
LazyVStack / List for long contentEager stacks instantiate everything
Replace GeometryReader with containerRelativeFrameAvoids parent-distortion + layout-pass cost
Cache pre-decoded imagesRemoves per-row decode
.drawingGroup() on complex animated compositionFlattens to one Metal layer
Canvas over many Shape siblingsDirect draw beats N views
Equatable-conform leaf viewsEquatableView short-circuits diffing
Avoid AnyView in hot pathsErases types and disables structural diffing optimizations

Anti-Patterns

Anti-patternProblemFix
AnyView everywhereDisables type-driven diffing; forces re-creationReturn some View; use Group, @ViewBuilder, or if/else
GeometryReader wrapping every screenDistorts layout, reads on every framecontainerRelativeFrame, onGeometryChange, or Layout
Top-level @State for deep input fieldWhole screen re-renders per keystrokePush @State into the field's view
ObservableObject for a 50-field modelAny change invalidates every observerMigrate to @Observable (per-property tracking)
.id(UUID()) to force refreshPathological re-creationUse stable IDs; reset state explicitly
AsyncImage in fast scroll listNo cache; re-fetches on scrollUse a real image pipeline with cache
Heavy work inside body (sort, filter, format)Re-runs every invalidationPre-compute in model; cache; .task(id:)
.animation(.default) blanket modifierAnimates unrelated changes.animation(_:value:) scoped to one value
ForEach(array, id: \.self) on mutable structsIdentity churns on editUse a real id (UUID, db pk, etc.)
Mutating @State inside bodyLayout-during-update warning, infinite invalidationMove into action closures, .task, or .onChange
Treating Self._printChanges() as production loggingIt's debug-onlyUse 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.

Keep looking

Skills are one crate of 326,852. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.