Pixels are only half of why an Apple app feels native. The other half is small decisions in code, like where the state lives, what runs on the main thread, and whether a button answers in the same frame you touch it.
I’ve been learning SwiftUI and Apple’s Design Guidelines, and these are the tips that stuck. I wanted to see them as pictures, so I drew them out. It’s for anyone who cares about how Apple software looks and how it behaves.
Swift is a progressive-disclosure language. Start with the simplest, most static, most single-threaded thing that works, and buy complexity only when you can point at the reason.
The ladder of defaults
Almost every choice in Swift has a gentle default and a stronger option underneath it. Step down only when you can say why, out loud, to another person.
flowchart TD
A["Data<br/>struct / enum"] --> B["Abstraction<br/>concrete type"]
B --> C["Polymorphism<br/>some P"]
C --> D["Execution<br/>main actor, synchronous"]
D --> E["Memory<br/>Array, String"]
E --> F["Safety<br/>safe APIs"]
A -. "need identity or sharing" .-> A2["class"]
B -. "repeated code" .-> B2["protocol"]
C -. "mixed types in one list" .-> C2["any P"]
D -. "profiler shows a hang" .-> D2["async, @concurrent, actor"]
E -. "profiler shows the cost" .-> E2["InlineArray, Span"]
F -. "C interop or hot path" .-> F2["Unsafe pointers"]
classDef calm fill:#E3F1FF,stroke:#5AA9FF,color:#1d1d1f,stroke-width:1.5px
classDef power fill:#FFE9D6,stroke:#FF9F58,color:#1d1d1f,stroke-width:1.5px,stroke-dasharray:4 3
class A,B,C,D,E,F calm
class A2,B2,C2,D2,E2,F2 power
Model your data with values
struct and enum are the default. A class is for things with identity, like a window, a database connection or a document that exists once. A Point or a Drink is just a value.
flowchart LR
Q{"Does it have<br/>identity?"}
Q -- "No, it's just data" --> S["struct / enum"]
Q -- "Yes, one shared thing" --> C["class"]
Q -- "Must never be copied" --> N["~Copyable"]
classDef good fill:#DDF5E4,stroke:#4CC38A,color:#1d1d1f,stroke-width:1.5px
classDef warn fill:#FFE9D6,stroke:#FF9F58,color:#1d1d1f,stroke-width:1.5px
classDef ask fill:#F0E6FF,stroke:#A678F0,color:#1d1d1f,stroke-width:1.5px
class S good
class C,N warn
class Q ask
Two habits help right away. Use let unless something really mutates, and never hide a mutable reference type inside a struct and call it a value, because every copy will share it and changes will leak.
Impossible states
This one is my favorite for UI. A pile of optional properties lets your screen land in states that make no sense:
// Can be "sharing" with no target, or have rows selected while not sharing
var isSharing = false
var selectedRows: [Row] = []
var shareTarget: Contact?
One enum fixes it, and a state change happens all at once instead of as a series of writes you might forget to finish:
enum State {
case browsing
case selecting(rows: [Row])
case sharing(rows: [Row], target: Contact)
}
stateDiagram-v2
[*] --> browsing
browsing --> selecting: tap Select
selecting --> sharing: choose a contact
sharing --> browsing: done
selecting --> browsing: cancel
If you can draw the states, you can write the enum, and then your SwiftUI switch is basically the design. One view per state, nothing in between.
Stay on the main thread
This part trips up a lot of people, because the model changed in Swift 6.2. The rule is simple. Keep everything on the main actor and move work away only when Instruments shows a hang.
flowchart LR
A["1. Main actor<br/>no concurrency at all"] --> B["2. async / await<br/>hide network and disk waits"]
B --> C["3. @concurrent<br/>heavy work off the main thread"]
C --> D["4. actor<br/>move state off the main actor"]
classDef step fill:#E3F1FF,stroke:#5AA9FF,color:#1d1d1f,stroke-width:1.5px
classDef later fill:#F0E6FF,stroke:#A678F0,color:#1d1d1f,stroke-width:1.5px
class A,B step
class C,D later
In Swift 6.2, marking a function async does not move it off the current actor. It runs wherever it was called from. To really leave the main thread, you have to say so.
nonisolated struct PhotoProcessor {
@concurrent // guaranteed to run in the background
func process(_ data: Data) async -> ProcessedPhoto {
async let sticker = extractSticker(data) // two independent jobs, in parallel
async let colors = extractColors(data)
return await ProcessedPhoto(sticker: sticker, colors: colors)
}
}
The keywords differ like this:
| Keyword | Where it runs | Use it for |
|---|---|---|
| (nothing) | The caller’s actor | Almost everything in an app |
nonisolated |
Wherever it’s called from | Library APIs, the caller decides |
@concurrent |
The background thread pool | CPU-heavy work you measured |
Two build settings help. Turn on Approachable Concurrency in every project, and set Default Actor Isolation to MainActor for app targets (new Xcode 26 app projects already do). After that you can delete most of your @MainActor annotations.
Profile before you offload. A task has a cost, and starting one to read a single
UserDefaultsvalue costs more than it saves.
Animate in the same frame
When someone taps a button or drags a card, the response has to start in the same frame as the touch. If you wait for a Task to get scheduled, the interface feels sticky, even when the delay is a few milliseconds.
sequenceDiagram
participant You as You
participant UI as SwiftUI callback
participant Task as Task (async work)
You->>UI: tap
UI->>UI: withAnimation { state change }
Note over UI: same frame, instant feel
UI->>Task: start the long-running work
Task-->>UI: synchronous update when finished
That’s why SwiftUI action callbacks are synchronous. Put the withAnimation change right there and open a Task only for the slow part that comes after:
Button("Save") {
withAnimation(.spring) { isSaving = true } // instant
Task {
await store.save(draft) // slow part
isSaving = false
}
}
One more detail. SwiftUI runs a few of your closures off the main thread on purpose to keep frames cheap, such as visualEffect, Shape.path(in:) and onGeometryChange. You can spot them by the @Sendable in their signature. If the compiler complains inside one, don’t capture self. Copy the one value you need:
.visualEffect { [pulse] effect, proxy in
effect.blur(radius: pulse ? 2 : 0)
}
Prefer some over any
Write some View by default and use any only when you need to store different types together.
flowchart LR
S["some P<br/>one fixed type"] --> S1["keeps every type relationship"]
S --> S2["compiler can specialize it"]
X["any P<br/>a type-erased box"] --> X1["mixed types in one array"]
X --> X2["costs speed and type info"]
classDef some fill:#DDF5E4,stroke:#4CC38A,color:#1d1d1f,stroke-width:1.5px
classDef any fill:#FFE9D6,stroke:#FF9F58,color:#1d1d1f,stroke-width:1.5px
class S,S1,S2 some
class X,X1,X2 any
Don’t start with a protocol either. Write concrete types first, notice the repeated code, then pull the shared part out. If every conformance would use the same default implementation, you don’t need a protocol. A constrained extension does the job.
Design the API for the point of use
Code gets read far more than it gets written, so clarity at the call site wins over everything else. These are the small rules I’d follow.
Skip type prefixes in Swift-only code, since modules already keep names apart. Drop the leading get, so it’s persistentPosts and not getPersistentPosts. Use throw for problems you can recover from, like a failed network call, and precondition for programmer mistakes, like an out-of-bounds index. Give errors context, because case duplicateFriend(String) tells you far more than case duplicateFriend.
Also, use @Observable instead of ObservableObject with a @Published on every property, plus if and switch expressions instead of nested ternaries.
Measure, then make it fast
The biggest speedups are almost never micro-optimizations. They come from the algorithm.
flowchart LR
A["Write it clearly"] --> B["Does it hang or drop frames?"]
B -- "No" --> Z["Ship it"]
B -- "Yes" --> C["Profile in Instruments"]
C --> D["Fix the algorithm first"]
D --> E{"Still slow?"}
E -- "No" --> Z
E -- "Yes" --> F["Then reach for @concurrent,<br/>InlineArray or Span"]
classDef ok fill:#DDF5E4,stroke:#4CC38A,color:#1d1d1f,stroke-width:1.5px
classDef step fill:#E3F1FF,stroke:#5AA9FF,color:#1d1d1f,stroke-width:1.5px
classDef ask fill:#F0E6FF,stroke:#A678F0,color:#1d1d1f,stroke-width:1.5px
class Z ok
class A,C,D,F step
class B,E ask
A few classics: remove(at:) inside a loop is O(n²), while removeAll(where:) is O(n) in total. Chained map, filter and flatMap allocate an array per stage, which is fine for a list and expensive per pixel. Hopping to the main actor costs a real context switch, so batch the work and hop once instead of once per item.
Test with Swift Testing
New tests should use @Test and #expect, so there’s no family of XCTAssertEqual functions to memorize. @Test(arguments:) runs the same test over many inputs and names the failing one in the results.
@Test(arguments: ["Espresso", "Latte", "Cortado"])
func `drinks have a name`(name: String) {
#expect(!name.isEmpty)
}
XCTest is still needed for UI automation, performance metrics and Objective-C code.
Summary
| Need | Reach for | Not |
|---|---|---|
| A data type | struct / enum |
class without identity |
| Shared mutable state | actor or @MainActor class |
class plus a lock you must remember |
| Move work off the main thread | @concurrent |
Task.detached, DispatchQueue.global() |
| Work tied to a UI event | Task { } inside the callback |
making the callback async |
| Polymorphism | some P |
any P unless you need storage |
| Deciding to optimize | Instruments | intuition |