Overview
SwiftUI on iOS 18 with Swift 6 and Xcode 16 is the default UI stack for new iOS work. This page covers patterns that hold up at scale: small views, the Observation macro, type-erased navigation, preview-driven development, environment dependencies, and accessibility from the first commit. For language rules, see swift; for map and persistence, see mapkit and core-data.
Keep views small and extract aggressively
A view’s body should fit on one screen. When a modifier chain grows past 5 modifiers or the body past 30 lines, extract a subview.
struct ProfileHeader: View {
let user: User
var body: some View {
HStack(spacing: 12) {
AvatarView(url: user.avatarURL)
VStack(alignment: .leading) {
Text(user.name).font(.headline)
Text(user.handle).font(.subheadline).foregroundStyle(.secondary)
}
}
.padding(.horizontal)
}
}Small views preview faster and let the compiler infer types without the “too complex to type-check” error. Extract anything that takes its own props.
Use @Observable for reference-type state
Drop ObservableObject and @Published on new code. The Observation macro (iOS 17+) tracks reads per property and only re-renders views that read a changed property.
import Observation
@Observable
final class FeedModel {
var posts: [Post] = []
var isLoading = false
func load() async { /* ... */ }
}
struct FeedView: View {
@State private var model = FeedModel()
var body: some View {
List(model.posts) { PostRow(post: $0) }
.task { await model.load() }
}
}Use @State to own the model in the view that creates it. Use @Bindable in child views that need two-way bindings into an @Observable passed by parameter.
Type-erase NavigationStack destinations with Hashable
Drive navigation by a typed path, not chained NavigationLink views. Map destination types to views once at the root.
struct AppRouter: View {
@State private var path: [Route] = []
var body: some View {
NavigationStack(path: $path) {
HomeView()
.navigationDestination(for: Route.self) { route in
switch route {
case .post(let id): PostDetail(id: id)
case .user(let id): UserDetail(id: id)
}
}
}
}
}
enum Route: Hashable { case post(Post.ID), user(User.ID) }path is the single source of truth. Deep links and state restoration work by mutating the array.
Drive previews with explicit state
Use the #Preview macro and pass stub data. A preview that does not exercise loading, empty, and error states is half a preview.
#Preview("Loaded") {
FeedView(model: .preview(posts: Post.samples))
}
#Preview("Empty") {
FeedView(model: .preview(posts: []))
}Add a static func preview(...) on each model so previews and tests share the stub. Previews that talk to the network or disk are flaky; inline data is not.
Put shared dependencies in the environment
Inject services through @Environment keys, not singletons. Tests and previews swap implementations by setting a different value on the root.
private struct AnalyticsKey: EnvironmentKey {
static let defaultValue: any Analytics = NoopAnalytics()
}
extension EnvironmentValues {
var analytics: any Analytics {
get { self[AnalyticsKey.self] } set { self[AnalyticsKey.self] = newValue }
}
}
// At the app root
RootView().environment(\.analytics, FirebaseAnalytics())Use this for analytics, feature flags, the API client, and anything else a leaf view should not construct.
Keep body declarative; compute elsewhere
ViewBuilder runs on every state change. Any work in body runs again on every render.
- Move sorting, filtering, and formatting into the model or a
@Statecache. - Use
.task(id:)for async work tied to a value; the closure cancels and restarts when the id changes. - Reach for
.onChange(of:)only when a side effect must fire on a value transition.
Profile with Instruments’ SwiftUI template before guessing.
Accessibility is not optional
Every interactive element needs a label. Every image that conveys information needs a description. Dynamic Type should not break the layout.
.accessibilityLabel("Save draft")on icon-only buttons..accessibilityHint("Saves the current note without publishing.")when the label is ambiguous.- Test with the largest accessibility size via preview environment overrides; fix overflows with
ViewThatFitsor scrollable containers. - Audit with the Accessibility Inspector before shipping.