iOS Engineer Hub

SwiftUI for iOS Engineers (2026): A Senior Engineer's Reference

In short

SwiftUI is the default UI framework for new iOS work in 2026, but it earns its senior+ reputation through three specific competencies: migrating from ObservableObject to @Observable without leaking class identity, writing a custom Layout that doesn't degrade scroll performance, and reasoning about view identity well enough to avoid the .id() trap that destroys text-field focus on every parent re-render. This page walks through each with real Swift code, names the WWDC sessions that actually matter, and points to the production gotchas you will hit at scale.

Key takeaways

  • @Observable (iOS 17+, SE-0395 macros) replaced ObservableObject for new code; the migration is mechanical except in two places — class-identity comparisons and Combine publishers — both covered in WWDC23 'Discover Observation in SwiftUI' (8:42 onward).
  • View identity is the single most common source of production SwiftUI bugs. Using .id(forceRefresh) to trigger a re-render destroys all child @State, focus, and scroll position — the canonical anti-pattern WWDC22 'Demystify SwiftUI' (37:00–42:30) walks through.
  • Custom Layout (iOS 16+) is the senior+ bar. A flow layout in 60 lines beats nesting GeometryReader inside ZStack inside ScrollView — and reasons about cache invalidation correctly.
  • Performance: SwiftUI re-evaluates a body whenever any read property changes. The fix is granular — Equatable conformance on view structs, .equatable() modifier, or an @Observable model that only marks specific properties dirty. WWDC24 'Demystify SwiftUI containers' covers measurement.
  • Cross-platform adaptation in 2026 means one SwiftUI source tree with #if os(visionOS) and platform-specific scene types — RealityView for visionOS, NavigationSplitView for iPadOS/macOS, NavigationStack for iPhone.

@Observable migration: the mechanical part and the two traps

The Observation framework (SE-0395) introduced in iOS 17 / Swift 5.9 replaces ObservableObject. The mechanical migration looks like this:

// BEFORE (iOS 13–16): ObservableObject + @Published
import Combine

final class CartViewModel: ObservableObject {
    @Published var items: [CartItem] = []
    @Published var isLoading = false
}

struct CartView: View {
    @StateObject private var vm = CartViewModel()
    var body: some View {
        List(vm.items) { item in CartRow(item: item) }
    }
}

// AFTER (iOS 17+): @Observable macro
import Observation

@Observable
final class CartViewModel {
    var items: [CartItem] = []
    var isLoading = false
}

struct CartView: View {
    @State private var vm = CartViewModel()
    var body: some View {
        List(vm.items) { item in CartRow(item: item) }
    }
}

The traps:

  • Don't use @StateObject anymore. @State is correct for owned @Observable instances. @StateObject only exists for ObservableObject conformance — Xcode will warn but not error if you mix them.
  • @Bindable replaces @ObservedObject for two-way binding to non-owned models. Inside a child view that needs $vm.items, declare @Bindable var vm: CartViewModel at the top of body or as a parameter wrapper.
  • Combine publishers don't auto-bridge. If your view-model exposed a $items publisher consumed by something downstream, @Observable does not generate one. Use withObservationTracking manually or expose an AsyncSequence.

WWDC23 'Discover Observation in SwiftUI' (developer.apple.com/videos/play/wwdc2023/10149) covers the migration surface end-to-end; the @Bindable section starts at 8:42.

View identity and the .id() modifier trap

SwiftUI uses identity to decide whether to keep a view's state across re-renders or to throw it away and create a fresh one. When identity changes, every @State inside that subtree resets — including TextField focus, ScrollView position, and any @StateObject instances. The most common production bug is using .id() to force a re-render:

// BAD — destroys text-field focus on every parent re-render
struct SearchScreen: View {
    @State private var query = ""
    @State private var refreshToken = UUID()  // changes on pull-to-refresh

    var body: some View {
        SearchBar(query: $query)
            .id(refreshToken)  // ← every refresh kills the keyboard
        ResultList(query: query)
    }
}

// GOOD — explicitly invalidate just the data, not the view identity
struct SearchScreen: View {
    @State private var query = ""
    @State private var results: [Result] = []

    var body: some View {
        SearchBar(query: $query)
        ResultList(results: results)
            .task(id: query) {
                results = await api.search(query)
            }
    }
}

WWDC22 'Demystify SwiftUI' (developer.apple.com/videos/play/wwdc2022/10056, identity section 37:00–42:30) walks through three identity bugs that ship to production at FAANG-tier teams. The rule of thumb: .id() should only set identity from a stable model identifier (e.g., .id(post.id)) — never from a refresh counter or boolean toggle.

Custom Layout: a flow layout in 60 lines

The Layout protocol (iOS 16+, WWDC22 'Compose custom layouts with SwiftUI' at developer.apple.com/videos/play/wwdc2022/10056) is what separates senior from staff in SwiftUI work. It also fixes the most common SwiftUI scroll-performance issue: nested GeometryReader inside a ScrollView, which forces a full re-measure on every scroll tick.

struct FlowLayout: Layout {
    var spacing: CGFloat = 8

    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        let maxWidth = proposal.width ?? .infinity
        var rows: [CGFloat] = [0]
        var currentRowWidth: CGFloat = 0
        var totalHeight: CGFloat = 0
        var rowHeight: CGFloat = 0

        for subview in subviews {
            let size = subview.sizeThatFits(.unspecified)
            if currentRowWidth + size.width > maxWidth {
                totalHeight += rowHeight + spacing
                currentRowWidth = size.width + spacing
                rowHeight = size.height
                rows.append(0)
            } else {
                currentRowWidth += size.width + spacing
                rowHeight = max(rowHeight, size.height)
            }
        }
        totalHeight += rowHeight
        return CGSize(width: maxWidth, height: totalHeight)
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        var x = bounds.minX
        var y = bounds.minY
        var rowHeight: CGFloat = 0

        for subview in subviews {
            let size = subview.sizeThatFits(.unspecified)
            if x + size.width > bounds.maxX {
                x = bounds.minX
                y += rowHeight + spacing
                rowHeight = 0
            }
            subview.place(
                at: CGPoint(x: x, y: y),
                proposal: ProposedViewSize(size)
            )
            x += size.width + spacing
            rowHeight = max(rowHeight, size.height)
        }
    }
}

// Usage — wrap any SwiftUI views
FlowLayout(spacing: 12) {
    ForEach(tags) { tag in TagChip(text: tag.name) }
}

The senior-bar refinement: implement the cache associated type to memoize sizes. For 200+ subviews, a non-cached layout will re-measure on every sizeThatFits call. The cache pattern is in WWDC22 'Compose custom layouts' at 19:30.

Performance: where SwiftUI re-evaluates and how to stop it

SwiftUI re-evaluates a view's body whenever any property it reads changes. With @Observable, the framework tracks reads at the property level — but only if the property is read inside the body during dependency tracking. Three patterns matter:

  • Equatable conformance on heavy views. Add extension MyHeavyRow: Equatable and use .equatable(). SwiftUI will skip body re-evaluation when the view's properties haven't changed.
  • Move expensive work out of body. If you compute a derived value (sorted list, formatted date) inside body, it runs on every re-evaluation. Move it to a computed property on an @Observable model where the macro caches the read.
  • Use Self._printChanges() in DEBUG. Calling let _ = Self._printChanges() at the top of body logs why a view re-rendered. Indispensable for hunting down the view that's invalidating on every keystroke.
struct ExpensiveRow: View, Equatable {
    let item: Item

    static func == (lhs: ExpensiveRow, rhs: ExpensiveRow) -> Bool {
        lhs.item.id == rhs.item.id && lhs.item.updatedAt == rhs.item.updatedAt
    }

    var body: some View {
        let _ = Self._printChanges()  // DEBUG only
        VStack {
            Text(item.title)
            Text(item.formattedDate)  // computed once per equality check
        }
    }
}

// In parent
ForEach(items) { item in
    ExpensiveRow(item: item).equatable()
}

WWDC23 'Demystify SwiftUI performance' (developer.apple.com/videos/play/wwdc2023/10160) covers Instruments-based measurement starting at 14:00. The 'time profiler with SwiftUI lane' workflow is standard senior+ tooling.

Production gotchas you will hit

  • NavigationStack path bindings broke between iOS 17 and 18. Programmatic path mutation inside .task on iOS 17 sometimes silently no-ops; iOS 18 fixed it but introduced a new edge case where .navigationDestination(item:) with optional binding double-fires. Workaround: drive navigation from an @Observable router rather than path-binding directly.
  • Sheet detents have a history of regressions. .presentationDetents([.medium, .large]) shipped in iOS 16; the .height() custom detent shipped in iOS 16.4. Drag interactions changed in iOS 17. Test against the lowest deployment target your app supports.
  • ScrollView content offset is not directly readable in iOS 16. iOS 17 added .scrollPosition(id:); iOS 18 added .onScrollGeometryChange(for:of:action:). Below iOS 17, you need GeometryReader inside ScrollView with named coordinate space — and that pattern degrades scroll performance, which is why the new APIs exist.
  • @Observable + Codable do not auto-compose. The macro-synthesized observation registrar is non-Codable. Custom Codable implementation required if you want to persist the model directly.

Cross-platform: one source tree, multiple platform scenes

The 2026-correct cross-platform pattern is one SwiftUI source tree with platform-specific scene types and modifier branches. The shape:

@main
struct CrossPlatformApp: App {
    var body: some Scene {
        #if os(visionOS)
        WindowGroup(id: "main") {
            ContentView()
        }
        .windowStyle(.volumetric)

        ImmersiveSpace(id: "immersive") {
            ImmersiveScene()
        }
        #else
        WindowGroup {
            ContentView()
        }
        #endif
    }
}

struct ContentView: View {
    var body: some View {
        #if os(macOS) || os(iPadOS)
        NavigationSplitView {
            Sidebar()
        } content: {
            ItemList()
        } detail: {
            ItemDetail()
        }
        #else
        NavigationStack {
            ItemList()
        }
        #endif
    }
}

// Modifier-level adaptation
struct ItemRow: View {
    let item: Item
    var body: some View {
        HStack { Text(item.title); Spacer() }
            .padding()
            #if os(visionOS)
            .glassBackgroundEffect()
            #else
            .background(Color(.secondarySystemBackground))
            #endif
    }
}

The rule of thumb from WWDC23 'Make features for visionOS' (developer.apple.com/videos/play/wwdc2023/10110) at 12:00: fork the modifiers, not the views. NavigationSplitView is the cross-platform navigation primitive (iOS 16, macOS 13, visionOS 1) — use it everywhere except phone where NavigationStack is the right shape. Avoid #if os() at the view-tree level — it makes the code unreadable.

Frequently asked questions

When should I prefer UIKit over SwiftUI in 2026?
Three cases: (1) complex collection-view layouts with custom interaction (drag-and-drop reordering across sections, custom decoration views) — UICollectionViewCompositionalLayout still beats anything SwiftUI offers; (2) heavy text input with custom keyboard handling — UITextView gives you cursor-position events SwiftUI's TextField hides; (3) any code that has to support iOS 15 or below.
Is @StateObject deprecated?
Not technically deprecated, but obsolete for new code. @StateObject only works with ObservableObject; for @Observable models, use @State. Xcode will warn if you use @StateObject with an @Observable type — that's the signal you should switch to @State.
How do I debug 'Modifying state during view update' warnings?
The warning means a body evaluation is mutating a state that triggers another re-evaluation. Most common cause: calling a mutating method inside a computed body property (e.g., assigning to a @State inside a Text initializer). Move the mutation to .task, .onAppear, or .onChange. Build with -Xfrontend -warn-long-expression-type-checking=200 to surface slow type-check expressions that sometimes mask the real source.
What replaces UIViewRepresentable for visionOS?
RealityView for 3D content; UIViewRepresentable still works for 2D UIKit interop on visionOS. The new piece is RealityView's update closure, which gives you a RealityViewContent value you mutate to add/remove Entity instances — covered in WWDC23 'Build spatial experiences with RealityKit' (developer.apple.com/videos/play/wwdc2023/10080) at 18:00.
How do I test SwiftUI views?
Three layers: (1) View-model unit tests against the @Observable class — pure Swift, no SwiftUI imports. (2) Snapshot tests via swift-snapshot-testing or Apple's new ViewSnapshot APIs. (3) UI tests via XCUITest with the .accessibilityIdentifier modifier on every interactive element. SwiftUI's view tree is not directly inspectable — you cannot 'render and assert on the hierarchy' without a third-party library like ViewInspector.
Should I learn The Composable Architecture (TCA)?
Useful but not required. TCA (pointfree.co/collections/composable-architecture) shines on apps with complex side-effect graphs and team-scale state management. For a small-to-medium iOS app with @Observable + structured concurrency, you do not need TCA. At interview, knowing both — and being able to articulate when each pays off — is the senior+ signal.
How do I share SwiftUI views between iOS, macOS, and visionOS?
One Package or one shared target with #if os() blocks for platform-specific modifiers. NavigationSplitView is the cross-platform navigation primitive (iOS 16, macOS 13, visionOS 1). Avoid forking views per platform — fork only the modifiers (e.g., .navigationTitle behavior, .toolbar placement). The body section above shows the canonical pattern with platform-specific Scene types at the App level and modifier-level adaptation at the view level. WWDC23 'Make features for visionOS' (developer.apple.com/videos/play/wwdc2023/10110) covers the adaptation pattern at 12:00, and WWDC24 'Tailor your app for the Mac' (developer.apple.com/videos/play/wwdc2024/10146) at 8:00 covers the macOS-specific modifier set including .keyboardShortcut and the new .windowResizability values that don't apply on iOS.

Sources

  1. WWDC23 — Discover Observation in SwiftUI. The @Observable migration reference.
  2. WWDC22 — Demystify SwiftUI. View identity and lifecycle (37:00 onward).
  3. WWDC23 — Demystify SwiftUI performance. Instruments-based measurement.
  4. SE-0395 — Observability. The Observation framework specification.
  5. Apple Developer — Observation framework reference.
  6. Apple Developer — Layout protocol reference.
  7. Hacking with Swift — SwiftUI guides (Paul Hudson).

About the author. Blake Crosley founded ResumeGeni and writes about product design, hiring technology, and ATS optimization. More writing at blakecrosley.com.