Splitting Large SwiftUl Views in the Apple's Way

emredegirmenci1 pts0 comments

Splitting Large SwiftUI Views in the Apple's way

SubscribeSign in

Splitting Large SwiftUI Views in the Apple's way<br>Extract Subviews, Not Computed Properties (and Where @ViewBuilder Actually Fits)

Emre Degirmenci<br>Jul 07, 2026

14

Share

Apple recently added this to their Xcode 27 coding skills guidance, and it started a good conversation on X. Vincent shared it and Shannon asked a fair question: if you split a large view, does @ViewBuilder help with identity?

Vincent Pradeilles@v_pradeilles

Don't use computed properties to split a large View 🙅🏽‍♀️🙅🏻‍♂️

This an official bad practice written by Apple in Xcode 27 coding skills

11:29 AM · Jun 30, 2026 · 9.94K Views

9 Replies · 22 Reposts · 210 Likes

My reply was:<br>@ViewBuilder gives you clean structural identity for if/switch branches but it doesn’t create a new invalidation boundary. Same struct, same boundary. Separate View types are still the win for performance imho :)

I applied this in my app Walk Mate while refactoring a few heavy screens. This post is about what I changed, why it matters and where @ViewBuilder still has a place.<br>The Core Idea

When state changes, SwiftUI re-runs the body of the smallest enclosing view type that depends on that state. Everything inside that body gets evaluated again: conditionals, modifier chains, string interpolation, helper functions, computed properties. Same invalidation boundary. Same cost.<br>A separate View struct with narrow inputs gets its own boundary . SwiftUI can skip its body when only its inputs are unchanged. That’s the difference “skip for readability” and “split for performance”.<br>Extract Subviews, Not Computed Properties

What I had before

My ContentView was already split into computed properties:<br>struct ContentView: View {<br>@Bindable var store: StoreOf // TCA thing :)<br>@Namespace private var mapScope

var body: some View {<br>configuredContent

private var configuredContent: some View {<br>lifecycleContent<br>.alert($store.scope(state: \.alert, action: \.alert))<br>.sheet(item: $store.scope(state: \.settingsSheet, action: \.settingsSheet)) { ... }<br>// many more modifiers...

private var lifecycleContent: some View {<br>mainContent<br>.onAppear { store.send(.onAppear) }

private var mainContent: some View {<br>ZStack(alignment: .bottom) {<br>MapContainerView(store: store, mapScope: mapScope)<br>// map overlays, loading, bottom controls, medal toast, adventure completed...

It reads better than one giant body but every store update still re-evaluates all of those computed properties together.<br>Same story in AdventureCompletedView . The completion card lived inline inside body:<br>var body: some View {<br>if store.walkSession.showAdventureCompleted,<br>let selectedRoute = store.routeGeneration.selectedRoute {<br>ZStack {<br>Color.black.opacity(0.5).ignoresSafeArea()

ScrollView {<br>VStack(spacing: 0) {<br>// trophy, title, stats HStack, achievements, buttons...<br>StatCardView(<br>icon: “medal.fill”,<br>value: “\(selectedRoute.medals.filter { $0.isCollected }.count)/\(selectedRoute.medals.count)”,<br>label: String(localized: “Medals Collected”),<br>iconColor: .yellow<br>// more cards...

Any small animation state change (cardScale , cardOpacity , confetti ) could pull the whole card tree back through evaluation 🔄.<br>What I changed

I replaced computed properties with private view structs, each with a focused job:<br>struct ContentView: View {<br>@Bindable var store: StoreOf<br>@Namespace private var mapScope

var body: some View {<br>ContentConfiguredView(store: store, mapScope: mapScope)

private struct ContentConfiguredView: View {<br>@Bindable var store: StoreOf<br>let mapScope: Namespace.ID

var body: some View {<br>ContentLifecycleView(store: store, mapScope: mapScope)<br>.alert($store.scope(state: \.alert, action: \.alert))<br>.sheet(item: $store.scope(state: \.settingsSheet, action: \.settingsSheet)) { settingsStore in<br>SettingsView(store: settingsStore, contentStore: store)<br>// sheets only here

private struct ContentLifecycleView: View {<br>@Bindable var store: StoreOf<br>let mapScope: Namespace.ID

var body: some View {<br>ContentMainView(store: store, mapScope: mapScope)

private struct ContentMainView: View {<br>@Bindable var store: StoreOf<br>let mapScope: Namespace.ID

var body: some View {<br>ZStack(alignment: .bottom) {<br>ContentMapLayer(store: store, mapScope: mapScope)

if store.routeGeneration.isGeneratingRoutes {<br>LoadingOverlay()

ContentForegroundStack(store: store)

ContentMedalToastOverlay(<br>medal: store.walkSession.lastCollectedMedal,<br>isShowing: store.walkSession.showMedalCollectionToast,<br>onDismiss: { store.send(.walkSession(.dismissMedalCollectionToast)) }

if store.walkSession.showAdventureCompleted {<br>AdventureCompletedView(store: store)

Then I split the map and foreground UI too:<br>private struct ContentMapLayer: View {<br>@Bindable var store: StoreOf<br>let mapScope: Namespace.ID

var body: some View {<br>MapContainerView(store: store, mapScope: mapScope)<br>.overlay(alignment: .trailing) {<br>if !store.drawRoute.isDrawingRoute<br>&& !store.avoidance.isMarkingAvoidanceMode<br>&&...

store view mapscope body private struct

Related Articles