| name | swiftui-patterns-developer |
| description | SwiftUI view structure, composition, and best practices. Use when refactoring SwiftUI views, organizing view files, or extracting subviews. |
SwiftUI Patterns Developer (Smart Router)
Purpose
Apply consistent structure and patterns to SwiftUI views, with focus on ordering, subview extraction, and proper composition.
When Auto-Activated
- Refactoring SwiftUI view structure
- Organizing view file layout
- Splitting large views into subviews
- Keywords: view structure, view ordering, split view, extract subview, large view, refactor view
Core Guidelines
0) Three Qualities of SwiftUI Views (WWDC24)
Understanding these fundamentals helps you write better SwiftUI code:
1. Declarative - Describe what you want, not how to create it:
List(pets) { pet in
HStack {
Text(pet.name)
Spacer()
Text(pet.species)
}
}
2. Compositional - Build complex UIs from simple building blocks:
HStack {
Image(...)
VStack {
Text(...)
Text(...)
}
Spacer()
}
3. State-Driven - UI automatically updates when state changes:
@State private var count = 0
var body: some View {
Button("Count: \(count)") {
count += 1
}
}
Key insight: Views are VALUE TYPES (structs), not long-lived objects. They are descriptions of current UI state, not objects that receive commands over time. SwiftUI maintains the actual UI behind the scenes.
1) View Ordering (top -> bottom)
Follow Anytype's property organization from IOS_DEVELOPMENT_GUIDE.md:
struct ExampleView: View {
@State private var model: ExampleViewModel
@Injected(\.settingsService) private var settingsService
@Environment(\.dismiss) private var dismiss
let title: String
private var cancellables = Set<AnyCancellable>()
private var hasItems: Bool { !model.items.isEmpty }
init(title: String) {
self.title = title
_model = State(wrappedValue: ExampleViewModel(title: title))
}
var body: some View {
content
.task { await model.startSubscriptions() }
}
private var content: some View { ... }
() { }
}
2) ViewModel Pattern (Anytype Standard)
Anytype uses MVVM with ViewModels. Always use ViewModels for business logic:
struct ChatView: View {
@State private var model: ChatViewModel
init(spaceId: String, chatId: String) {
_model = State(wrappedValue: ChatViewModel(spaceId: spaceId, chatId: chatId))
}
var body: some View {
content
.task { await model.startSubscriptions() }
}
private var content: some View {
List(model.messages) { message in
MessageRow(message: message)
}
}
}
@MainActor
@Observable
final class ChatViewModel {
var messages: [Message] = []
@ObservationIgnored
@Injected(\.chatService) private var chatService
func startSubscriptions() async {
}
func sendMessage(_ text: String) {
}
}
Key points:
- Use
@State private var model: ViewModel in views
- Initialize ViewModel in view's
init with _model = State(wrappedValue:)
- Keep ViewModel init cheap, heavy work in
.task
- Use
@Observable macro (not ObservableObject)
- Mark ViewModels with
@MainActor
3) How Observation Works (WWDC23)
Understanding why @Observable works helps you use it correctly.
Property Access Tracking:
- SwiftUI tracks which properties you access during
body evaluation
- Only those accessed properties trigger view invalidation when changed
- Properties NOT read in
body don't cause re-renders (unlike @Published)
@Observable
final class SettingsViewModel {
var userName: String = ""
var isLoading: Bool = false
var analyticsData: Data = Data()
}
struct SettingsView: View {
@State private var model: SettingsViewModel
var body: some View {
VStack {
Text(model.userName)
if model.isLoading {
ProgressView()
}
}
}
}
Per-Instance Tracking:
- Arrays of
@Observable objects work efficiently
- Only the specific instance that changed triggers updates
- No need for
identifiable tricks with observation
@Observable
final class MessageViewModel {
var text: String
var isRead: Bool = false
}
List(model.messages) { message in
MessageRow(message: message)
}
Computed Properties Just Work:
- Computed properties composed from stored properties are automatically tracked
- SwiftUI traces through to the underlying stored properties
@Observable
final class CartViewModel {
var items: [Item] = []
var discount: Double = 0
var totalPrice: Double {
items.reduce(0) { $0 + $1.price } - discount
}
}
Performance Benefit:
With @Observable, views only update when properties they actually read change. This is more efficient than ObservableObject where ANY @Published change triggers objectWillChange for ALL subscribers.
4) Property Wrapper Decision Tree
When to use which wrapper with @Observable:
| Scenario | Wrapper | Why |
|---|
| View owns model lifecycle | @State | View creates and manages the model |
| Model shared app-wide | @Environment | Injected at app root, read anywhere |
| Just need bindings ($syntax) | @Bindable | Pass to TextField, Toggle, etc. |
| Just reading the model | Nothing | Direct property access triggers tracking |
struct ChatView: View {
@State private var model: ChatViewModel
init(chatId: String) {
_model = State(wrappedValue: ChatViewModel(chatId: chatId))
}
}
struct MessageEditor: View {
@Bindable var draft: DraftMessage
var body: some View {
TextField("Message", text: $draft.text)
}
}
struct MessageRow: View {
let message: MessageViewModel
var body: some View {
Text(message.text)
Image(systemName: message.isRead ? "checkmark.circle.fill" : "circle")
}
}
Migration from ObservableObject:
| Old | New |
|---|
@StateObject | @State |
@ObservedObject | @Bindable or nothing |
@EnvironmentObject | @Environment |
5) Migration from ObservableObject (WWDC23)
Step-by-step conversion from legacy ObservableObject:
Before (ObservableObject):
class SettingsViewModel: ObservableObject {
@Published var userName: String = ""
@Published var notifications: Bool = true
private var cancellables = Set<AnyCancellable>()
}
struct SettingsView: View {
@StateObject private var model = SettingsViewModel()
var body: some View {
TextField("Name", text: $model.userName)
Toggle("Notifications", isOn: $model.notifications)
}
}
After (@Observable):
@Observable
final class SettingsViewModel {
var userName: String = ""
var notifications: Bool = true
@ObservationIgnored
private var cancellables = Set<AnyCancellable>()
}
struct SettingsView: View {
@State private var model = SettingsViewModel()
var body: some View {
@Bindable var model = model
TextField("Name", text: $model.userName)
Toggle("Notifications", isOn: $model.notifications)
}
}
Migration Steps:
- Remove
ObservableObject conformance, add @Observable macro
- Remove
@Published from all properties (observation is automatic)
- Add
@ObservationIgnored to properties that shouldn't trigger updates
- Change
@StateObject → @State in views
- For
$ binding syntax, use @Bindable var model = model in body
- Replace
@EnvironmentObject with @Environment
Note: Anytype already uses @Observable - this section is for understanding legacy code during migrations.
6) Dependency Injection (Factory)
Anytype uses Factory DI, not SwiftUI Environment for services:
@Injected(\.chatService) private var chatService
@Environment(ChatService.self) private var chatService
Environment is for:
- System values:
@Environment(\.dismiss), @Environment(\.colorScheme)
- SwiftUI-provided context
@Injected is for:
- App services:
@Injected(\.chatService)
- Repositories:
@Injected(\.userRepository)
- Any business logic dependencies
7) View Modifiers and Order (WWDC24)
View modifiers create a hierarchical structure. Order matters - modifiers are applied sequentially:
Image("whiskers")
.clipShape(Circle())
.shadow(radius: 4)
.overlay(
Circle().stroke(.green, lineWidth: 2)
)
The hierarchy and order of effect is defined by the exact order of modifiers. Chaining modifiers makes it clear how results are produced and how to customize them.
8) Adaptive Views (WWDC24)
SwiftUI views describe purpose, not exact visual construction. This enables adaptation:
Buttons - Same purpose (labeled action), different contexts:
Button("Edit", action: handleEdit)
.swipeActions {
Button("Delete", role: .destructive) { delete() }
Button("Archive") { archive() }
}
Toggles - Switch, checkbox, or toggle button depending on context:
Toggle("Notifications", isOn: $notificationsEnabled)
Searchable - Describes capability, SwiftUI handles idiomatic presentation:
List(filteredItems) { ... }
.searchable(text: $searchText)
.searchSuggestions {
ForEach(suggestions) { Text($0) }
}
9) Split Large Bodies
If body grows beyond a screen, split into smaller subviews:
var body: some View {
List {
header
filters
results
}
}
private var header: some View { ... }
private var filters: some View { ... }
private var results: some View { ... }
struct HeaderSection: View {
let title: String
let subtitle: String?
var body: some View {
VStack(alignment: .leading, spacing: 4) {
AnytypeText(title, style: .heading)
if let subtitle {
AnytypeText(subtitle, style: .bodyRegular)
}
}
}
}
8) ViewState Enum Pattern
For views with loading/error/loaded states:
enum ViewState {
case loading
case error(String)
case loaded
}
@MainActor
@Observable
final class FeedViewModel {
var viewState: ViewState = .loading
var posts: [Post] = []
func loadPosts() async {
do {
posts = try await feedService.getFeed()
viewState = .loaded
} catch {
viewState = .error(error.localizedDescription)
}
}
}
struct FeedView: View {
@State private var model: FeedViewModel
var body: some View {
content
.task { await model.loadPosts() }
}
@ViewBuilder
private var content: some View {
switch model.viewState {
case .loading:
ProgressView()
case .error(let message):
ErrorView(message: message, retry: { { model.loadPosts() } })
.loaded:
(model.posts) { post
(post: post)
}
}
}
}
11) State, Binding, and Source of Truth (WWDC24)
@State creates internal source of data for a view:
struct RatingView: View {
@State private var rating = 0
var body: some View {
HStack {
Text("\(rating)")
Button("+") { rating += 1 }
Button("-") { rating -= 1 }
}
}
}
@Binding creates two-way reference to state owned elsewhere:
struct RatingContainerView: View {
@State private var rating = 0
var body: some View {
VStack {
Gauge(value: Double(rating), in: 0...10) {}
RatingEditor(rating: $rating)
}
}
}
struct RatingEditor: View {
@Binding var rating: Int
var body: some View {
Button("+") { rating += 1 }
}
}
Key principle: One source of truth. When multiple views need the same data, lift state up to common ancestor and pass bindings down.
12) Animation with State Changes (WWDC24)
Wrap state changes with withAnimation to animate resulting view updates:
Button("Rate") {
withAnimation {
rating += 1
}
}
Customize transitions for specific views:
Text("\(rating)")
.contentTransition(.numericText())
Animations in SwiftUI build on the same data-driven updates - when state changes, views update, and withAnimation makes those updates animate.
13) Task and onChange Usage
.task {
await model.startSubscriptions()
}
.task(id: searchText) {
guard !searchText.isEmpty else { return }
await model.search(query: searchText)
}
.onChange(of: selectedTab) { oldValue, newValue in
}
14) Large View File Organization
When file exceeds ~300 lines:
struct LargeView: View {
}
private extension LargeView {
var header: some View { ... }
var content: some View { ... }
}
private extension LargeView {
func loadData() async { ... }
func handleTap() { ... }
}
15) UIKit/AppKit Interoperability (WWDC24)
SwiftUI provides seamless interop with UIKit and AppKit - no expectation that an app needs to be entirely SwiftUI.
Embed UIKit in SwiftUI - Use UIViewRepresentable:
struct MapView: UIViewRepresentable {
func makeUIView(context: Context) -> MKMapView {
MKMapView()
}
func updateUIView(_ uiView: MKMapView, context: Context) {
}
}
var body: some View {
VStack {
MapView()
Button("Center") { ... }
}
}
Embed SwiftUI in UIKit - Use UIHostingController:
let swiftUIView = ProfileView(user: user)
let hostingController = UIHostingController(rootView: swiftUIView)
addChild(hostingController)
view.addSubview(hostingController.view)
Incremental Adoption Philosophy: Apple's own apps use these tools to adopt SwiftUI incrementally - whether bringing SwiftUI into existing apps or incorporating UIKit views into new SwiftUI apps. All are valid approaches.
Common Mistakes
Using @Environment for Services
@Environment(FeedService.self) private var feedService
@Injected(\.feedService) private var feedService
Missing ViewModel for Complex Views
struct FeedView: View {
@State private var posts: [Post] = []
private func loadPosts() async {
}
}
struct FeedView: View {
@State private var model: FeedViewModel
}
Avoid Group with Conditionals + Lifecycle Modifiers
var body: some View {
Group {
if model.isLoading {
ProgressView()
} else {
content
}
}
.onAppear { model.onAppear() }
}
var body: some View {
loadingContent
.onAppear { model.onAppear() }
}
@ViewBuilder
private var loadingContent: some View {
if model.isLoading {
ProgressView()
} else {
content
}
}
Related Skills
- ios-dev-guidelines -> Full MVVM/Coordinator patterns, code style
- swiftui-performance-developer -> Performance optimization
- design-system-developer -> Icons, typography, colors
Navigation: This skill provides SwiftUI structure patterns. For full architecture guidance, see IOS_DEVELOPMENT_GUIDE.md.
Attribution: View structure patterns adapted from Dimillian/Skills, aligned with Anytype MVVM architecture. WWDC24 insights from "SwiftUI Essentials" session.