SwiftUI Patterns
State Ownership
Use the right property wrapper for the right scope.
// @State — private, view-owned mutable state
struct CounterView: View {
@State private var count = 0
var body: some View {
Button("Count: \(count)") { count += 1 }
}
}
// @Binding — child receives reference to parent state
struct ToggleRow: View {
let title: String
@Binding var isOn: Bool
var body: some View { Toggle(title, isOn: $isOn) }
}
// @StateObject — view creates and owns the ObservableObject
struct ProfileView: View {
@StateObject private var vm = ProfileViewModel()
var body: some View { Text(vm.name) }
}
// @ObservedObject — view receives (does NOT own) an ObservableObject
struct OrderRow: View {
@ObservedObject var order: Order
var body: some View { Text(order.status) }
}
// @EnvironmentObject — injected from ancestor; app-wide state
struct ThemeButton: View {
@EnvironmentObject var theme: ThemeStore
var body: some View { Button("Toggle") { theme.toggle() } }
}
View Decomposition
Break large views into small, focused subviews. Use computed properties for static sub-trees.
struct ArticleView: View {
let article: Article
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
headerSection
AuthorRow(author: article.author)
Text(article.body).font(.body)
}
.padding()
}
}
private var headerSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text(article.title).font(.title.bold())
Text(article.publishedAt, style: .date).foregroundStyle(.secondary)
}
}
}
Navigation (NavigationStack)
Prefer typed NavigationStack paths over deprecated NavigationView.
enum Route: Hashable {
case detail(Item)
case settings
case profile(User)
}
struct RootView: View {
@State private var path: [Route] = []
var body: some View {
NavigationStack(path: $path) {
ItemListView(path: $path)
.navigationDestination(for: Route.self) { route in
switch route {
case .detail(let item): ItemDetailView(item: item)
case .settings: SettingsView()
case .profile(let user): ProfileView(user: user)
}
}
}
}
}
Lists and Lazy Stacks
List(items) { item in
ItemRow(item: item)
.swipeActions(edge: .trailing) {
Button("Delete", role: .destructive) { delete(item) }
}
}
.refreshable { await viewModel.reload() }
ScrollView {
LazyVStack(spacing: 12, pinnedViews: .sectionHeaders) {
ForEach(sections) { section in
Section { ForEach(section.items) { ItemCard(item: $0) } }
header: { SectionHeader(title: section.title) }
}
}
.padding(.horizontal)
}
Custom View Modifiers
struct CardStyle: ViewModifier {
var cornerRadius: CGFloat = 12
func body(content: Content) -> some View {
content
.padding()
.background(.background, in: RoundedRectangle(cornerRadius: cornerRadius))
.shadow(color: .black.opacity(0.08), radius: 8, y: 4)
}
}
extension View {
func card(cornerRadius: CGFloat = 12) -> some View {
modifier(CardStyle(cornerRadius: cornerRadius))
}
}
Animations
Button("Expand") {
withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
isExpanded.toggle()
}
}
// Hero transitions
@Namespace private var heroNamespace
// Source view
Image(item.thumbnail).matchedGeometryEffect(id: item.id, in: heroNamespace)
// Destination view
Image(item.thumbnail).matchedGeometryEffect(id: item.id, in: heroNamespace)
Common Anti-Patterns
@Stateon reference types — use@StateObject@ObservedObjectfor view-created objects — they get recreated; use@StateObjectNavigationView— deprecated; useNavigationStack- Forcing
AnyVieweverywhere — keep views typed withsome View - One massive
body— decompose into subviews