
Swift Optimise
- 221 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
swift-optimise: A skill for development. This provides functionality for development workflows.
Key points
- swift-optimise
Swift Optimise by the numbers
- 221 all-time installs (skills.sh)
- +7 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,804 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill swift-optimiseAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 221 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I use swift-optimise for development tasks?
Use swift-optimise for development tasks
Who is it for?
Best when you're working on backend & apis and need structured help with swift-optimise.
Skip if: Teams with no backend & apis needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to use swift-optimise for development tasks, or when swift-optimise: a skill for development. this provides functionality for development workflows.
What you get
Structured output aligned to swift-optimise: swift-optimise.
Files
Apple Swift/SwiftUI Performance Optimization Best Practices
Comprehensive guide for Swift and SwiftUI performance optimization. Contains 19 rules across 3 categories covering modern concurrency, render performance, and animation performance. Targets iOS 26 / Swift 6.2 with @Observable and Swift 6 strict concurrency.
Clinic Architecture Contract (iOS 26 / Swift 6.2)
All guidance in this skill assumes the clinic modular MVVM-C architecture:
- Feature modules import
Domain+DesignSystemonly (neverData, never sibling features) - App target is the convergence point and owns
DependencyContainer, concrete coordinators, and Route Shell wiring Domainstays pure Swift and defines models plus repository,*Coordinating,ErrorRouting, andAppErrorcontractsDataowns SwiftData/network/sync/retry/background I/O and implements Domain protocols- Read/write flow defaults to stale-while-revalidate reads and optimistic queued writes
- ViewModels call repository protocols directly (no default use-case/interactor layer)
When to Apply
Reference these guidelines when:
- Migrating to Swift 6 strict concurrency (Sendable, actor isolation)
- Replacing Combine publishers with async/await
- Implementing @MainActor isolation and actor-based concurrency
- Decomposing views to reduce state invalidation blast radius
- Optimizing scroll and render performance with lazy containers
- Using Canvas/TimelineView for high-performance rendering
- Profiling with SwiftUI Instruments before optimizing
- Building performant spring animations and transitions
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Concurrency & Async | CRITICAL | conc- |
| 2 | Render & Scroll Performance | HIGH | perf- |
| 3 | Animation Performance | MEDIUM | anim- |
Quick Reference
1. Concurrency & Async (CRITICAL)
- `conc-combine-to-async` - Replace Combine publishers with async/await
- `conc-mainactor-isolation` - Use @MainActor instead of DispatchQueue.main
- `conc-swift6-sendable` - Adopt Sendable and Swift 6 strict concurrency
- `conc-task-id-pattern` - Use .task(id:) for reactive data loading
- `conc-actor-for-shared-state` - Replace lock-based shared state with actors
- `conc-asyncsequence-streams` - Replace NotificationCenter observers with AsyncSequence
2. Render & Scroll Performance (HIGH)
- `perf-view-decomposition` - Decompose views to limit state invalidation blast radius
- `perf-instruments-profiling` - Profile with SwiftUI Instruments before optimizing
- `perf-lazy-containers` - Use lazy containers for large collections
- `perf-canvas-timeline` - Use Canvas and TimelineView for high-performance rendering
- `perf-drawinggroup` - Use drawingGroup for complex graphics
- `perf-equatable-views` - Add Equatable conformance to prevent spurious redraws
- `perf-task-modifier` - Use .task modifier instead of .onAppear for async work
- `perf-async-image` - Use AsyncImage with caching strategy for remote images
3. Animation Performance (MEDIUM)
- `anim-spring` - Use spring animations as default
- `anim-matchedgeometry` - Use matchedGeometryEffect for shared transitions
- `anim-gesture-driven` - Make animations gesture-driven
- `anim-with-animation` - Use withAnimation for state-driven transitions
- `anim-transition-effects` - Apply transition effects for view insertion and removal
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
Rule Title Here
Brief explanation of WHY this matters and the performance/quality implications. Keep to 1-3 sentences.
Incorrect (description of the problem/cost):
// Production-realistic bad code example
// Include comment explaining the consequence
struct ExampleView: View {
var body: some View {
Text("Example")
}
}Correct (description of the benefit/solution):
// Production-realistic good code example
// Minimal diff from incorrect version
struct ExampleView: View {
var body: some View {
Text("Example")
}
}Alternative (when to use this approach):
// Optional: alternative approach for different contextsWhen NOT to use this pattern:
- Exception case 1
- Exception case 2
Reference: Documentation Title
{
"version": "1.0.8",
"organization": "Apple",
"technology": "Swift 6.2 / SwiftUI (iOS 26)",
"date": "2026-02",
"abstract": "Performance optimization and modern concurrency patterns for Swift and SwiftUI. Contains 19 rules across 3 categories covering Swift 6 strict concurrency migration, async/await patterns, render performance with view decomposition and lazy containers, and animation performance. Targets iOS 26 / Swift 6.2 with @Observable and structured concurrency. Aligned with the iOS 26 / Swift 6.2 clinic modular MVVM-C architecture.",
"references": [
"https://developer.apple.com/documentation/Xcode/understanding-and-improving-swiftui-performance",
"https://developer.apple.com/videos/play/wwdc2023/10149/",
"https://developer.apple.com/videos/play/wwdc2023/10158/",
"https://www.swift.org/migration/documentation/swift-6-concurrency-migration-guide/commonproblems/",
"https://developer.apple.com/documentation/swift/asyncsequence",
"https://developer.apple.com/documentation/swiftui/canvas"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Concurrency & Async (conc)
Impact: CRITICAL Description: Replacing Combine with async/await, using @MainActor for compile-time thread safety, task management, actors for shared state, and AsyncSequence streams modernize concurrency and prevent data races under Swift 6 strict concurrency.
2. Render & Scroll Performance (perf)
Impact: HIGH Description: View decomposition for state granularity, lazy containers, drawingGroup, task modifiers, and Equatable views ensure smooth 120fps scrolling and efficient rendering by minimizing unnecessary body re-evaluations.
3. Animation Performance (anim)
Impact: MEDIUM Description: Spring animations, matchedGeometryEffect, gesture-driven animations, withAnimation, and transition effects create performant, fluid motion that feels native to iOS.
Make Animations Gesture-Driven
Gesture-driven animations respond to user input in real-time. The view follows the finger, then settles into place when released.
Incorrect (toggle-based, not interactive):
struct DismissibleCard: View {
@State private var isDismissed = false
var body: some View {
CardContent()
.offset(x: isDismissed ? 300 : 0)
.onTapGesture {
withAnimation { isDismissed = true } // Not draggable
}
}
}Correct (gesture-driven with spring settle):
struct DismissibleCard: View {
@State private var offset: CGFloat = 0
@State private var isDismissed = false
var body: some View {
CardContent()
.offset(x: offset)
.gesture(
DragGesture()
.onChanged { gesture in
offset = gesture.translation.width // Follow finger
}
.onEnded { gesture in
let threshold: CGFloat = 100
if gesture.translation.width > threshold {
// Dismiss with velocity
withAnimation(.spring()) {
offset = 500
isDismissed = true
}
} else {
// Snap back
withAnimation(.spring()) {
offset = 0
}
}
}
)
}
}Sheet-like drag to dismiss:
struct InteractiveSheet: View {
@Binding var isPresented: Bool
@State private var dragOffset: CGFloat = 0
var body: some View {
VStack { /* content */ }
.offset(y: max(0, dragOffset))
.gesture(
DragGesture()
.onChanged { dragOffset = $0.translation.height }
.onEnded { gesture in
if gesture.translation.height > 150 ||
gesture.predictedEndTranslation.height > 300 {
withAnimation(.spring()) {
isPresented = false
}
} else {
withAnimation(.spring()) {
dragOffset = 0
}
}
}
)
}
}Using predictedEndTranslation for natural feel:
// Consider velocity, not just position
if gesture.predictedEndTranslation.height > 300 {
dismiss() // User flicked quickly
}Reference: Human Interface Guidelines - Gestures
Use matchedGeometryEffect for Shared Transitions
matchedGeometryEffect creates smooth transitions where elements appear to move between different views, like Apple's Photos app.
Incorrect (abrupt appearance):
struct PhotoGallery: View {
@State private var selectedPhoto: Photo?
var body: some View {
ScrollView {
LazyVGrid(columns: columns) {
ForEach(photos) { photo in
PhotoThumbnail(photo: photo)
.onTapGesture { selectedPhoto = photo }
}
}
}
.fullScreenCover(item: $selectedPhoto) { photo in
PhotoDetail(photo: photo) // Appears from nowhere
}
}
}Correct (matched geometry transition):
struct PhotoGallery: View {
@State private var selectedPhoto: Photo?
@Namespace private var animation
var body: some View {
ZStack {
ScrollView {
LazyVGrid(columns: columns) {
ForEach(photos) { photo in
if selectedPhoto != photo {
PhotoThumbnail(photo: photo)
.matchedGeometryEffect(id: photo.id, in: animation)
.onTapGesture {
withAnimation(.spring) {
selectedPhoto = photo
}
}
}
}
}
}
if let photo = selectedPhoto {
PhotoDetail(photo: photo)
.matchedGeometryEffect(id: photo.id, in: animation)
.onTapGesture {
withAnimation(.spring) {
selectedPhoto = nil
}
}
}
}
}
}Key requirements: 1. Same id on both source and destination views 2. Same @Namespace shared between views 3. Wrap state change in withAnimation 4. Only one view with the ID visible at a time
Card expansion example:
struct ExpandableCardList: View {
@State private var expandedID: UUID?
@Namespace private var cardAnimation
var body: some View {
ForEach(cards) { card in
CardView(card: card, isExpanded: expandedID == card.id)
.matchedGeometryEffect(id: card.id, in: cardAnimation)
.onTapGesture {
withAnimation(.spring(duration: 0.4)) {
expandedID = expandedID == card.id ? nil : card.id
}
}
}
}
}Known Limitations:
- Modifier order matters -- apply
.matchedGeometryEffectbefore.frame()and other layout modifiers, or the animation will interpolate from the wrong geometry - Unreliable inside NavigationStack -- push/pop transitions conflict with matchedGeometryEffect; use
ZStack-based custom navigation instead - AttributeGraph crashes -- if two views with the same ID are visible simultaneously, SwiftUI may crash with "Bound preference ... tried to update multiple times per frame". Ensure only one view with a given ID is in the hierarchy at a time
- Performance with many items -- each matched ID adds overhead to the animation system; avoid matching more than ~20 items simultaneously
Reference: matchedGeometryEffect Documentation)
Use Spring Animations as Default
Spring animations are the iOS system default. They simulate physical motion with natural deceleration, making UI feel responsive rather than mechanical. Since iOS 26, withAnimation uses spring by default, and the API has been simplified with static presets.
Incorrect (mechanical easing):
struct ExpandableCard: View {
@State private var isExpanded = false
var body: some View {
VStack {
Text("Header")
if isExpanded {
Text("Details...")
}
}
.onTapGesture {
withAnimation(.easeInOut(duration: 0.3)) {
isExpanded.toggle()
}
}
}
}Correct (spring physics — iOS 26 / Swift 6.2):
struct ExpandableCard: View {
@State private var isExpanded = false
var body: some View {
VStack {
Text("Header")
if isExpanded {
Text("Details...")
}
}
.onTapGesture {
withAnimation(.spring) {
isExpanded.toggle()
}
}
}
}iOS 26 / Swift 6.2 spring presets (static properties):
.spring // Default, balanced
.smooth // No bounce, smooth settle
.snappy // Quick, minimal bounce
.bouncy // Playful, noticeable bounce
// Custom spring (iOS 26 / Swift 6.2 API: duration/bounce)
.spring(duration: 0.3, bounce: 0.2)
// duration: perceptual duration of the animation
// bounce: 0 = no bounce, 0.5 = moderate, negative = overdampedLegacy spring API (iOS 16 and earlier):
// Use response/dampingFraction for deployment targets < iOS 26
.spring(response: 0.3, dampingFraction: 0.6)
// response: duration-like (lower = faster)
// dampingFraction: 0 = infinite bounce, 1 = no bounceWhen to use each:
| Animation | Use Case |
|---|---|
| .spring | General UI transitions (default) |
| .smooth | Scroll position, subtle changes |
| .snappy | Button feedback, quick actions |
| .bouncy | Fun interactions, achievements |
| .easeOut | One-way exits (dismiss, fade out) |
Implicit animation:
Circle()
.frame(width: isLarge ? 100 : 50)
.animation(.spring, value: isLarge)Reference: WWDC23: Animate with Springs
Apply Transition Effects for View Insertion and Removal
When views are conditionally added or removed from the hierarchy, they pop in and out without any visual cue by default. Adding .transition() modifiers animates the insertion and removal, giving users a clear signal that content has appeared or disappeared.
Incorrect (notification banner appears and disappears abruptly):
struct ContentView: View {
@State private var showBanner = false
var body: some View {
ZStack(alignment: .top) {
MainFeedView()
if showBanner {
NotificationBanner(message: "Item saved successfully")
.padding(.top, 8)
}
}
}
}Correct (transition animates the banner sliding in and fading out):
struct ContentView: View {
@State private var showBanner = false
var body: some View {
ZStack(alignment: .top) {
MainFeedView()
if showBanner {
NotificationBanner(message: "Item saved successfully")
.padding(.top, 8)
.transition(.move(edge: .top).combined(with: .opacity)) // slides in from top and fades
}
}
.animation(.easeInOut(duration: 0.3), value: showBanner)
}
}Reference: Develop in Swift Tutorials
Use withAnimation for State-Driven Transitions
Abrupt state changes disorient users because elements appear or disappear without spatial context. Wrapping state mutations in withAnimation interpolates between the old and new layout, helping users understand what changed and where to look next.
Incorrect (abrupt state change with no animation):
struct FAQItem: View {
let question: String
let answer: String
@State private var isExpanded = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Button {
isExpanded.toggle()
} label: {
HStack {
Text(question)
.font(.headline)
Spacer()
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
}
}
if isExpanded {
Text(answer)
.font(.body)
.foregroundStyle(.secondary)
}
}
.padding()
}
}Correct (withAnimation wraps the state change for smooth disclosure):
struct FAQItem: View {
let question: String
let answer: String
@State private var isExpanded = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Button {
withAnimation(.easeInOut(duration: 0.25)) { // animates the layout change
isExpanded.toggle()
}
} label: {
HStack {
Text(question)
.font(.headline)
Spacer()
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
}
}
if isExpanded {
Text(answer)
.font(.body)
.foregroundStyle(.secondary)
}
}
.padding()
}
}Reference: Develop in Swift Tutorials
Replace Lock-Based Shared State with Actors
Manual lock-based synchronization with NSLock or DispatchQueue is error-prone: every access to the protected state must be wrapped in the correct lock/unlock pair, and a single missed call introduces a data race. Actors provide compiler-enforced mutual exclusion -- all access to an actor's mutable state is automatically serialized, and the compiler rejects unsafe cross-isolation access at build time.
Incorrect (manual lock management, easy to miss):
class ImageCache {
private let lock = NSLock()
private var cache: [URL: UIImage] = [:]
func image(for url: URL) -> UIImage? {
lock.lock()
defer { lock.unlock() }
return cache[url]
}
func store(_ image: UIImage, for url: URL) {
lock.lock()
defer { lock.unlock() }
cache[url] = image
}
func clearExpired(olderThan urls: Set<URL>) {
// Forgetting to lock here causes a data race
for url in urls {
cache.removeValue(forKey: url)
}
}
}Correct (compiler-enforced mutual exclusion):
actor ImageCache {
private var cache: [URL: UIImage] = [:]
func image(for url: URL) -> UIImage? {
cache[url]
}
func store(_ image: UIImage, for url: URL) {
cache[url] = image
}
func clearExpired(olderThan urls: Set<URL>) {
// Actor isolation guarantees exclusive access
for url in urls {
cache.removeValue(forKey: url)
}
}
}Reference: Actor
Replace NotificationCenter Observers with AsyncSequence
Traditional NotificationCenter.addObserver requires a matching removeObserver call in deinit or at teardown. Forgetting the removal causes notifications to be delivered to a deallocated object (potential crash) or to a stale handler (logic bug). The notifications(named:) AsyncSequence stops iteration automatically when the enclosing task is cancelled -- no manual cleanup is needed.
Incorrect (manual observer removal required in deinit):
@Observable
@MainActor
class ConnectivityMonitor {
var isReachable = true
private var observer: NSObjectProtocol?
func startMonitoring() {
observer = NotificationCenter.default.addObserver(
forName: .connectivityChanged,
object: nil,
queue: .main
) { [weak self] notification in
let status = notification.userInfo?["reachable"] as? Bool
MainActor.assumeIsolated {
self?.isReachable = status ?? false
}
}
}
deinit {
// Forgetting this causes stale delivery or crashes
if let observer {
NotificationCenter.default.removeObserver(observer)
}
}
}Correct (automatic cleanup when task is cancelled):
@Observable
@MainActor
class ConnectivityMonitor {
var isReachable = true
func startMonitoring() async {
let notifications = NotificationCenter.default.notifications(
named: .connectivityChanged
)
// Iteration stops automatically when the task is cancelled
for await notification in notifications {
let status = notification.userInfo?["reachable"] as? Bool
isReachable = status ?? false
}
}
}
// Usage in a view:
// .task { await monitor.startMonitoring() }Reference: notifications(named:object:))
Replace Combine Publishers with async/await
Combine publisher chains require manual lifecycle management through Set<AnyCancellable>. Forgetting to store a subscription causes it to be immediately cancelled, while retaining self in .sink closures creates retain cycles. Structured concurrency with async/await scopes the work automatically to the enclosing task -- when the task is cancelled, the work stops without any manual cleanup.
Incorrect (manual cancellable management with retain-cycle risk):
@Observable
@MainActor
class SearchViewModel {
var searchText = ""
var results: [SearchResult] = []
private var cancellables = Set<AnyCancellable>()
private let searchTextSubject = PassthroughSubject<String, Never>()
private let searchService: any SearchServiceProtocol
init(searchService: any SearchServiceProtocol) {
self.searchService = searchService
searchTextSubject
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.removeDuplicates()
.sink { [weak self] query in
guard let self else { return }
Task { await self.performSearch(query) }
}
.store(in: &cancellables)
}
func updateSearch(_ text: String) {
searchText = text
searchTextSubject.send(text)
}
private func performSearch(_ query: String) async {
results = await searchService.search(query: query)
}
}Correct (automatic scoping via structured concurrency):
@Observable
@MainActor
class SearchViewModel {
var searchText = ""
var results: [SearchResult] = []
private let searchService: any SearchServiceProtocol
init(searchService: any SearchServiceProtocol) {
self.searchService = searchService
}
func performSearch(_ query: String) async {
results = (try? await searchService.search(query: query)) ?? []
}
}
struct SearchView: View {
@State private var viewModel = SearchViewModel()
var body: some View {
List(viewModel.results) { result in
SearchRow(result: result)
}
.searchable(text: $viewModel.searchText)
.task(id: viewModel.searchText) {
// Acts as a debounce: cancelled and restarted on each keystroke.
// The 300ms sleep only completes after 300ms of inactivity.
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
await viewModel.performSearch(viewModel.searchText)
}
}
}Note on debounce behavior: The .task(id:) + Task.sleep pattern behaves like Combine's .debounce -- it waits for a pause in changes before executing. Each keystroke cancels the previous sleep and starts a new one, so the search only fires after 300ms of inactivity.
See also: `data-combine-avoid` in swift-ui-architect for the architectural decision rule on when Combine is still appropriate.
Reference: AsyncSequence
Use @MainActor Instead of DispatchQueue.main
DispatchQueue.main.async provides only a runtime guarantee of main-thread execution. If a developer forgets the dispatch, the code still compiles but silently introduces a data race. @MainActor moves this guarantee to compile time -- the compiler rejects any non-isolated call site that tries to invoke main-actor-isolated code synchronously. This is essential under Swift 6 strict concurrency checking, where data races become compile errors.
Incorrect (runtime-only main thread dispatch, easy to forget):
@Observable
class ProfileViewModel {
var profile: UserProfile?
var errorMessage: String?
func loadProfile(userID: String) async {
do {
let result = try await APIClient.fetchProfile(userID: userID)
DispatchQueue.main.async {
self.profile = result
}
} catch {
// Easy to forget DispatchQueue.main here -- silent data race
self.errorMessage = error.localizedDescription
}
}
}Correct (compile-time main thread guarantee):
@MainActor
@Observable
class ProfileViewModel {
var profile: UserProfile?
var errorMessage: String?
func loadProfile(userID: String) async {
do {
// Already on MainActor -- assignment is safe
profile = try await APIClient.fetchProfile(userID: userID)
} catch {
// Compiler guarantees this runs on MainActor too
errorMessage = error.localizedDescription
}
}
}Isolating specific methods instead of the whole class:
@Observable
class DataProcessor {
var results: [ProcessedItem] = []
// Heavy computation stays off the main thread
func process(items: [RawItem]) async -> [ProcessedItem] {
await withTaskGroup(of: ProcessedItem.self) { group in
for item in items {
group.addTask { ProcessedItem(from: item) }
}
return await group.reduce(into: []) { $0.append($1) }
}
}
// Only the UI update is isolated to MainActor
@MainActor
func updateUI(with items: [ProcessedItem]) {
results = items
}
}Reference: MainActor
Adopt Sendable and Swift 6 Strict Concurrency
Swift 6 strict concurrency checking turns data races into compile errors. Types that cross isolation boundaries (passed between actors, captured in @Sendable closures, or sent to Task blocks) must conform to Sendable. Silencing warnings with @unchecked Sendable or nonisolated(unsafe) without understanding the implication reintroduces the data races the compiler is trying to prevent.
Incorrect (silencing the compiler without ensuring safety):
// Marking a mutable class as @unchecked Sendable hides the data race
class AppSettings: @unchecked Sendable {
var theme: Theme = .system
var fontSize: Int = 14
// Mutable properties accessed from multiple threads -- data race
}
// nonisolated(unsafe) on a mutable global
nonisolated(unsafe) var sharedCache: [String: Data] = [:]Correct (using proper isolation to ensure safety):
// Option 1: Actor -- compiler-enforced mutual exclusion
actor AppSettings {
var theme: Theme = .system
var fontSize: Int = 14
}
// Option 2: Sendable struct -- value semantics guarantee safety
struct AppTheme: Sendable {
let theme: Theme
let fontSize: Int
}
// Option 3: MainActor isolation for UI-bound singletons
@Observable
@MainActor
final class AppSettings {
var theme: Theme = .system
var fontSize: Int = 14
}When `@unchecked Sendable` is legitimate:
// Thread-safe wrapper with internal synchronization
final class AtomicCounter: @unchecked Sendable {
private let lock = NSLock()
private var _value: Int = 0
var value: Int {
lock.lock()
defer { lock.unlock() }
return _value
}
func increment() {
lock.lock()
defer { lock.unlock() }
_value += 1
}
}When `nonisolated(unsafe)` is acceptable:
// Truly immutable after initialization (e.g., app launch constants)
// Safe because the value never changes after first assignment
nonisolated(unsafe) let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"Migration strategy: 1. Enable strict concurrency per-target: Swift Compiler > Upcoming Features > StrictConcurrency 2. Fix warnings category by category: global state → Sendable conformance → actor isolation 3. Use @unchecked Sendable only for types with provably-safe internal synchronization 4. Use nonisolated(unsafe) only for truly immutable globals
Swift 6.2 note: nonisolated(nonsending) becomes the default for async functions, meaning nonisolated async functions no longer need explicit @Sendable closures in many cases.
Reference: Swift 6 Migration Guide
Use .task(id:) for Reactive Data Loading
In iOS 26 / Swift 6.2 clinic architecture modules, prefer this pattern for feature View -> ViewModel loading triggers so stale tasks are cancelled before repository calls fan out into Data sync paths.
Using .onChange with a manually created Task requires the developer to track and cancel the previous task before starting a new one. If cancellation is forgotten, multiple tasks run concurrently for stale values, wasting resources and risking out-of-order results. The .task(id:) modifier handles this automatically -- SwiftUI cancels the running task and launches a fresh one whenever the observed value changes.
Incorrect (manual task lifecycle with no automatic cancellation):
struct CategoryItemsView: View {
@State var viewModel: CategoryItemsViewModel
@State private var loadTask: Task<Void, Never>?
var body: some View {
VStack {
CategoryPicker(selection: $viewModel.selectedCategory)
ItemsList(items: viewModel.items)
}
.onChange(of: viewModel.selectedCategory) { _, newCategory in
// Must remember to cancel the previous task
loadTask?.cancel()
loadTask = Task {
await viewModel.loadItems(for: newCategory)
}
}
}
}Correct (automatic cancellation and re-trigger on value change):
struct CategoryItemsView: View {
@State var viewModel: CategoryItemsViewModel
var body: some View {
VStack {
CategoryPicker(selection: $viewModel.selectedCategory)
ItemsList(items: viewModel.items)
}
.task(id: viewModel.selectedCategory) {
// Automatically cancelled and re-launched
// when selectedCategory changes
await viewModel.loadItems(for: viewModel.selectedCategory)
}
}
}
@Observable
@MainActor
class CategoryItemsViewModel {
var selectedCategory: Category = .all
var items: [Item] = []
private let fetchItemsUseCase: any FetchItemsUseCase
init(fetchItemsUseCase: any FetchItemsUseCase) {
self.fetchItemsUseCase = fetchItemsUseCase
}
func loadItems(for category: Category) async {
items = (try? await fetchItemsUseCase.execute(category: category)) ?? []
}
}Reference: task(id:priority:_:))
Use AsyncImage with Caching Strategy for Remote Images
AsyncImage (iOS 15+) simplifies remote image loading with built-in placeholder and error states. However, AsyncImage does not provide meaningful image caching -- it relies on URLSession.shared's URLCache (default 512KB memory / 10MB disk), which is insufficient for most apps. Images scrolled off-screen will be re-fetched. For lists and grids, pair AsyncImage with an explicit caching layer or use a dedicated library.
Incorrect (manual image loading with no error handling):
struct AvatarView: View {
let url: URL
@State private var image: UIImage?
var body: some View {
Group {
if let image {
Image(uiImage: image)
} else {
ProgressView()
}
}
.task {
let (data, _) = try? await URLSession.shared.data(from: url)
if let data { image = UIImage(data: data) }
}
}
}Correct (AsyncImage with phase handling):
struct AvatarView: View {
let url: URL
var body: some View {
AsyncImage(url: url) { phase in
switch phase {
case .empty:
ProgressView()
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fill)
case .failure:
Image(systemName: "person.circle.fill")
.foregroundStyle(.secondary)
@unknown default:
EmptyView()
}
}
.frame(width: 50, height: 50)
.clipShape(Circle())
}
}Simplified syntax with placeholder:
AsyncImage(url: user.avatarURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Color.gray.opacity(0.3)
}
.frame(width: 50, height: 50)
.clipShape(Circle())When AsyncImage is sufficient:
- One-off image loads (profile header, settings avatar)
- Images that appear once per session
- Prototypes and simple apps
When you need a caching library:
- List/grid rows that recycle (images re-fetched on each scroll)
- Offline support required
- Custom cache eviction policies
- Image transformations (resize, blur, round corners at load time)
Recommended caching libraries:
- CachedAsyncImage -- drop-in AsyncImage replacement with NSCache
- Nuke -- full pipeline with disk cache, prefetching, progressive loading
- Kingfisher -- mature, feature-rich image loading and caching
Reference: AsyncImage Documentation
Use Canvas and TimelineView for High-Performance Rendering
SwiftUI's view hierarchy adds per-view overhead for identity tracking, diffing, and layout. For animations with many moving elements (particles, charts with hundreds of data points, custom visualizations), this overhead dominates frame time. Canvas provides immediate-mode drawing that bypasses view diffing entirely, and TimelineView drives frame-rate updates without creating new view instances.
Incorrect (each particle is a separate SwiftUI view -- O(n) view diffing per frame):
struct ParticleField: View {
@State private var particles: [Particle] = []
var body: some View {
TimelineView(.animation) { timeline in
ZStack {
ForEach(particles) { particle in
Circle()
.fill(particle.color)
.frame(width: particle.size, height: particle.size)
.position(particle.position(at: timeline.date))
.opacity(particle.opacity(at: timeline.date))
}
}
// 500 particles = 500 view diffs per frame
}
}
}Correct (Canvas draws all particles in a single pass -- no view diffing):
struct ParticleField: View {
@State private var particles: [Particle] = []
var body: some View {
TimelineView(.animation) { timeline in
Canvas { context, size in
let now = timeline.date
for particle in particles {
let position = particle.position(at: now)
let opacity = particle.opacity(at: now)
let rect = CGRect(
x: position.x - particle.size / 2,
y: position.y - particle.size / 2,
width: particle.size,
height: particle.size
)
context.opacity = opacity
context.fill(
Circle().path(in: rect),
with: .color(particle.color)
)
}
}
}
.ignoresSafeArea()
}
}Rendering resolved symbols for richer content:
Canvas { context, size in
// Resolve symbols once, reuse across draws
let heartSymbol = context.resolveSymbol(id: "heart")
for reaction in reactions {
if let symbol = heartSymbol {
context.draw(symbol, at: reaction.position)
}
}
} symbols: {
Image(systemName: "heart.fill")
.foregroundStyle(.red)
.tag("heart")
}When to use Canvas + TimelineView:
- Particle systems (snow, confetti, fireworks)
- Custom chart rendering with 100+ data points
- Game-like animations with many moving elements
- Any view with > 50 simultaneously-animated elements
When to use regular views instead:
- Interactive elements that need hit testing (Canvas draws are not tappable)
- Accessible content (Canvas content is invisible to VoiceOver)
- Small number of animated elements (< 20)
Reference: Canvas Documentation
Use drawingGroup for Complex Graphics
By default, SwiftUI composites each layer of a view hierarchy on the CPU. For complex custom shapes with many overlapping paths, gradients, or blend modes, this CPU composition becomes a bottleneck, dropping frame rates during animation. Adding .drawingGroup() flattens the view into a single Metal-backed texture, offloading composition to the GPU. Avoid using it for simple views -- the GPU roundtrip adds overhead that exceeds the savings for trivial content.
Incorrect (each element rendered separately on CPU):
struct ParticleEffect: View {
let particles: [Particle]
var body: some View {
ZStack {
ForEach(particles) { particle in
Circle()
.fill(particle.color.gradient)
.frame(width: particle.size, height: particle.size)
.position(particle.position)
.blur(radius: 2)
}
}
// 500 particles = 500 separate render passes
}
}Correct (flattened to single Metal texture):
struct ParticleEffect: View {
let particles: [Particle]
var body: some View {
ZStack {
ForEach(particles) { particle in
Circle()
.fill(particle.color.gradient)
.frame(width: particle.size, height: particle.size)
.position(particle.position)
.blur(radius: 2)
}
}
.drawingGroup() // Renders to single Metal texture
}
}Activity rings with gradients and shadows:
struct ActivityRingsView: View {
let rings: [RingData]
var body: some View {
ZStack {
ForEach(rings) { ring in
Circle()
.trim(from: 0, to: ring.progress)
.stroke(
ring.gradient,
style: StrokeStyle(lineWidth: 20, lineCap: .round)
)
.rotationEffect(.degrees(-90))
.shadow(color: ring.color.opacity(0.5), radius: 6)
.padding(CGFloat(ring.index) * 28)
}
}
.frame(width: 250, height: 250)
.drawingGroup()
// Flattened to a single Metal texture --
// consistent 60 fps even with many rings
}
}Good candidates for drawingGroup:
- Particle systems
- Complex gradients
- Many overlapping shapes
- Path-heavy visualizations
- Charts with many data points
Not recommended for:
- Simple views (overhead not worth it)
- Views with text (can reduce text quality)
- Views needing high-quality scaling
- Interactive elements (breaks hit testing inside)
Combining with compositingGroup:
ZStack {
// Background layers
ForEach(layers) { layer in
layer.view
}
}
.compositingGroup() // Groups for blending
.drawingGroup() // Renders to textureMeasuring impact:
// Use Instruments > Core Animation
// Look for "Offscreen-Rendered" layers
// drawingGroup should reduce render passesReference: drawingGroup Documentation)
Apply @Equatable Macro to Prevent Spurious Redraws
When a view contains ANY non-Equatable property (closures, reference types), SwiftUI's reflection-based diffing fails silently and conservatively re-evaluates body on every parent invalidation. The @Equatable macro generates Equatable conformance for all stored properties, excluding those marked @SkipEquatable. Build fails if a non-Equatable property is added without @SkipEquatable — acting as a compile-time performance linter.
iOS 26 / Swift 6.2 note: With @Observable, SwiftUI tracks property access at the individual property level — only views that read a changed property are invalidated. @Equatable still prevents unnecessary body re-evaluations when views receive closures, non-Observable data, or non-Equatable properties that SwiftUI cannot diff automatically.
Incorrect (no @Equatable — closure makes entire view non-diffable):
struct MetricCard: View {
let title: String
let value: Int
let onTap: () -> Void
var body: some View {
VStack {
Text(title)
.font(.caption)
Text("\(value)")
.font(.title.bold())
}
.onTapGesture { onTap() }
// SwiftUI cannot compare closures, so body
// re-evaluates on every parent invalidation
}
}Correct (@Equatable macro — compile-time diffability guarantee):
@Equatable
struct MetricCard: View {
let title: String
let value: Int
@SkipEquatable let onTap: () -> Void
var body: some View {
VStack {
Text(title)
.font(.caption)
Text("\(value)")
.font(.title.bold())
}
.onTapGesture { onTap() }
// Body only re-evaluates when title or value changes
// Closure excluded from comparison via @SkipEquatable
}
}Complex list row with multiple closures:
@Equatable
struct MessageRow: View {
let message: Message
let isSelected: Bool
@SkipEquatable let onTap: () -> Void
@SkipEquatable let onSwipeDelete: () -> Void
var body: some View {
HStack {
Avatar(url: message.sender.avatarURL)
VStack(alignment: .leading) {
Text(message.sender.name)
Text(message.preview)
}
}
.background(isSelected ? Color.accentColor.opacity(0.1) : .clear)
.onTapGesture(perform: onTap)
}
}Prerequisite: The @Equatable macro requires the `ordo-one/equatable` SPM package (or equivalent). This is NOT built into SwiftUI. Add it via Package.swift or Xcode's package manager. The open-source package uses @EquatableIgnored instead of @SkipEquatable (which is Airbnb's internal name).
Alternative (built-in SwiftUI, no third-party dependency):
struct MetricCard: View, Equatable {
let title: String
let value: Int
let onTap: () -> Void
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.title == rhs.title && lhs.value == rhs.value
}
var body: some View {
VStack {
Text(title).font(.caption)
Text("\(value)").font(.title.bold())
}
.onTapGesture { onTap() }
}
}
// Use with .equatable() modifierWhen to use @Equatable:
- Every view that receives closures (callbacks, actions)
- Views with complex nested data
- List/grid rows that update frequently
- Views where you want compile-time diffability enforcement
When manual Equatable is acceptable:
- Projects that cannot add the ordo-one/equatable dependency
- Simple views with no closures where SwiftUI's automatic diffing suffices
See also: `diff-equatable-views`, `diff-closure-skip` in swift-ui-architect for the full diffing strategy.
Reference: Airbnb Engineering — Understanding and Improving SwiftUI Performance
Profile with SwiftUI Instruments Before Optimizing
Premature optimization wastes effort on code paths that are not actual bottlenecks. The SwiftUI Instruments template reveals which views re-evaluate most frequently, how long each body takes, and where the render pipeline stalls. Always measure before applying optimization patterns.
Incorrect (guessing at the bottleneck):
// Developer assumed LazyVStack was the problem and added
// Equatable to every row -- but the actual bottleneck was
// an expensive date formatter called in body
struct MessageRow: View, Equatable {
let message: Message
static func == (lhs: MessageRow, rhs: MessageRow) -> Bool {
lhs.message.id == rhs.message.id &&
lhs.message.updatedAt == rhs.message.updatedAt
}
var body: some View {
HStack {
Text(message.sender)
Spacer()
// This creates a new DateFormatter on every call
Text(DateFormatter.localizedString(
from: message.date,
dateStyle: .short,
timeStyle: .short
))
}
}
}Correct (profile first, then fix the actual bottleneck):
struct MessageRow: View {
let message: Message
var body: some View {
HStack {
Text(message.sender)
Spacer()
// Cache the formatted date on the model, not in the view
Text(message.formattedDate)
}
}
}
extension Message {
// Computed once when message is created, reused across redraws
var formattedDate: String {
Self.dateFormatter.string(from: date)
}
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
return formatter
}()
}How to profile SwiftUI:
1. Open Instruments: Product > Profile (Cmd+I) in Xcode 2. Choose template: Select "SwiftUI" (or "Time Profiler" for general CPU) 3. Record: Interact with the problem area (scroll, navigate, animate) 4. Analyze body evaluations: Look for views with high evaluation counts or long durations 5. Check for hangs: The "Hangs" instrument shows main thread stalls > 250ms
Key metrics to watch:
- Body evaluation count -- if a view evaluates 100x during a scroll, it needs decomposition
- Body evaluation duration -- if
bodytakes > 1ms, it contains expensive work - View update count -- mismatches between evaluations and updates indicate wasted diffs
- Main thread hangs -- stalls > 250ms are visible as frame drops
Profiling checklist before optimizing:
- [ ] Profile in Release mode (Debug has 10-100x overhead for SwiftUI)
- [ ] Record the specific user flow that feels slow
- [ ] Identify the top 3 views by evaluation count
- [ ] Check if body durations exceed 1ms for any view
- [ ] Verify the optimization actually improves the metric
Use Lazy Containers for Large Collections
Non-lazy stacks instantiate every child view upfront, even those far off-screen. For a collection of 1,000+ items, this means 1,000+ view allocations, body evaluations, and layout passes on initial load. LazyVStack and LazyHStack only create views as they scroll into the visible area, reducing initial work from O(total) to O(visible).
Memory comparison:
- VStack with 1000 rows: ~1000 views in memory
- LazyVStack with 1000 rows: ~20 views in memory (visible + buffer)
Incorrect (all items instantiated upfront, even off-screen):
struct TransactionHistoryView: View {
let transactions: [Transaction]
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 8) {
ForEach(transactions) { transaction in
TransactionRow(transaction: transaction)
}
}
// All 1,000+ TransactionRow views are created
// immediately, causing a multi-second hang
}
}
}Correct (only visible items are instantiated on demand):
struct TransactionHistoryView: View {
let transactions: [Transaction]
var body: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(transactions) { transaction in
TransactionRow(transaction: transaction)
}
}
// Only the ~15 visible TransactionRow views are
// created; the rest load as the user scrolls
}
}
}Lazy grid for galleries:
struct PhotoGallery: View {
let photos: [Photo]
private let columns = [
GridItem(.adaptive(minimum: 100), spacing: 2)
]
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 2) {
ForEach(photos) { photo in
AsyncImage(url: photo.thumbnailURL)
.aspectRatio(1, contentMode: .fill)
}
}
}
}
}Combining with pagination:
LazyVStack {
ForEach(items) { item in
ItemRow(item: item)
}
if hasMoreItems {
ProgressView()
.onAppear { loadMoreItems() }
}
}When NOT to use Lazy:
- Small, fixed collections (< 20 items) -- regular stacks avoid lazy container bookkeeping overhead
- When you need simultaneous animations
- When using
.id()modifier (can break lazy loading)
See also: `list-lazy-stacks` in swift-ui-architect for lazy stack usage within the Airbnb architecture.
Reference: Creating Performant Scrollable Stacks
Use .task Modifier Instead of .onAppear for Async Work
The .task modifier runs async work when a view appears and automatically cancels it when the view disappears. Using .onAppear with a manually created Task leaks work -- the task continues executing even after the view is gone, wasting CPU, network, and memory.
Incorrect (onAppear doesn't cancel when view disappears):
struct ArticleView: View {
@State var viewModel: ArticleViewModel
var body: some View {
ArticleContent(article: viewModel.article)
.onAppear {
Task {
// This task continues even if view disappears
await viewModel.loadArticle()
}
}
}
}Correct (.task auto-cancels on disappearance):
struct ArticleView: View {
@State var viewModel: ArticleViewModel
var body: some View {
ArticleContent(article: viewModel.article)
.task {
await viewModel.loadArticle()
}
}
}
@Observable
@MainActor
class ArticleViewModel {
let articleID: String
var article: Article?
private let fetchArticleUseCase: any FetchArticleUseCase
init(articleID: String, fetchArticleUseCase: any FetchArticleUseCase) {
self.articleID = articleID
self.fetchArticleUseCase = fetchArticleUseCase
}
func loadArticle() async {
article = try? await fetchArticleUseCase.execute(id: articleID)
}
}Handling cancellation explicitly:
.task {
do {
await viewModel.loadArticle()
} catch is CancellationError {
// View disappeared -- no action needed
} catch {
viewModel.error = error
}
}Multiple async operations in parallel:
.task {
async let articles = viewModel.loadArticles()
async let user = viewModel.loadUser()
// Both cancelled if view disappears
await articles
await user
}When to use .onAppear instead:
- Synchronous work only
- Fire-and-forget analytics
- UI state setup (focus, scroll position)
See also: `conc-task-id-pattern` for re-triggering async work when a value changes using .task(id:). See `data-task-modifier` in swift-ui-architect for the architectural usage pattern with ViewModels.
Reference: task(priority:_:) Documentation)
Decompose Views to Limit State Invalidation Blast Radius
When a @State or @Observable property changes, SwiftUI re-evaluates the body of the view that owns or reads that property -- plus all child views constructed inline. In a monolithic view, a single counter increment re-evaluates hundreds of lines of body. Extracting independent subtrees into separate view structs limits invalidation to only the subtree that depends on the changed state.
Incorrect (monolithic view -- any state change re-evaluates entire body):
struct OrderDetailView: View {
@State private var quantity = 1
@State private var selectedShipping: ShippingMethod = .standard
let product: Product
var body: some View {
ScrollView {
// Product header -- re-evaluated when quantity changes
VStack {
AsyncImage(url: product.imageURL)
.frame(height: 300)
Text(product.name).font(.title)
Text(product.description).font(.body)
StarRating(rating: product.rating)
}
// Quantity picker -- owns the state
Stepper("Quantity: \(quantity)", value: $quantity, in: 1...99)
// Shipping section -- re-evaluated when quantity changes
ForEach(ShippingMethod.allCases) { method in
ShippingOptionRow(
method: method,
isSelected: selectedShipping == method
)
.onTapGesture { selectedShipping = method }
}
// Price summary -- re-evaluated when quantity changes
PriceSummary(
unitPrice: product.price,
quantity: quantity,
shipping: selectedShipping
)
}
}
}Correct (decomposed -- each section only re-evaluates when its inputs change):
struct OrderDetailView: View {
let product: Product
@State private var quantity = 1
@State private var selectedShipping: ShippingMethod = .standard
var body: some View {
ScrollView {
ProductHeader(product: product)
QuantityPicker(quantity: $quantity)
ShippingSelector(selected: $selectedShipping)
PriceSummary(
unitPrice: product.price,
quantity: quantity,
shipping: selectedShipping
)
}
}
}
// Only re-evaluated when product changes (which is rare)
private struct ProductHeader: View {
let product: Product
var body: some View {
VStack {
AsyncImage(url: product.imageURL)
.frame(height: 300)
Text(product.name).font(.title)
Text(product.description).font(.body)
StarRating(rating: product.rating)
}
}
}
// Only re-evaluated when quantity binding fires
private struct QuantityPicker: View {
@Binding var quantity: Int
var body: some View {
Stepper("Quantity: \(quantity)", value: $quantity, in: 1...99)
}
}
// Only re-evaluated when selectedShipping binding fires
private struct ShippingSelector: View {
@Binding var selected: ShippingMethod
var body: some View {
ForEach(ShippingMethod.allCases) { method in
ShippingOptionRow(method: method, isSelected: selected == method)
.onTapGesture { selected = method }
}
}
}Guidelines:
- Extract any section that does not depend on frequently-changing state
- Pass only the minimum data each subview needs (
@Bindingfor mutable,letfor read-only) - Use
private structfor subviews that are only used by one parent - Profile with Instruments > SwiftUI to verify which views re-evaluate
When NOT to decompose:
- Views with < 5 child elements and no expensive computations
- When all children depend on the same state (decomposition adds structure without reducing work)
See also: `view-body-complexity` and `view-extract-subviews` in swift-ui-architect for the maximum 10 node rule and subview extraction patterns.
Related skills
FAQ
What does swift-optimise do?
swift-optimise: A skill for development. This provides functionality for development workflows.
When should I use swift-optimise?
When you need to use swift-optimise for development tasks, or when swift-optimise: a skill for development. this provides functionality for development workflows.
What are the main capabilities?
swift-optimise.