| name | swiftui-view-composition |
| description | Structure and refactor SwiftUI views for readability, reuse, and maintainability by extracting dedicated View structs, ViewModifiers, and layout helpers instead of bloated bodies or extension dumping grounds. Use when breaking down large views, improving screen clarity, creating reusable components, or reviewing SwiftUI architecture and view organization. |
SwiftUI View Composition
Structure SwiftUI screens so body reads like a high-level overview — not an implementation dump. The goal is clarity and reusability, not minimizing line count.
Skill split:
swiftui-view-composition (this skill) — how to structure and refactor views
native-swiftui — what Apple components and system styles to use
swiftui-project-structure — repo folders, packages, targets, MV vs Store layers
swiftui-expert-skill — state, performance, concurrency (avdlee/swiftui-agent-skill; install via ./scripts/install-all.sh)
Agent workflow
- Scan the view — identify logical clusters (header, row, empty state, toolbar, section).
- Ask the reuse question — "Does this UI have a clear purpose and potential to be reused?" (see Decision guide)
- Extract in order — dedicated
View → repeated styling as modifier → generic layout helper.
- Check native alternatives — before custom card/row styling, consult
native-swiftui (GroupBox, List, LabeledContent).
- Add previews — each extracted view gets
#Preview with its states.
- Leave
body declarative — screen view orchestrates; children render.
The problem: growing view bodies
Large body properties with nested stacks and inline styling are hard to scan, test, and evolve. Adding empty states, loading, and actions makes it worse.
First step when refactoring: identify subjects — self-contained UI clusters that could become their own unit.
Anti-pattern: extensions and computed properties
Moving body chunks into extension computed properties or @ViewBuilder functions only moves code around. It does not improve reusability or maintainability, and the file often grows longer.
extension ArticleListView {
var header: some View { Text("Articles").font(.largeTitle).bold() }
func articleRow(for article: Article) -> some View {
HStack { }
}
}
When computed properties are OK: tiny, single-use sections with no reuse potential and no performance concern — keep them private and simple.
Prefer separate struct views for anything with its own concern, multiple states, or reuse potential. Separate structs also let SwiftUI skip body re-evaluation when inputs are unchanged (see swiftui-expert-skill view-structure guidance).
Cross-file extensions
Splitting a type across files (e.g. NoteStore+Notes.swift for SwiftLint length limits) is fine for stores/models. That is not the same as dumping body into extension computed properties.
Access control: private and private(set) are file-private. Extensions in other files cannot read or assign those members — you get “setter is inaccessible” / “inaccessible due to private”. Widen anything shared across those files to internal (omit the keyword). Keep private only for helpers that stay in the defining file.
var snapshot: VaultSnapshot
var isLoaded = false
private(set) var isLoaded = false
After splits: xcodegen generate (if used), build, then ./scripts/check.sh and ./scripts/deadcode.sh (see ios-quality-gate).
Three extraction techniques
1. Extract dedicated SwiftUI views
Give each logical UI unit its own View struct with explicit inputs:
struct ArticleRow: View {
let article: Article
var body: some View {
LabeledContent(article.title) {
Image(systemName: "chevron.right")
.foregroundStyle(.tertiary)
}
}
}
struct ArticleListView: View {
let articles: [Article]
var body: some View {
List(articles) { article in
ArticleRow(article: article)
}
.navigationTitle("Articles")
}
}
Benefits:
- Screen
body reads as an outline
- Single responsibility per file
- States and previews live in one place
- Reusable on other screens
2. Create reusable view modifiers
When the same modifier chain appears on multiple views, extract it — but prefer system styles first (native-swiftui).
struct ElevatedSurfaceStyle: ViewModifier {
func body(content: Content) -> some View {
content
.padding()
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
}
}
extension View {
func elevatedSurface() -> some View {
modifier(ElevatedSurfaceStyle())
}
}
Before creating cardStyle(): check whether GroupBox, List + .listStyle, or .buttonStyle already solve it. Custom card modifiers are a last resort for branded design tokens, not default row styling.
Detection workflow: search the codebase for repeated modifier combinations → replace with one modifier → update all call sites.
3. Build generic view extensions
For repeated layout patterns (not full components), use @ViewBuilder extensions:
extension View {
func sectionHeader(_ title: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.title3)
.bold()
self
}
}
}
VStack {
ForEach(articles) { ArticleRow(article: $0) }
}
.sectionHeader("Articles")
Trade-off: less explicit than a SectionHeader view, but reduces indentation. Use when the wrapper is purely structural and unlikely to need its own state or previews.
Decision guide
Ask before extracting:
"Does this piece of UI have a clear purpose and potential to be reused?"
| Answer | Action |
|---|
| Yes — distinct concern, appears elsewhere, or needs isolated previews | New View struct (or ViewModifier for repeated styling) |
| Maybe — used once now but likely again | Extract View; move to shared folder when second use appears |
| No — one-off, trivial, tightly coupled to parent | Keep inline or use a small private computed property |
Extraction priority
- Rows and cells (
ArticleRow, SettingsToggleRow)
- Empty / loading / error states (often
ContentUnavailableView — see native-swiftui)
- Section headers and toolbars
- Repeated styling → modifier (after checking system alternatives)
- Layout shells → generic extension
Rule of three (abstraction guard)
Promote a view to a shared, generic primitive only after three concrete uses with the same behavior. Before that, keep it feature-local — screen-specific APIs are fine; do not invent a 12-parameter “configurable” view to force reuse.
Configuration objects (avoid long initializers)
When a view would take roughly four or more related parameters — especially mixed data, bindings, and action closures — wrap them in a dedicated configuration (or actions) struct and pass that instead of a flat parameter list.
struct ItemRow: View {
let title: String
let subtitle: String?
let isCompact: Bool
let onTitleChange: (String) -> Void
let onDelete: () -> Void
let onIndent: () -> Void
}
struct ItemRowConfiguration {
var title: String
var subtitle: String?
var actions: ItemRowActions
}
struct ItemRow: View {
let configuration: ItemRowConfiguration
}
Keep configuration types feature-local and one primary type per file. Prefer small, named bags (…Configuration, …Actions) over generic “options” dictionaries.
Display-mode sibling views
Do not branch compact/comfortable (or similar display modes) throughout a single body. Create separate views for each mode and toggle at the parent; share metrics/constants in a layout/chrome enum so mode switches stay visually stable.
var body: some View {
if isCompact { } else { }
}
var body: some View {
if isCompact {
CompactItemRowBody(configuration: configuration)
} else {
ComfortableItemRowBody(configuration: configuration)
}
}
Refactor walkthrough
Before — monolithic list with inline row styling:
struct ArticleListView: View {
let articles: [Article]
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Articles").font(.largeTitle).bold()
ForEach(articles, id: \.url) { article in
HStack {
VStack(alignment: .leading) {
Text(article.title).font(.headline)
Text(article.url.absoluteString)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
}
.padding()
.background(Color(.secondarySystemBackground))
.cornerRadius(8)
}
}
.padding()
}
}
After — composed, native-aligned:
struct ArticleListView: View {
let articles: [Article]
var body: some View {
List(articles) { article in
ArticleRow(article: article)
}
.navigationTitle("Articles")
}
}
struct ArticleRow: View {
let article: Article
var body: some View {
VStack(alignment: .leading) {
Text(article.title).font(.headline)
Text(article.url.absoluteString)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
#Preview("Row") {
ArticleRow(article: Article(title: "SwiftUI", url: URL(string: "https://example.com")!))
}
Changes: extracted row, List instead of manual card stacks, navigation title instead of inline header (or use .sectionHeader if staying in VStack).
More patterns: extraction-patterns.md.
File and folder conventions
- One primary type per file; name the file after the type (
ArticleRow.swift, ItemRowActions.swift).
- Co-locate by feature —
Articles/ArticleListView.swift, Articles/ArticleRow.swift — not global Views/ dumps. For repo-level layout (packages, targets, Navigation/, when to add a Store), see swiftui-project-structure.
- Within a feature, use semantic subfolders when the file count grows (e.g.
Editor/Row/, Editor/Chrome/, Editor/Layout/) so related types stay findable.
- Shared primitives — only after rule-of-three; e.g.
DesignSystem/ElevatedSurface.swift or a small Swift package.
// MARK: - for properties, body, helpers; protocol conformance in extensions.
Screen vs component views
| Type | Role | API shape |
|---|
| Screen / orchestrator | Wires navigation, state, and child views | Owns @State, fetches data, composes children |
| Component | Renders one UI unit | let inputs; callbacks for actions (onTap, onDelete) |
| Primitive | App-wide design atom | Minimal, stateless, generic |
Do not force screen-specific orchestrators into generic shared components — give them a name and a rich, local API.
Previews
Every extracted view gets previews for its meaningful states:
#Preview("Default") { ArticleRow(article: .sample) }
#Preview("Long title") { ArticleRow(article: .longTitleSample) }
#Preview("Empty list") { ArticleListView(articles: []) }
Use @Previewable for dynamic preview state when needed.
Checklist
Before finishing a refactor:
Related skills
native-swiftui — prefer GroupBox, List, system button styles over custom cards
swiftui-project-structure — feature folders, SPM packages, app/scene wiring, architecture layers
swiftui-expert-skill — property wrappers, diffing performance, list identity (avdlee/swiftui-agent-skill)
- Source inspiration: SwiftUI Architecture — Antoine van der Lee