Skip to main content

Technical tips for designing for Apple devices

Tips from my journey learning Swift UI and Apple Design Guidelines

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 UserDefaults value 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