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: CartViewModelat the top of body or as a parameter wrapper. - Combine publishers don't auto-bridge. If your view-model exposed a
$itemspublisher consumed by something downstream, @Observable does not generate one. UsewithObservationTrackingmanually 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: Equatableand 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
.taskon 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 needGeometryReaderinsideScrollViewwith 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
- WWDC23 — Discover Observation in SwiftUI. The @Observable migration reference.
- WWDC22 — Demystify SwiftUI. View identity and lifecycle (37:00 onward).
- WWDC23 — Demystify SwiftUI performance. Instruments-based measurement.
- SE-0395 — Observability. The Observation framework specification.
- Apple Developer — Observation framework reference.
- Apple Developer — Layout protocol reference.
- 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.