From apple-kit-skills
Audits and improves SwiftUI runtime performance, diagnosing slow rendering, janky scrolling, high CPU, excessive view updates, layout thrash, identity issues, and guiding Instruments profiling.
npx claudepluginhub dpearson2699/swift-ios-skills --plugin all-ios-skillsThis skill uses the workspace's default tool permissions.
Audit SwiftUI view performance end-to-end, from instrumentation and baselining to root-cause analysis and concrete remediation steps.
Generates design tokens/docs from CSS/Tailwind/styled-components codebases, audits visual consistency across 10 dimensions, detects AI slop in UI.
Records polished WebM UI demo videos of web apps using Playwright with cursor overlay, natural pacing, and three-phase scripting. Activates for demo, walkthrough, screen recording, or tutorial requests.
Delivers idiomatic Kotlin patterns for null safety, immutability, sealed classes, coroutines, Flows, extensions, DSL builders, and Gradle DSL. Use when writing, reviewing, refactoring, or designing Kotlin code.
Audit SwiftUI view performance end-to-end, from instrumentation and baselining to root-cause analysis and concrete remediation steps.
Collect:
Focus on:
id churn, UUID() per render).if/else returning different root branches).body (formatting, sorting, image decoding).GeometryReader, preference chains).Provide:
Explain how to collect data with Instruments:
Ask for:
Prioritize likely SwiftUI culprits:
id churn, UUID() per render).if/else returning different root branches).body (formatting, sorting, image decoding).GeometryReader, preference chains).Summarize findings with evidence from traces/logs.
Apply targeted fixes:
@State/@Observable closer to leaf views).ForEach and lists.body (precompute, cache, @State).equatable() or value wrappers for expensive subtrees.Look for these patterns during code review.
bodyvar body: some View {
let number = NumberFormatter() // slow allocation
let measure = MeasurementFormatter() // slow allocation
Text(measure.string(from: .init(value: meters, unit: .meters)))
}
Prefer cached formatters in a model or a dedicated helper:
final class DistanceFormatter {
static let shared = DistanceFormatter()
let number = NumberFormatter()
let measure = MeasurementFormatter()
}
var filtered: [Item] {
items.filter { $0.isEnabled } // runs on every body eval
}
Prefer precompute or cache on change:
@State private var filtered: [Item] = []
// update filtered when inputs change
body or ForEach// DON'T: sorts or filters on every body evaluation
ForEach(items.sorted(by: sortRule)) { item in Row(item) }
ForEach(items.filter { $0.isEnabled }) { item in Row(item) }
Prefer precomputed, cached collections with stable identity. Update on input change, not in body.
ForEach(items, id: \.self) { item in
Row(item)
}
Avoid id: \.self for non-stable values; use a stable ID.
var content: some View {
if isEditing {
editingView
} else {
readOnlyView
}
}
Prefer one stable base view and localize conditions to sections/modifiers (for example inside toolbar, row content, overlay, or disabled). This reduces root identity churn and helps SwiftUI diffing stay efficient.
Image(uiImage: UIImage(data: data)!)
Prefer decode/downsample off the main thread and store the result.
@Observable class Model {
var items: [Item] = []
}
var body: some View {
Row(isFavorite: model.items.contains(item))
}
Prefer granular view models or per-item state to reduce update fan-out.
Ask the user to re-run the same capture and compare with baseline metrics. Summarize the delta (CPU, frame drops, memory peak) if provided.
Provide:
Use the SwiftUI instrument template in Xcode (Cmd+I to profile). Key instruments: SwiftUI View Body (body evaluation counts), SwiftUI View Properties (state change tracking), Time Profiler, and Hangs.
Add Self._printChanges() in debug builds to log which property triggered a view update:
var body: some View {
#if DEBUG
let _ = Self._printChanges() // "MyView: @self, _count changed."
#endif
Text("Count: \(count)")
}
See references/optimizing-swiftui-performance-instruments.md for the full profiling workflow.
SwiftUI assigns every view an identity used to track its lifetime, state, and animations.
body to distinguish views..id(_:) modifier or ForEach(items, id: \.stableID).// Structural identity: SwiftUI knows these are different views by position
VStack {
Text("First") // position 0
Text("Second") // position 1
}
When a view's identity changes, SwiftUI treats it as a new view:
@State is reset.onAppear fires again.When identity stays the same, SwiftUI updates the existing view in place, preserving state and providing smooth transitions.
AnyView erases type information, forcing SwiftUI to fall back to less efficient diffing:
// DON'T: AnyView destroys type identity
func makeView(for item: Item) -> AnyView {
if item.isPremium {
return AnyView(PremiumRow(item: item))
} else {
return AnyView(StandardRow(item: item))
}
}
// DO: use @ViewBuilder to preserve structural identity
@ViewBuilder
func makeView(for item: Item) -> some View {
if item.isPremium {
PremiumRow(item: item)
} else {
StandardRow(item: item)
}
}
AnyView also prevents SwiftUI from detecting which branch changed, causing full subtree replacement instead of targeted updates.
if/else in a view builder creates _ConditionalContent — two separate view branches with distinct identities. When the condition changes, SwiftUI destroys one branch and creates the other, resetting all @State.
For toggling modifiers on the same view, use a ternary expression instead:
// DON'T: if/else creates two separate Text views with different identities
if isHighlighted {
Text(title).foregroundStyle(.yellow)
} else {
Text(title).foregroundStyle(.primary)
}
// DO: ternary keeps one Text view, just changes the modifier value
Text(title)
.foregroundStyle(isHighlighted ? .yellow : .primary)
This preserves the view's identity (and its state) across the condition change, and SwiftUI can animate the transition smoothly.
Use if/else when the view type itself differs between branches. Use ternary when only a property or modifier changes.
The .id() modifier assigns explicit identity. Changing the value destroys and recreates the view:
// DON'T: UUID() changes every render, destroying and recreating the view each time
ScrollView {
LazyVStack {
ForEach(items) { item in
Row(item: item)
.id(UUID()) // kills performance -- new identity every render
}
}
}
// DO: use a stable identifier
ForEach(items) { item in
Row(item: item)
.id(item.stableID) // identity only changes when the item actually changes
}
Intentional .id() change is useful for resetting state (e.g., .id(selectedTab) to reset a scroll position when switching tabs).
Lazy stacks only create views for items currently visible on screen. Off-screen items are not evaluated until scrolled into view.
ScrollView {
LazyVStack {
ForEach(items) { item in
ItemRow(item: item)
}
}
}
Key behaviors:
onAppear fires when the view first enters the visible area.onDisappear fires when it leaves, but the view is still alive.Use lazy grids for multi-column layouts:
// Adaptive: as many columns as fit with minimum width
let columns = [GridItem(.adaptive(minimum: 150))]
ScrollView {
LazyVGrid(columns: columns) {
ForEach(photos) { photo in
PhotoThumbnail(photo: photo)
}
}
}
// Fixed: exact number of equal columns
let fixedColumns = [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible()),
]
| Scenario | Use |
|---|---|
| < 50 items | VStack / HStack (eager is fine) |
| 50-100 items | Either works; prefer Lazy if items are complex |
| > 100 items | LazyVStack / LazyHStack (required for performance) |
| Always-visible content | VStack (no benefit to lazy) |
| Scrollable lists | LazyVStack inside ScrollView, or List |
Important: Do not nest GeometryReader inside lazy containers. It forces eager measurement and defeats lazy loading. Use .onGeometryChange (iOS 16+) instead.
@Observable (Observation framework, iOS 17+) tracks property access at the per-property level. A view only re-evaluates when properties it actually read in body change:
@Observable class UserProfile {
var name: String = ""
var avatarURL: URL?
var biography: String = ""
}
// This view ONLY re-renders when `name` changes -- not when
// biography or avatarURL change, because it only reads `name`
struct NameLabel: View {
let profile: UserProfile
var body: some View {
Text(profile.name)
}
}
This is a significant improvement over ObservableObject + @Published, which invalidates all observing views when any published property changes.
If a view reads many properties from an @Observable model in body, it re-renders when any of those properties change. Push reads into child views to narrow the scope:
// DON'T: reads name, email, avatar, and settings in one body
struct ProfileView: View {
let model: ProfileModel
var body: some View {
VStack {
Text(model.name) // tracks name
Text(model.email) // tracks email
AsyncImage(url: model.avatar) // tracks avatar
SettingsForm(model.settings) // tracks settings
}
}
}
// DO: split into child views so each only tracks what it reads
struct ProfileView: View {
let model: ProfileModel
var body: some View {
VStack {
NameRow(model: model) // only tracks name
EmailRow(model: model) // only tracks email
AvatarView(model: model) // only tracks avatar
SettingsForm(model: model) // only tracks settings
}
}
}
Use computed properties on @Observable models to derive state without introducing extra stored properties that widen observation scope:
@Observable class ShoppingCart {
var items: [CartItem] = []
// Views reading `total` only re-render when `items` changes
var total: Decimal {
items.reduce(0) { $0 + $1.price * Decimal($1.quantity) }
}
}
@Observable models into focused ones, or use computed properties/closures to narrow observation scope.GeometryReader inside ScrollView items. GeometryReader forces eager sizing and defeats lazy loading. Prefer .onGeometryChange (iOS 16+) or measure outside the lazy container.DateFormatter() or NumberFormatter() inside body. These are expensive to create. Make them static or move them outside the view.Equatable, then use .animation(_:value:) for simple value-bound changes or .animation(_:body:) for narrower modifier-scoped implicit animation.List without identifiers. Use id: or make items Identifiable so SwiftUI can diff efficiently instead of rebuilding the entire list.@State wrapper objects. Wrapping a simple value type in a class for @State defeats value semantics. Use plain @State with structs.MainActor with synchronous I/O. File reads, JSON parsing of large payloads, and image decoding should happen off the main actor. Use Task.detached or a custom actor.DateFormatter/NumberFormatter allocations inside bodyIdentifiable items or explicit id:@Observable models expose only the properties views actually readMainActor (image processing, parsing)GeometryReader is not inside a LazyVStack/LazyHStack/List.animation(_:value:) for value-bound changes or .animation(_:body:) for narrower modifier scope@Observable view models are @MainActor-isolated; types crossing concurrency boundaries are Sendable