Splitting Large SwiftUI Views in the Apple's way
Extract Subviews, Not Computed Properties (and Where @ViewBuilder Actually Fits)
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?
My reply was:
@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.
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.
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”.
Extract Subviews, Not Computed Properties
What I had before
My ContentView was already split into computed properties:
struct ContentView: View {
@Bindable var store: StoreOf<ContentFeature> // TCA thing :)
@Namespace private var mapScope
var body: some View {
configuredContent
}
private var configuredContent: some View {
lifecycleContent
.alert($store.scope(state: \.alert, action: \.alert))
.sheet(item: $store.scope(state: \.settingsSheet, action: \.settingsSheet)) { ... }
// many more modifiers...
}
private var lifecycleContent: some View {
mainContent
.onAppear { store.send(.onAppear) }
}
private var mainContent: some View {
ZStack(alignment: .bottom) {
MapContainerView(store: store, mapScope: mapScope)
// 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.
Same story in AdventureCompletedView. The completion card lived inline inside body:
var body: some View {
if store.walkSession.showAdventureCompleted,
let selectedRoute = store.routeGeneration.selectedRoute {
ZStack {
Color.black.opacity(0.5).ignoresSafeArea()
ScrollView {
VStack(spacing: 0) {
// trophy, title, stats HStack, achievements, buttons...
StatCardView(
icon: “medal.fill”,
value: “\(selectedRoute.medals.filter { $0.isCollected }.count)/\(selectedRoute.medals.count)”,
label: String(localized: “Medals Collected”),
iconColor: .yellow
)
// more cards...
}
}
}
}
}Any small animation state change (cardScale, cardOpacity, confetti) could pull the whole card tree back through evaluation 🔄.
What I changed
I replaced computed properties with private view structs, each with a focused job:
struct ContentView: View {
@Bindable var store: StoreOf<ContentFeature>
@Namespace private var mapScope
var body: some View {
ContentConfiguredView(store: store, mapScope: mapScope)
}
}
private struct ContentConfiguredView: View {
@Bindable var store: StoreOf<ContentFeature>
let mapScope: Namespace.ID
var body: some View {
ContentLifecycleView(store: store, mapScope: mapScope)
.alert($store.scope(state: \.alert, action: \.alert))
.sheet(item: $store.scope(state: \.settingsSheet, action: \.settingsSheet)) { settingsStore in
SettingsView(store: settingsStore, contentStore: store)
}
// sheets only here
}
}
private struct ContentLifecycleView: View {
@Bindable var store: StoreOf<ContentFeature>
let mapScope: Namespace.ID
var body: some View {
ContentMainView(store: store, mapScope: mapScope)
}
}
private struct ContentMainView: View {
@Bindable var store: StoreOf<ContentFeature>
let mapScope: Namespace.ID
var body: some View {
ZStack(alignment: .bottom) {
ContentMapLayer(store: store, mapScope: mapScope)
if store.routeGeneration.isGeneratingRoutes {
LoadingOverlay()
}
ContentForegroundStack(store: store)
ContentMedalToastOverlay(
medal: store.walkSession.lastCollectedMedal,
isShowing: store.walkSession.showMedalCollectionToast,
onDismiss: { store.send(.walkSession(.dismissMedalCollectionToast)) }
)
if store.walkSession.showAdventureCompleted {
AdventureCompletedView(store: store)
}
}
}
}Then I split the map and foreground UI too:
private struct ContentMapLayer: View {
@Bindable var store: StoreOf<ContentFeature>
let mapScope: Namespace.ID
var body: some View {
MapContainerView(store: store, mapScope: mapScope)
.overlay(alignment: .trailing) {
if !store.drawRoute.isDrawingRoute
&& !store.avoidance.isMarkingAvoidanceMode
&& !store.avoidance.isEditingAvoidedLocations {
MapControlsView(store: store, mapScope: mapScope)
}
}
.safeAreaInset(edge: .top) {
if !store.drawRoute.isDrawingRoute
&& !store.avoidance.isMarkingAvoidanceMode
&& !store.avoidance.isEditingAvoidedLocations {
AdventureStatsView(store: store)
}
}
}
}
private struct ContentForegroundStack: View {
@Bindable var store: StoreOf<ContentFeature>
var body: some View {
VStack(alignment: .leading, spacing: 0) {
if store.avoidance.isMarkingAvoidanceMode {
ContentAvoidanceBanner(
showsTapToMarkBanner: store.avoidance.temporarySelectedSegment == nil
&& store.avoidance.avoidedSegments.isEmpty
)
}
Spacer()
if store.drawRoute.isDrawingRoute {
DrawRouteOverlayView(store: store)
} else if !store.avoidance.isMarkingAvoidanceMode
&& !store.avoidance.isEditingAvoidedLocations {
BottomControls(
routes: store.routeGeneration.routes,
isGeneratingRoutes: store.routeGeneration.isGeneratingRoutes,
store: store,
selectedRoute: store.routeGeneration.selectedRoute
)
}
}
}
}The parent ContentView body is now one line. Each layer owns its own invalidation boundary.
I extracted the card into AdventureCompletionCard and passed only what it needs:
AdventureCompletionCard(
selectedRoute: selectedRoute,
distanceWalked: store.walkSession.distanceWalked,
distanceUnit: store.distanceUnit,
newlyUnlockedMilestones: store.walkSession.newlyUnlockedMilestones,
onWalkAgain: { store.send(.walkSession(.walkAgainTapped)) }
)And the card struct:
private struct AdventureCompletionCard: View {
let selectedRoute: Route
let distanceWalked: Double
let distanceUnit: DistanceUnit
let newlyUnlockedMilestones: [Milestone]
let onWalkAgain: () -> Void
var body: some View {
VStack(spacing: 0) {
// trophy, title, stats, achievements, buttons
}
.compatibleGlassEffect(cornerRadius: 24)
}
...
}I also split button styling into AdventureCompletedActionButtons, AdventureCompletedActionButton and AdventureCompletedButtonBackground.
Now animation state (cardScale, showConfetti) lives in AdventureCompletedView, while the heavy card content lives in a separate type with stable inputs.
What this gives you
Better diffing: SwiftUI compares struct inputs, not one giant parent body.
Less wasted work on unrelated state changes.
Easier previews: you can preview AdventureCompletionCard with sample data.
Clearer ownership: each file section has one responsibility. Solid? :D
@ViewBuilder (and What It Does NOT Do)
This is where the X thread gets interesting. @ViewBuilder is useful but it solves a different problem than subview extraction.
What @ViewBuilder is good at
@ViewBuilder helps when you need conditional view structure inside one body:
@ViewBuilder
private var conditionalView: some View {
if isExpanded {
VStack {
Text(”Expanded View”)
Image(systemName: “star”)
}
} else {
Text(”Collapsed View”)
}
}It gives you clean syntax for if/switch branches and structural identity for those branches.
Apple’s guidance also says: use it for small, simple sections that do not need their own invalidation boundary.
What @ViewBuilder does not do
It does not create a new invalidation boundary. If you put your whole complex section in a @ViewBuilder function inside the parent struct that function still runs whenever the parent body runs 🔄.
So this is still one boundary. Tapping the button re-evaluates complexSection() every time.
struct ParentView: View {
@State private var count = 0
var body: some View {
VStack {
Button(”Tap: \(count)”) { count += 1 }
complexSection()
}
}
@ViewBuilder
func complexSection() -> some View {
ForEach(0..<100) { i in
Text(”Item \(i)”)
}
}
}The objc.io lesson: conditional branches can break identity
Chris Eidhof wrote a great post on this: Why Conditional View Modifiers are a Bad Idea.
The key point: when you branch with if/else, SwiftUI often sees different view types in each branch (_ConditionalContent<...>). That can:
break smooth animations (fade transition instead of interpolation)
reset @State / @StateObject when the branch flips
Example from that post:
Rectangle()
.applyIf(condition: myState, transform: { $0.frame(width: 100) })vs
Rectangle()
.frame(width: myState ? 100 : nil)The second one keeps the same view identity and animates correctly.
Same idea applies to @ViewBuilder conditionals inside one parent: they are fine for structure but they are not a free performance win and they can still hurt identity if you use them like a conditional modifier wrapper.
Where I still use small extracted structs instead of @ViewBuilder helpers
In Walk Mate, I extracted ContentAvoidanceBanner instead of keeping nested if blocks inline:
private struct ContentAvoidanceBanner: View {
let showsTapToMarkBanner: Bool
var body: some View {
VStack(spacing: 0) {
if showsTapToMarkBanner {
TapToMarkBanner()
.padding(.horizontal, AppConstants.UI.largeSpacing)
.padding(.trailing, AppConstants.UI.bannerTrailingPadding)
.padding(.top, 80)
}
}
}
}This is a small view but it has a clear input (showsTapToMarkBanner) and its own boundary. The parent only passes a Bool, not the whole banner layout logic.
That is the pattern I follow now:
@ViewBuilder for tiny local branching when extraction would be noisy
separate View structs for anything stateful expensive or reused
Practical Checklist (What I Use in Code Reviews Now)
If a screen has private var section:
some View and that section depends on changing state, extract a struct.Pass narrow inputs (Route, Double, [Milestone], Bool) instead of the whole store when possible.
Do not use computed properties as a fake performance split 😂
Do not use custom .applyIf style helpers for modifiers.
Prefer modifier(value ? a : b) over if branches for the same view.
Keep @ViewBuilder for small structural branching not as a replacement for subviews.
Splitting a large SwiftUI view is not just a style preference anymore. Apple is calling it out directly and in a map-heavy app like Walk Mate it makes a real difference.
@ViewBuilder is still useful. It gives you clean conditional structure. But if your goal is fewer body re-evaluations, separate View types with narrow inputs are the win.
If you are refactoring an old screen, start with the noisiest part first: map overlays, stats sections, completion cards, sheet containers. That is where I saw the biggest readability and performance payoff.
Sources
Apple view structure guidance (Xcode 27 coding skills)
swiftui-expert-skill/references/view-structure.md
Why Conditional View Modifiers are a Bad Idea by Chris Eidhof (objc.io)
https://www.avanderlee.com/ai-development/using-xcode-27s-agent-skills-in-claude-codex-and-cursor/




Actually one good use for computed @ViewBuilder var is you can have a single-sided if without a VStack like you needed in `struct ContentAvoidanceBanner`