swiftui-patterns
SKILL.md
SwiftUI Architecture Patterns
Comprehensive guide to modern SwiftUI architecture, @Observable macro, state management, view composition, and the ongoing MV vs MVVM debate for iOS 26 development.
Prerequisites
- iOS 17+ for @Observable (iOS 26 recommended)
- Xcode 26+
- SwiftUI framework
The Architecture Debate: MV vs MVVM
The Modern Reality
With @Observable (iOS 17+), SwiftUI has evolved. The traditional MVVM pattern is not always necessary.
When to Use MV (Model-View)
// Simple screens with straightforward logic
// Model is directly observable
@Observable
class Note {
var title: String
var content: String
var lastModified: Date
init(title: String = "", content: String = "") {
self.title = title
self.content = content
self.lastModified = Date()
}
}
struct NoteEditorView: View {
@Bindable var note: Note // Direct model binding
var body: some View {
VStack {
TextField("Title", text: $note.title)
TextEditor(text: $note.content)
}
.onChange(of: note.content) {
note.lastModified = Date()
}
}
}
Use MV when:
- Simple data display and editing
- Logic is minimal or can live in the model
- No complex async operations
- Rapid prototyping
When to Use MVVM
// Complex screens needing presentation logic, async operations,
// or coordination between multiple data sources
@Observable
class NoteListViewModel {
var notes: [Note] = []
var searchQuery = ""
var isLoading = false
var error: Error?
var filteredNotes: [Note] {
guard !searchQuery.isEmpty else { return notes }
return notes.filter {
$0.title.localizedCaseInsensitiveContains(searchQuery)
}
}
@MainActor
func loadNotes() async {
isLoading = true
defer { isLoading = false }
do {
notes = try await noteService.fetchAll()
} catch {
self.error = error
}
}
func deleteNote(_ note: Note) async {
await noteService.delete(note)
notes.removeAll { $0.id == note.id }
}
}
struct NoteListView: View {
@State private var viewModel = NoteListViewModel()
var body: some View {
List(viewModel.filteredNotes) { note in
NoteRow(note: note)
}
.searchable(text: $viewModel.searchQuery)
.task {
await viewModel.loadNotes()
}
.overlay {
if viewModel.isLoading {
ProgressView()
}
}
}
}
Use MVVM when:
- Complex business logic
- Multiple data sources to coordinate
- Async operations (network, database)
- Need testability
- Team prefers clear separation
@Observable vs @ObservableObject
@Observable (iOS 17+ - Recommended)
import Observation
@Observable
class UserSettings {
var username = ""
var theme: Theme = .system
var notificationsEnabled = true
// Computed properties are tracked
var displayName: String {
username.isEmpty ? "Guest" : username
}
}
struct SettingsView: View {
var settings: UserSettings // No property wrapper needed
var body: some View {
Form {
TextField("Username", text: Bindable(settings).username)
// Or with @Bindable
}
}
}
struct SettingsViewWithBindable: View {
@Bindable var settings: UserSettings
var body: some View {
Form {
TextField("Username", text: $settings.username)
Toggle("Notifications", isOn: $settings.notificationsEnabled)
}
}
}
Benefits:
- Per-property tracking (not whole object)
- No
@Publishedneeded - Cleaner syntax
- Better performance
@ObservableObject (Legacy)
import Combine
class LegacySettings: ObservableObject {
@Published var username = ""
@Published var theme: Theme = .system
@Published var notificationsEnabled = true
var displayName: String {
username.isEmpty ? "Guest" : username
}
}
struct LegacySettingsView: View {
@ObservedObject var settings: LegacySettings
// Or @StateObject if this view owns the object
var body: some View {
Form {
TextField("Username", text: $settings.username)
}
}
}
When to use:
- Supporting iOS 16 or earlier
- Existing codebase migration
- Combine integration needed
Property Wrappers Deep Dive
@State
Local, view-owned mutable state:
struct CounterView: View {
@State private var count = 0
@State private var history: [Int] = []
var body: some View {
VStack {
Text("Count: \(count)")
Button("+1") {
count += 1
history.append(count)
}
}
}
}
Rules:
- Always
private - View owns the source of truth
- Value types preferred
@Binding
Two-way connection to parent's state:
struct ParentView: View {
@State private var isOn = false
var body: some View {
ToggleView(isOn: $isOn)
}
}
struct ToggleView: View {
@Binding var isOn: Bool
var body: some View {
Toggle("Switch", isOn: $isOn)
}
}
// Creating bindings manually
struct ManualBindingExample: View {
@State private var value = 0
var body: some View {
ChildView(value: Binding(
get: { value },
set: { newValue in
value = min(100, max(0, newValue)) // Clamp
}
))
}
}
@Bindable
Creates bindings for @Observable:
@Observable
class Document {
var title = ""
var content = ""
}
struct DocumentEditor: View {
@Bindable var document: Document
var body: some View {
VStack {
TextField("Title", text: $document.title)
TextEditor(text: $document.content)
}
}
}
@Environment
Access shared values from view hierarchy:
struct ThemedView: View {
@Environment(\.colorScheme) var colorScheme
@Environment(\.dismiss) var dismiss
@Environment(\.horizontalSizeClass) var sizeClass
@Environment(\.dynamicTypeSize) var dynamicType
var body: some View {
VStack {
if colorScheme == .dark {
Text("Dark Mode")
}
Button("Close") {
dismiss()
}
}
}
}
// Custom environment values
struct ThemeKey: EnvironmentKey {
static let defaultValue = AppTheme.default
}
extension EnvironmentValues {
var appTheme: AppTheme {
get { self[ThemeKey.self] }
set { self[ThemeKey.self] = newValue }
}
}
// Usage
ContentView()
.environment(\.appTheme, customTheme)
@Environment with @Observable
@Observable
class AppState {
var user: User?
var isAuthenticated: Bool { user != nil }
}
struct RootView: View {
@State private var appState = AppState()
var body: some View {
ContentView()
.environment(appState)
}
}
struct ContentView: View {
@Environment(AppState.self) var appState
var body: some View {
if appState.isAuthenticated {
MainView()
} else {
LoginView()
}
}
}
View Composition Patterns
Extract Subviews
// Before: Monolithic view
struct MessyProfileView: View {
var user: User
var body: some View {
VStack {
// 50 lines of header code
// 30 lines of stats code
// 40 lines of activity code
}
}
}
// After: Composed views
struct ProfileView: View {
var user: User
var body: some View {
ScrollView {
VStack(spacing: 20) {
ProfileHeader(user: user)
ProfileStats(user: user)
ProfileActivity(user: user)
}
}
}
}
struct ProfileHeader: View {
var user: User
var body: some View {
VStack {
AsyncImage(url: user.avatarURL)
Text(user.name)
}
}
}
ViewBuilder Methods
struct ContentView: View {
var items: [Item]
var isEditing: Bool
var body: some View {
VStack {
header
itemsList
if isEditing {
editingControls
}
}
}
private var header: some View {
Text("Items")
.font(.largeTitle)
}
@ViewBuilder
private var itemsList: some View {
if items.isEmpty {
ContentUnavailableView("No Items", systemImage: "tray")
} else {
List(items) { item in
ItemRow(item: item)
}
}
}
@ViewBuilder
private var editingControls: some View {
HStack {
Button("Select All") { }
Button("Delete", role: .destructive) { }
}
}
}
Generic Views
struct LoadingView<Content: View, T>: View {
let state: LoadingState<T>
let content: (T) -> Content
var body: some View {
switch state {
case .idle:
Color.clear
case .loading:
ProgressView()
case .loaded(let data):
content(data)
case .error(let error):
ErrorView(error: error)
}
}
}
enum LoadingState<T> {
case idle
case loading
case loaded(T)
case error(Error)
}
// Usage
struct UserProfileView: View {
@State private var state: LoadingState<User> = .idle
var body: some View {
LoadingView(state: state) { user in
ProfileContent(user: user)
}
.task {
state = .loading
do {
let user = try await fetchUser()
state = .loaded(user)
} catch {
state = .error(error)
}
}
}
}
State Management Patterns
Single Source of Truth
@Observable
class AppState {
var user: User?
var settings: Settings
var notifications: [Notification]
static let shared = AppState()
private init() {
self.settings = Settings()
self.notifications = []
}
}
// Inject at root
@main
struct MyApp: App {
@State private var appState = AppState.shared
var body: some Scene {
WindowGroup {
RootView()
.environment(appState)
}
}
}
Feature-Based State
// Each feature has its own state
@Observable
class NotesFeature {
var notes: [Note] = []
var selectedNote: Note?
var searchQuery = ""
func loadNotes() async { }
func createNote() -> Note { }
func deleteNote(_ note: Note) { }
}
@Observable
class SettingsFeature {
var appearance: Appearance = .system
var notifications: NotificationSettings = .default
func save() async { }
func reset() { }
}
// Compose features
@Observable
class AppFeatures {
let notes = NotesFeature()
let settings = SettingsFeature()
}
Action-Based Updates
@Observable
class Store {
private(set) var state: AppState
init(initialState: AppState = .init()) {
self.state = initialState
}
func dispatch(_ action: Action) {
state = reduce(state, action)
}
private func reduce(_ state: AppState, _ action: Action) -> AppState {
var newState = state
switch action {
case .addItem(let item):
newState.items.append(item)
case .removeItem(let id):
newState.items.removeAll { $0.id == id }
case .updateItem(let item):
if let index = newState.items.firstIndex(where: { $0.id == item.id }) {
newState.items[index] = item
}
}
return newState
}
}
enum Action {
case addItem(Item)
case removeItem(UUID)
case updateItem(Item)
}
Dependency Injection
Environment-Based DI
// Protocol for abstraction
protocol DataServiceProtocol {
func fetchItems() async throws -> [Item]
}
// Production implementation
class DataService: DataServiceProtocol {
func fetchItems() async throws -> [Item] {
// Real network call
}
}
// Mock for testing/previews
class MockDataService: DataServiceProtocol {
func fetchItems() async throws -> [Item] {
[Item(name: "Mock 1"), Item(name: "Mock 2")]
}
}
// Environment key
struct DataServiceKey: EnvironmentKey {
static let defaultValue: DataServiceProtocol = DataService()
}
extension EnvironmentValues {
var dataService: DataServiceProtocol {
get { self[DataServiceKey.self] }
set { self[DataServiceKey.self] = newValue }
}
}
// Usage in views
struct ItemListView: View {
@Environment(\.dataService) var dataService
@State private var items: [Item] = []
var body: some View {
List(items) { item in
Text(item.name)
}
.task {
items = try? await dataService.fetchItems() ?? []
}
}
}
// Preview with mock
#Preview {
ItemListView()
.environment(\.dataService, MockDataService())
}
Constructor Injection
@Observable
class ItemViewModel {
private let service: DataServiceProtocol
var items: [Item] = []
init(service: DataServiceProtocol = DataService()) {
self.service = service
}
func load() async {
items = (try? await service.fetchItems()) ?? []
}
}
Navigation Patterns
Coordinator Pattern
@Observable
class AppCoordinator {
var path = NavigationPath()
func showDetail(for item: Item) {
path.append(item)
}
func showSettings() {
path.append(Route.settings)
}
func pop() {
path.removeLast()
}
func popToRoot() {
path.removeLast(path.count)
}
}
enum Route: Hashable {
case settings
case profile(userId: String)
}
struct CoordinatedApp: View {
@State private var coordinator = AppCoordinator()
var body: some View {
NavigationStack(path: $coordinator.path) {
HomeView()
.navigationDestination(for: Item.self) { item in
ItemDetailView(item: item)
}
.navigationDestination(for: Route.self) { route in
switch route {
case .settings:
SettingsView()
case .profile(let userId):
ProfileView(userId: userId)
}
}
}
.environment(coordinator)
}
}
Best Practices
- Start Simple - Use MV pattern first, add ViewModel when needed
- Prefer @Observable - Over @ObservableObject for new code
- Extract Early - Break views at 50-100 lines
- Single Responsibility - Each view does one thing
- Testable Design - Use protocols for dependencies
- Environment for Shared State - Pass global state via environment
- Local State is Fine - Not everything needs to be in a store
Official Resources
Weekly Installs
6
Repository
bluewaves-creat…s-skillsGitHub Stars
1
First Seen
Jan 26, 2026
Security Audits
Installed on
opencode6
claude-code5
codex5
gemini-cli5
continue4
qwen-code4