SwiftUI: From Beginner to Intermediate

A Comprehensive Deep-Dive Reference Guide


How to use this guide: Each section builds on the last. Code examples are production-oriented — the patterns shown here reflect real-world usage, not toy demos. Where Swift nuances matter, they're explained in full.


Table of Contents

  1. The SwiftUI Mental Model
  2. Views — The Building Blocks
  3. Layout System
  4. State & Data Flow
  5. Bindings & Two-Way Data Flow
  6. Environment & Preferences
  7. ObservableObject & The Combine Bridge
  8. The Observation Framework (Swift 5.9+)
  9. Navigation
  10. Lists & Dynamic Content
  11. Animations & Transitions
  12. Gestures
  13. Custom View Modifiers
  14. ViewBuilder & Custom Containers
  15. Drawing & Graphics with Canvas & Shapes
  16. Async/Await in SwiftUI
  17. Task Management & Lifecycle
  18. Custom Layout Protocol
  19. Accessibility
  20. Performance & Architecture Patterns

1. The SwiftUI Mental Model

1.1 Declarative vs. Imperative

Before writing a single line of SwiftUI, you need to rewire how you think about UI construction. UIKit is imperative: you tell the system how to build the UI step-by-step.

// UIKit — imperative
let label = UILabel()
label.text = "Hello"
label.textColor = .red
view.addSubview(label)

SwiftUI is declarative: you describe what the UI should look like given the current state. The framework figures out how to get there.

// SwiftUI — declarative
Text("Hello")
    .foregroundColor(.red)

The critical insight: SwiftUI is a function of state. Your UI is not a persistent tree of objects you mutate — it's a description that gets regenerated every time state changes. SwiftUI then diffs that description against the previous one and applies the minimum changes needed to the actual render layer.

UI = f(State)

1.2 View Identity

SwiftUI tracks views across updates using identity. There are two kinds:

// Explicit identity forces re-creation when id changes
Text("Hello")
    .id(userID) // new userID = new Text view entirely

Understanding identity is critical for animations and for avoiding subtle bugs where SwiftUI reuses view state when you expect a fresh view.

1.3 The Render Cycle

When state changes:

  1. SwiftUI calls body on affected views.
  2. SwiftUI diffs the new view description against the previous one.
  3. SwiftUI commits minimal updates to the renderer.
  4. The cycle repeats only for views whose dependencies actually changed.

body must be pure — same state in, same view description out, every time. Side effects in body (network calls, mutations) are bugs waiting to happen.


2. Views — The Building Blocks

2.1 The View Protocol

Every piece of UI in SwiftUI conforms to the View protocol:

public protocol View {
    associatedtype Body: View
    @ViewBuilder var body: Self.Body { get }
}

The associatedtype Body is what makes SwiftUI's type system work. When you write a custom view, Swift infers the concrete type of Body from your implementation — no AnyView erasure required (though that exists too, at a performance cost).

2.2 Primitive Views

SwiftUI ships with a set of primitive views. Everything else is composed from these:

// Text — renders a string
Text("Hello, World!")
Text("Formatted: \(Date(), style: .date)")
Text(attributedString) // NSAttributedString / AttributedString

// Image
Image("launchScreen")                    // from asset catalog
Image(systemName: "heart.fill")          // SF Symbol
Image(decorative: "background")          // accessibility: not announced

// Shapes
Rectangle()
Circle()
RoundedRectangle(cornerRadius: 12)
Capsule()
Ellipse()

// Controls
Button("Tap me") { print("tapped") }
Toggle("Enabled", isOn: $isEnabled)
Slider(value: $progress, in: 0...1)
Stepper("Count: \(count)", value: $count, in: 0...10)
TextField("Placeholder", text: $inputText)
SecureField("Password", text: $password)
DatePicker("Date", selection: $date)
Picker("Flavor", selection: $flavor) { ... }

// Spacers & Dividers
Spacer()          // flexible, fills available space
Divider()         // horizontal or vertical line

2.3 View Composition

Views are composed by nesting them inside container views. SwiftUI encourages extracting subviews aggressively — this is free from a performance standpoint because SwiftUI only re-renders views whose state changed.

struct ProfileCard: View {
    let user: User

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            AvatarView(imageURL: user.avatarURL)
            UserInfoView(name: user.name, bio: user.bio)
            FollowButton(userID: user.id)
        }
        .padding()
        .background(.regularMaterial)
        .clipShape(RoundedRectangle(cornerRadius: 16))
    }
}

struct AvatarView: View {
    let imageURL: URL
    var body: some View {
        AsyncImage(url: imageURL) { image in
            image.resizable().scaledToFill()
        } placeholder: {
            ProgressView()
        }
        .frame(width: 60, height: 60)
        .clipShape(Circle())
    }
}

2.4 some View vs AnyView

some View is an opaque return type. The compiler knows the concrete type but you don't have to spell it out. This preserves type information, allowing SwiftUI's diffing to work efficiently.

// ✅ Preferred — type is known at compile time
var body: some View {
    Text("Hello")
}

// ⚠️ Type erasure — use sparingly
func makeView() -> AnyView {
    AnyView(Text("Hello"))
}

AnyView erases the type, which means SwiftUI loses structural information and can't diff as efficiently. It also prevents certain optimizations. The two main legitimate uses: heterogeneous collections, or returning different concrete types from a function (which @ViewBuilder usually solves better).

2.5 Modifiers — The Modifier Chain

Modifiers transform a view, returning a new View wrapping the original. The order matters significantly.

Text("Hello")
    .padding()          // adds padding inside the background
    .background(.blue)  // background covers padded area
    .cornerRadius(8)

// vs.

Text("Hello")
    .background(.blue)  // background hugs the text
    .padding()          // padding added outside the background
    .cornerRadius(8)

Modifiers are not mutating the view — they're building a chain of wrapper types. Text("Hello").padding() returns a ModifiedContent<Text, _PaddingLayout>. Every modifier wraps the previous result in another generic type. This is why some View is essential — spelling out those types manually would be unreadable.


3. Layout System

3.1 The Layout Protocol's Three-Step Dance

SwiftUI's layout works through a recursive negotiation:

  1. Parent proposes a size to the child.
  2. Child decides its own size (it can accept, shrink, or ignore the proposal).
  3. Parent positions the child within its bounds.

A child always has final say over its own size. A parent can propose but cannot force.

// Text takes exactly the space it needs (ignores proposal if unconstrained)
Text("Hello") // hugs content

// Spacer takes all available space
Spacer()

// Rectangle fills proposed space by default
Rectangle()
    .fill(.blue)

3.2 Stacks

VStack, HStack, ZStack are the workhorses:

// HStack — horizontal, with alignment and spacing
HStack(alignment: .firstTextBaseline, spacing: 8) {
    Image(systemName: "star")
    Text("Rating")
    Spacer()
    Text("4.8")
        .bold()
}

// VStack — vertical
VStack(alignment: .leading, spacing: 4) {
    Text("Title").font(.headline)
    Text("Subtitle").font(.caption).foregroundStyle(.secondary)
}

// ZStack — layered (back to front)
ZStack(alignment: .bottomTrailing) {
    Image("photo")
    BadgeView()
        .offset(x: 8, y: 8)
}

Alignment across a stack dimension uses alignment guides. The default HStack alignment is .center — this means SwiftUI aligns the centers of children vertically. .firstTextBaseline aligns based on where the first text baseline sits, which is usually more typographically correct when mixing text sizes.

3.3 LazyStacks

LazyVStack and LazyHStack defer view creation until a view is about to be visible. Use inside a ScrollView when you have many items.

ScrollView {
    LazyVStack(spacing: 16, pinnedViews: [.sectionHeaders]) {
        ForEach(sections) { section in
            Section {
                ForEach(section.items) { item in
                    ItemRow(item: item)
                }
            } header: {
                SectionHeader(title: section.title)
                    .background(.regularMaterial) // sticky header looks good with material
            }
        }
    }
}

Important: Don't use LazyVStack everywhere. Regular VStack is faster when you have a small, fixed number of children because lazy stacks have overhead for managing view recycling. Prefer List or LazyVStack only when your content is scrollable and large.

3.4 Grid

Grid (iOS 16+) is a two-dimensional layout container:

Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
    GridRow {
        Text("Name").bold()
        Text("Age").bold()
        Text("Role").bold()
    }
    Divider().gridCellUnsizedAxes(.horizontal)
    ForEach(users) { user in
        GridRow {
            Text(user.name)
            Text("\(user.age)")
            Text(user.role)
        }
    }
}

GridRow defines a row. Views in a Grid align along their column. Use .gridCellColumns(_:) to span a view across multiple columns.

3.5 Frame Modifier

.frame() is one of the most-used and most-misunderstood modifiers:

// Fixed size — view always this size regardless of content
Text("Hello")
    .frame(width: 200, height: 50)

// Flexible with limits
Text("Hello")
    .frame(minWidth: 100, maxWidth: .infinity, minHeight: 44)

// Fill all available width
Text("Hello")
    .frame(maxWidth: .infinity, alignment: .leading)

When you set maxWidth: .infinity, the frame expands to fill the parent's proposal, but the content inside is positioned using the alignment parameter. The text itself doesn't stretch — it sits at the leading edge because that's what alignment: .leading specifies.

3.6 Alignment Guides

Custom alignment guides let you align views across container boundaries:

extension HorizontalAlignment {
    enum MyCustomAlignment: AlignmentID {
        static func defaultValue(in context: ViewDimensions) -> CGFloat {
            context[HorizontalAlignment.center]
        }
    }
    static let myCustom = HorizontalAlignment(MyCustomAlignment.self)
}

VStack(alignment: .myCustom) {
    Text("Short")
        .alignmentGuide(.myCustom) { d in d[.leading] }
    Text("A longer line of text")
        .alignmentGuide(.myCustom) { d in d[.trailing] }
}

Alignment guides are powerful for creating complex, precisely aligned layouts without resorting to GeometryReader or hardcoded offsets.

3.7 GeometryReader

GeometryReader gives you access to the proposed size and safe area insets from the parent:

GeometryReader { proxy in
    let width = proxy.size.width
    let height = proxy.size.height
    let safeArea = proxy.safeAreaInsets

    Circle()
        .fill(.blue)
        .frame(width: width * 0.8) // 80% of available width
        .position(x: width / 2, y: height / 2)
}

GeometryReader takes up all proposed space — it doesn't size itself to its content. Be careful using it inside VStacks; wrap it in a .frame(height:) if needed. Overusing GeometryReader is a code smell — SwiftUI's layout system can handle most cases without it. Reach for it when you genuinely need proportional sizing or need to read the available space.


4. State & Data Flow

State management is where SwiftUI lives and breathes. Getting this right is the difference between a smooth app and a buggy, unpredictable one.

4.1 @State

@State is for view-local, value-type state. It lives inside SwiftUI's managed storage — not inside your view struct itself (which gets recreated frequently).

struct CounterView: View {
    @State private var count = 0
    @State private var isExpanded = false

    var body: some View {
        VStack {
            Button("Increment") {
                count += 1
            }

            if isExpanded {
                Text("Count is \(count)")
                    .transition(.opacity)
            }

            Button("Toggle Detail") {
                withAnimation {
                    isExpanded.toggle()
                }
            }
        }
    }
}

Key rules for @State:

4.2 @State with Complex Types

@State works with any value type, including your own structs:

struct FilterState {
    var searchText = ""
    var selectedCategory: Category = .all
    var sortOrder: SortOrder = .ascending
    var showFavoritesOnly = false
}

struct ContentView: View {
    @State private var filters = FilterState()

    var body: some View {
        ProductList(filters: filters)
            .searchable(text: $filters.searchText)
    }
}

Using a struct to group related state is a great pattern — it reduces the number of @State properties and keeps related state cohesive.

4.3 Why Views Are Structs

SwiftUI views are structs, not classes. This is intentional:

The "real" persistent state lives in SwiftUI's internal storage, keyed by the view's identity in the hierarchy. Your struct is a transient description.


5. Bindings & Two-Way Data Flow

5.1 @Binding

@Binding creates a two-way reference to state owned elsewhere. It's a read-write handle to state that lives in a parent.

struct ToggleRow: View {
    let title: String
    @Binding var isOn: Bool   // doesn't own the value

    var body: some View {
        Toggle(title, isOn: $isOn)
    }
}

struct SettingsView: View {
    @State private var notificationsEnabled = true  // owns the value

    var body: some View {
        ToggleRow(title: "Notifications", isOn: $notificationsEnabled)
    }
}

The $ prefix creates a Binding<T> from a @State or @StateObject property. Passing $notificationsEnabled gives ToggleRow a handle to mutate the parent's state.

5.2 Derived Bindings

You can derive bindings from existing ones using subscripts or transformations:

// Binding to an array element
ForEach($items) { $item in
    ItemEditor(item: $item)  // $item is Binding<Item>
}

// Binding to an optional (map)
let nameBinding = Binding<String>(
    get: { user?.name ?? "" },
    set: { user?.name = $0 }
)

// Binding with transformation
let doubledBinding = Binding<Double>(
    get: { value * 2 },
    set: { value = $0 / 2 }
)

5.3 Custom Binding

You can create bindings from scratch using the Binding initializer:

struct SearchBar: View {
    @Binding var text: String

    // Custom binding that trims whitespace on set
    var trimmedBinding: Binding<String> {
        Binding(
            get: { text },
            set: { text = $0.trimmingCharacters(in: .whitespaces) }
        )
    }

    var body: some View {
        TextField("Search", text: trimmedBinding)
    }
}

This pattern is useful for adding validation, transformation, or side effects to a binding without changing the source data model.

5.4 Binding Pitfalls

// ❌ WRONG: Mutating @Binding in a computed property getter
var displayName: String {
    text = text.uppercased() // side effect in getter — undefined behavior
    return text
}

// ✅ Mutation should happen in response to events
Button("Uppercase") {
    text = text.uppercased()
}

6. Environment & Preferences

6.1 @Environment

The environment is a dictionary-like storage that flows down the view hierarchy. SwiftUI uses it for system values, and you can inject your own.

struct ContentView: View {
    @Environment(\.colorScheme) var colorScheme
    @Environment(\.locale) var locale
    @Environment(\.dynamicTypeSize) var typeSize
    @Environment(\.horizontalSizeClass) var sizeClass
    @Environment(\.dismiss) var dismiss         // action
    @Environment(\.openURL) var openURL         // action
    @Environment(\.isEnabled) var isEnabled

    var body: some View {
        VStack {
            if colorScheme == .dark {
                Text("Dark mode!")
            }
            Button("Dismiss") {
                dismiss()
            }
        }
    }
}

6.2 Custom Environment Keys

// 1. Define the key
struct CardStyleEnvironmentKey: EnvironmentKey {
    static let defaultValue: CardStyle = .default
}

// 2. Extend EnvironmentValues
extension EnvironmentValues {
    var cardStyle: CardStyle {
        get { self[CardStyleEnvironmentKey.self] }
        set { self[CardStyleEnvironmentKey.self] = newValue }
    }
}

// 3. Inject via .environment
ParentView()
    .environment(\.cardStyle, .compact)

// 4. Read anywhere in the subtree
struct CardView: View {
    @Environment(\.cardStyle) var cardStyle
    // ...
}

Custom environment values are great for theme systems, feature flags, and configuration that needs to flow down a deep hierarchy without prop-drilling.

6.3 @EnvironmentObject

@EnvironmentObject injects a reference-type observable object into the environment:

// Define the object
class AppRouter: ObservableObject {
    @Published var currentRoute: Route = .home
}

// Inject at the root
@main
struct MyApp: App {
    @StateObject private var router = AppRouter()

    var body: some Scene {
        WindowGroup {
            RootView()
                .environmentObject(router)
        }
    }
}

// Read anywhere in the tree
struct SomeDeepView: View {
    @EnvironmentObject var router: AppRouter

    var body: some View {
        Button("Go Home") {
            router.currentRoute = .home
        }
    }
}

If you use @EnvironmentObject in a view but forget to inject it, you'll get a runtime crash — not a compile-time error. Prefer the newer @Environment(SomeClass.self) pattern available in iOS 17+ with the @Observable macro, which is type-safe.

6.4 Preference Keys

Preferences flow up the hierarchy — the reverse of environment. They let child views communicate values to ancestors:

struct HeightPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = max(value, nextValue())  // take the maximum height
    }
}

// Child reports its height
struct MeasurableRow: View {
    var body: some View {
        Text("Row Content")
            .background(
                GeometryReader { geo in
                    Color.clear
                        .preference(key: HeightPreferenceKey.self,
                                    value: geo.size.height)
                }
            )
    }
}

// Parent reads heights of all children
struct ParentView: View {
    @State private var maxHeight: CGFloat = 0

    var body: some View {
        VStack {
            ForEach(rows) { row in
                MeasurableRow()
            }
        }
        .onPreferenceChange(HeightPreferenceKey.self) { height in
            maxHeight = height
        }
    }
}

A common use case is making all cells in a list the same height (the tallest one's height), or scroll-tracking for custom navigation effects.


7. ObservableObject & The Combine Bridge

7.1 @StateObject & @ObservedObject

For reference type state that persists beyond a single view's lifetime, you use ObservableObject:

class UserViewModel: ObservableObject {
    @Published var name: String = ""
    @Published var isLoading: Bool = false
    @Published var error: Error? = nil

    private var cancellables = Set<AnyCancellable>()

    func loadUser(id: String) {
        isLoading = true
        UserService.shared.fetchUser(id: id)
            .receive(on: DispatchQueue.main)
            .sink(
                receiveCompletion: { [weak self] completion in
                    self?.isLoading = false
                    if case .failure(let error) = completion {
                        self?.error = error
                    }
                },
                receiveValue: { [weak self] user in
                    self?.name = user.name
                }
            )
            .store(in: &cancellables)
    }
}

@StateObject vs @ObservedObject: this is one of the most important distinctions in SwiftUI.

// ✅ @StateObject — view OWNS the object
// Created once; survives view re-renders
struct ProfileView: View {
    @StateObject private var viewModel = UserViewModel()
    // ...
}

// ✅ @ObservedObject — view receives the object from outside
// Must be created elsewhere (@StateObject in a parent)
struct NameField: View {
    @ObservedObject var viewModel: UserViewModel
    // ...
}

// ❌ WRONG: using @ObservedObject for a locally created object
struct ProfileView: View {
    @ObservedObject private var viewModel = UserViewModel()
    // This recreates the viewModel every time ProfileView's body is re-evaluated!
}

The rule: the view that creates an object should use @StateObject. Views that receive an existing object use @ObservedObject.

7.2 @Published Under the Hood

@Published is a Combine property wrapper. When you write @Published var name: String, it creates a Publisher for name that fires whenever the value changes. ObservableObject synthesizes an objectWillChange: ObservableObjectPublisher publisher. @Published automatically calls objectWillChange.send() before each mutation, which triggers SwiftUI's rendering cycle.

class ViewModel: ObservableObject {
    @Published var count = 0

    // This is roughly what @Published does:
    var count: Int = 0 {
        willSet {
            objectWillChange.send()
        }
    }
}

7.3 Combine Pipelines in ViewModels

ViewModels are the right place for Combine pipelines:

class SearchViewModel: ObservableObject {
    @Published var query: String = ""
    @Published var results: [Item] = []
    @Published var isLoading = false

    private var cancellables = Set<AnyCancellable>()

    init() {
        // Debounce search input, avoid searching on every keystroke
        $query
            .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
            .removeDuplicates()
            .filter { !$0.isEmpty }
            .handleEvents(receiveOutput: { [weak self] _ in
                self?.isLoading = true
            })
            .flatMap { query in
                SearchService.search(query: query)
                    .catch { _ in Just([]) }
            }
            .receive(on: DispatchQueue.main)
            .assign(to: &$results)  // assign to @Published directly!

        // isLoading cleanup when results arrive
        $results
            .map { _ in false }
            .assign(to: &$isLoading)
    }
}

.assign(to: &$results) is a Combine operator that wires a publisher directly to a @Published property, handling lifecycle correctly without leaking.


8. The Observation Framework (Swift 5.9+)

iOS 17+ brings the @Observable macro, which replaces ObservableObject with a cleaner, more performant system.

8.1 @Observable Macro

// Before (ObservableObject)
class UserStore: ObservableObject {
    @Published var name: String = ""
    @Published var age: Int = 0
}

// After (@Observable) — no @Published needed!
@Observable
class UserStore {
    var name: String = ""
    var age: Int = 0
    var profileImage: UIImage? = nil
}

The @Observable macro tracks per-property access. A view that reads only name will only re-render when name changes — not when age changes. With ObservableObject, any @Published change invalidates all observing views. @Observable is strictly more efficient.

8.2 Using @Observable in Views

// Create and own with @State
struct ProfileView: View {
    @State private var store = UserStore()

    var body: some View {
        TextField("Name", text: $store.name) // $ works on @Observable properties via @State
    }
}

// Inject into environment
WindowGroup {
    ContentView()
        .environment(store) // No .environmentObject — just .environment
}

// Read from environment
struct DeepView: View {
    @Environment(UserStore.self) var store  // type-safe, no runtime crash
}

8.3 @Bindable

With @Observable, use @Bindable to create bindings:

struct EditView: View {
    @Bindable var store: UserStore  // received from outside, creates bindings

    var body: some View {
        TextField("Name", text: $store.name)  // $store.name is a Binding<String>
    }
}

Migration rule: For iOS 17+ projects, prefer @Observable over ObservableObject. For iOS 16 and below, use ObservableObject.


9. Navigation

Navigation evolved dramatically with iOS 16. Here's the full picture.

9.1 NavigationStack (iOS 16+)

NavigationStack is the replacement for NavigationView. It's programmatically driven with a path:

struct AppView: View {
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            HomeView()
                .navigationDestination(for: User.self) { user in
                    UserDetailView(user: user)
                }
                .navigationDestination(for: Post.self) { post in
                    PostDetailView(post: post)
                }
        }
    }
}

struct HomeView: View {
    @Environment(\.dismiss) var dismiss

    var body: some View {
        List(users) { user in
            NavigationLink(value: user) {
                UserRow(user: user)
            }
        }
    }
}

NavigationPath is a type-erased stack. You can path.append(someUser) to push programmatically, path.removeLast() to pop, or path.removeLast(path.count) to pop to root.

// Value-based (preferred, iOS 16+)
NavigationLink(value: item) {
    Text(item.name)
}

// Label-based (legacy, iOS 13+)
NavigationLink("Item") {
    ItemDetailView(item: item)
}

// isActive binding (legacy, avoid in new code)
NavigationLink("Item", isActive: $isDetailShowing) {
    ItemDetailView()
}

9.3 NavigationSplitView

For iPad and Mac, use NavigationSplitView:

struct ContentView: View {
    @State private var selectedItem: Item?

    var body: some View {
        NavigationSplitView {
            // Sidebar
            List(items, selection: $selectedItem) { item in
                NavigationLink(value: item) {
                    Text(item.name)
                }
            }
        } detail: {
            // Detail
            if let item = selectedItem {
                DetailView(item: item)
            } else {
                Text("Select an item")
            }
        }
    }
}

9.4 Sheets, Full Screen Covers, and Popovers

struct ContentView: View {
    @State private var showingSheet = false
    @State private var selectedUser: User?
    @State private var showingPopover = false

    var body: some View {
        VStack {
            // Boolean-driven sheet
            Button("Show Sheet") { showingSheet = true }
                .sheet(isPresented: $showingSheet) {
                    SheetView()
                        .presentationDetents([.medium, .large]) // iOS 16+
                        .presentationDragIndicator(.visible)
                }

            // Item-driven sheet (cleaner pattern)
            Button("Show User") { selectedUser = someUser }
                .sheet(item: $selectedUser) { user in
                    UserDetailSheet(user: user)
                }

            // Full screen
            Button("Full Screen")  { showingSheet = true }
                .fullScreenCover(isPresented: $showingSheet) {
                    FullScreenView()
                }

            // Popover (shows as sheet on iPhone)
            Button("Popover") { showingPopover = true }
                .popover(isPresented: $showingPopover,
                         attachmentAnchor: .point(.bottom),
                         arrowEdge: .top) {
                    PopoverContent()
                        .frame(width: 300, height: 200)
                }

            // Confirmation dialog
            Button("Delete", role: .destructive) { showingSheet = true }
                .confirmationDialog("Are you sure?", isPresented: $showingSheet) {
                    Button("Delete", role: .destructive) { performDelete() }
                    Button("Cancel", role: .cancel) {}
                } message: {
                    Text("This cannot be undone.")
                }
        }
    }
}

9.5 Dismissal

// From a sheet
struct SheetView: View {
    @Environment(\.dismiss) var dismiss

    var body: some View {
        Button("Close") { dismiss() }
    }
}

// Programmatic navigation pop
struct DetailView: View {
    @Environment(\.dismiss) var dismiss

    var body: some View {
        Button("Back") { dismiss() }
    }
}

10. Lists & Dynamic Content

10.1 List Fundamentals

List is the most feature-rich scrolling container in SwiftUI. It handles cell recycling, swipe actions, edit mode, and section headers automatically.

struct UserListView: View {
    @State private var users: [User]
    @State private var selection = Set<User.ID>()  // multi-selection

    var body: some View {
        List(users, selection: $selection) { user in
            HStack {
                AsyncImage(url: user.avatarURL)
                    .frame(width: 40, height: 40)
                    .clipShape(Circle())
                VStack(alignment: .leading) {
                    Text(user.name).font(.headline)
                    Text(user.email).font(.caption).foregroundStyle(.secondary)
                }
            }
            .swipeActions(edge: .trailing, allowsFullSwipe: true) {
                Button(role: .destructive) {
                    deleteUser(user)
                } label: {
                    Label("Delete", systemImage: "trash")
                }
                Button {
                    archiveUser(user)
                } label: {
                    Label("Archive", systemImage: "archivebox")
                }
                .tint(.orange)
            }
        }
        .listStyle(.insetGrouped)
        .toolbar {
            EditButton()
        }
    }
}

10.2 List Sections & Headers

List {
    Section {
        ForEach(pinnedItems) { item in
            ItemRow(item: item)
        }
    } header: {
        Text("Pinned").font(.caption).foregroundStyle(.secondary)
    } footer: {
        Text("\(pinnedItems.count) pinned items")
            .font(.caption2)
    }

    Section("Recent") {
        ForEach(recentItems) { item in
            ItemRow(item: item)
        }
    }
}

10.3 Dynamic List Mutations

struct TaskListView: View {
    @State private var tasks: [Task] = Task.samples

    var body: some View {
        List {
            ForEach(tasks) { task in
                TaskRow(task: task)
            }
            .onDelete { indexSet in
                tasks.remove(atOffsets: indexSet)
            }
            .onMove { source, destination in
                tasks.move(fromOffsets: source, toOffset: destination)
            }
        }
        .toolbar {
            EditButton()  // toggles list edit mode for move/delete handles
        }
    }
}

10.4 ForEach — Deep Dive

ForEach is not a loop — it's a view that generates multiple views. The id parameter tells SwiftUI how to identify each element:

// Identifiable — preferred
ForEach(items) { item in ... }  // uses item.id automatically

// Custom keypath
ForEach(strings, id: \.self) { string in ... }

// Range
ForEach(0..<5) { i in Text("Item \(i)") }

// Binding iteration (iOS 15+)
ForEach($items) { $item in
    ItemEditor(item: $item)  // $item is Binding<Item>
}

If id values aren't stable or unique, you'll get animation glitches and re-render bugs. Always use stable, unique identifiers.

10.5 ScrollView & ScrollViewReader

struct MessageListView: View {
    @State private var messages: [Message]

    var body: some View {
        ScrollViewReader { proxy in
            ScrollView {
                LazyVStack(spacing: 8) {
                    ForEach(messages) { message in
                        MessageBubble(message: message)
                            .id(message.id)  // needed for scrollTo
                    }
                }
            }
            .onChange(of: messages.count) { _, newCount in
                // Auto-scroll to latest message
                if let lastID = messages.last?.id {
                    withAnimation {
                        proxy.scrollTo(lastID, anchor: .bottom)
                    }
                }
            }
        }
    }
}

11. Animations & Transitions

11.1 Implicit Animation with .animation()

struct AnimatedView: View {
    @State private var isLarge = false

    var body: some View {
        Circle()
            .fill(.blue)
            .frame(width: isLarge ? 200 : 100,
                   height: isLarge ? 200 : 100)
            .animation(.spring(response: 0.4, dampingFraction: 0.6),
                       value: isLarge)
            .onTapGesture { isLarge.toggle() }
    }
}

The value: parameter is critical — it scopes the animation to changes in that specific value. Without it, all state changes to that view animate, which is usually too broad and causes animation leakage.

11.2 Explicit Animation with withAnimation

Button("Toggle") {
    withAnimation(.easeInOut(duration: 0.3)) {
        isExpanded.toggle()
        selectedItem = nil
        progress = 1.0
    }
}

withAnimation wraps a block of mutations. All state changes inside will animate. This is explicit and clear about what's animated.

11.3 Animation Curves

// Built-in
.animation(.linear, value: progress)
.animation(.easeIn, value: progress)
.animation(.easeOut, value: progress)
.animation(.easeInOut, value: progress)
.animation(.default, value: progress)  // system default (easeInOut)

// Spring — physics-based
.animation(.spring(), value: isOn)
.animation(.spring(response: 0.5, dampingFraction: 0.7, blendDuration: 0), value: isOn)

// Bouncy / Smooth (iOS 17+)
.animation(.bouncy, value: isOn)
.animation(.smooth, value: isOn)
.animation(.snappy, value: isOn)

// Custom bezier
.animation(.timingCurve(0.2, 0.8, 0.2, 1.0, duration: 0.4), value: isOn)

// Delayed or repeated
.animation(.easeOut.delay(0.2), value: isOn)
.animation(.easeOut.repeatCount(3, autoreverses: true), value: isOn)
.animation(.easeOut.repeatForever(autoreverses: false), value: isOn)

11.4 Transitions

Transitions define how a view enters/exits when it's conditionally shown:

if isVisible {
    Text("Hello!")
        .transition(.opacity)                        // fade
        .transition(.move(edge: .bottom))            // slide from bottom
        .transition(.slide)                          // slides from leading
        .transition(.scale)                          // scale from center
        .transition(.asymmetric(
            insertion: .move(edge: .top),
            removal: .move(edge: .bottom)
        ))                                           // different in/out
}

Transitions are combined with animation on the parent or via withAnimation:

withAnimation(.spring()) {
    isVisible.toggle()
}

11.5 Custom Transitions

extension AnyTransition {
    static var flipFromLeft: AnyTransition {
        AnyTransition.modifier(
            active: FlipEffect(angle: -90),
            identity: FlipEffect(angle: 0)
        )
    }
}

struct FlipEffect: ViewModifier {
    let angle: Double
    func body(content: Content) -> some View {
        content.rotation3DEffect(.degrees(angle), axis: (x: 0, y: 1, z: 0))
    }
}

11.6 Matched Geometry Effect

Animates a view from one position to another, even across different parent containers:

struct HeroAnimationView: View {
    @Namespace private var namespace
    @State private var isExpanded = false

    var body: some View {
        if isExpanded {
            // Detail view
            VStack {
                Image("hero")
                    .resizable()
                    .matchedGeometryEffect(id: "heroImage", in: namespace)
                    .frame(maxWidth: .infinity)
                    .frame(height: 300)
                Text("Detail Content")
            }
            .onTapGesture { withAnimation { isExpanded = false } }
        } else {
            // Card view
            HStack {
                Image("hero")
                    .resizable()
                    .matchedGeometryEffect(id: "heroImage", in: namespace)
                    .frame(width: 60, height: 60)
                    .clipShape(RoundedRectangle(cornerRadius: 8))
                Text("Tap to expand")
            }
            .onTapGesture { withAnimation(.spring()) { isExpanded = true } }
        }
    }
}

The @Namespace creates a shared coordinate space. Views with the same id and in: namespace will animate from one to the other. Both source and destination must exist in the same rendering pass — typically managed by if/else.

11.7 PhaseAnimator & KeyframeAnimator (iOS 17+)

// PhaseAnimator — cycle through phases
PhaseAnimator([false, true]) { isHighlighted in
    Circle()
        .fill(isHighlighted ? .yellow : .orange)
        .scaleEffect(isHighlighted ? 1.2 : 1.0)
} animation: { phase in
    .spring(duration: 0.3)
}

// KeyframeAnimator — precise multi-step animation
KeyframeAnimator(initialValue: AnimationValues()) { values in
    RocketView()
        .rotationEffect(values.angle)
        .offset(y: values.verticalOffset)
} keyframes: { _ in
    KeyframeTrack(\.angle) {
        LinearKeyframe(.degrees(0), duration: 0.1)
        CubicKeyframe(.degrees(-15), duration: 0.2)
        CubicKeyframe(.degrees(15), duration: 0.2)
        LinearKeyframe(.degrees(0), duration: 0.1)
    }
    KeyframeTrack(\.verticalOffset) {
        LinearKeyframe(0, duration: 0.1)
        SpringKeyframe(-50, duration: 0.3)
        LinearKeyframe(0, duration: 0.2)
    }
}

12. Gestures

12.1 Basic Gestures

// Tap
view.onTapGesture { print("tapped") }
view.onTapGesture(count: 2) { print("double tapped") }

// Long press
view.onLongPressGesture(minimumDuration: 0.5) { print("long pressed") }

// Gesture modifier (more control)
view.gesture(TapGesture().onEnded { print("tapped") })

12.2 DragGesture

struct DraggableCard: View {
    @State private var offset: CGSize = .zero
    @State private var isDragging = false

    var body: some View {
        RoundedRectangle(cornerRadius: 16)
            .fill(.blue)
            .frame(width: 200, height: 120)
            .offset(offset)
            .scaleEffect(isDragging ? 1.05 : 1.0)
            .shadow(radius: isDragging ? 20 : 5)
            .animation(.spring(), value: isDragging)
            .gesture(
                DragGesture()
                    .onChanged { value in
                        offset = value.translation
                        isDragging = true
                    }
                    .onEnded { value in
                        // Snap back if not thrown far enough
                        let speed = sqrt(pow(value.velocity.width, 2) +
                                        pow(value.velocity.height, 2))
                        if speed < 500 {
                            withAnimation(.spring()) { offset = .zero }
                        }
                        isDragging = false
                    }
            )
    }
}

12.3 MagnifyGesture & RotateGesture

struct ZoomableImage: View {
    @State private var scale: CGFloat = 1.0
    @State private var rotation: Angle = .zero
    @State private var baseScale: CGFloat = 1.0

    var body: some View {
        Image("photo")
            .resizable()
            .scaledToFit()
            .scaleEffect(scale)
            .rotationEffect(rotation)
            .gesture(
                MagnifyGesture()
                    .onChanged { value in
                        scale = baseScale * value.magnification
                    }
                    .onEnded { value in
                        baseScale = scale
                    }
            )
            .gesture(
                RotateGesture()
                    .onChanged { value in
                        rotation = value.rotation
                    }
            )
    }
}

12.4 Simultaneous & Sequential Gestures

// Both gestures active at the same time
view.gesture(
    SimultaneousGesture(
        DragGesture(),
        MagnifyGesture()
    )
)

// Second gesture only begins after first completes
view.gesture(
    SequenceGesture(
        LongPressGesture(minimumDuration: 0.5),
        DragGesture()
    )
)

// Exclusive — first one to recognize wins
view.gesture(
    ExclusiveGesture(
        TapGesture(count: 2),
        TapGesture(count: 1)
    )
)

13. Custom View Modifiers

13.1 ViewModifier Protocol

struct CardStyle: ViewModifier {
    var isHighlighted: Bool

    func body(content: Content) -> some View {
        content
            .padding()
            .background(isHighlighted ? Color.yellow.opacity(0.3) : Color(.systemBackground))
            .clipShape(RoundedRectangle(cornerRadius: 12))
            .shadow(color: .black.opacity(0.1), radius: isHighlighted ? 8 : 4, y: 2)
            .animation(.easeInOut, value: isHighlighted)
    }
}

// Convenience extension
extension View {
    func cardStyle(highlighted: Bool = false) -> some View {
        modifier(CardStyle(isHighlighted: highlighted))
    }
}

// Usage
Text("Hello")
    .cardStyle(highlighted: true)

13.2 ViewModifier vs Extension

Both work, but ViewModifier is preferable when:

// ViewModifier with internal state
struct ShakeEffect: ViewModifier {
    @State private var shakes = 0

    func body(content: Content) -> some View {
        content
            .offset(x: CGFloat(shakes) % 2 == 0 ? -5 : 5)
            .animation(.default, value: shakes)
            .onAppear { shakes = 5 }
    }
}

13.3 Environment-Aware Modifiers

struct AdaptiveTextStyle: ViewModifier {
    @Environment(\.colorScheme) var colorScheme
    @Environment(\.dynamicTypeSize) var typeSize

    func body(content: Content) -> some View {
        content
            .foregroundStyle(colorScheme == .dark ? .white : .black)
            .font(typeSize >= .accessibility1 ? .title : .body)
    }
}

14. ViewBuilder & Custom Containers

14.1 @ViewBuilder

@ViewBuilder is what makes SwiftUI's closure-based API work. It transforms a block of view declarations into a single View type using result builder magic.

// You've been using @ViewBuilder all along — this is what VStack does
struct VStack<Content: View>: View {
    let content: Content

    init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }

    var body: some View { content }
}

You can use @ViewBuilder in your own views to accept generic view content:

struct Card<Content: View>: View {
    let title: String
    @ViewBuilder let content: Content

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text(title)
                .font(.headline)
            Divider()
            content
        }
        .padding()
        .background(.regularMaterial)
        .clipShape(RoundedRectangle(cornerRadius: 12))
    }
}

// Usage
Card(title: "Profile") {
    Text("Name: Simran")
    Text("Role: iOS Developer")
    HStack { /* ... */ }
}

14.2 Generic Container Views

Building reusable containers with generic content:

struct AsyncContentView<T, Content: View, Loading: View, Failure: View>: View {
    let state: LoadingState<T>
    @ViewBuilder let content: (T) -> Content
    @ViewBuilder let loading: () -> Loading
    @ViewBuilder let failure: (Error) -> Failure

    var body: some View {
        switch state {
        case .idle, .loading:
            loading()
        case .loaded(let value):
            content(value)
        case .failed(let error):
            failure(error)
        }
    }
}

// Usage
AsyncContentView(state: viewModel.state) { users in
    UserList(users: users)
} loading: {
    ProgressView("Loading...")
} failure: { error in
    ErrorView(error: error)
}

14.3 Conditional ViewBuilder

@ViewBuilder supports if, if/else, and switch:

@ViewBuilder
var buttonContent: some View {
    if isLoading {
        ProgressView()
            .tint(.white)
    } else if isSuccess {
        Image(systemName: "checkmark")
            .foregroundStyle(.green)
    } else {
        Text("Submit")
    }
}

15. Drawing & Graphics with Canvas & Shapes

15.1 Custom Shapes

Implement Shape by defining a path(in:) function:

struct Triangle: Shape {
    func path(in rect: CGRect) -> Path {
        var path = Path()
        path.move(to: CGPoint(x: rect.midX, y: rect.minY))
        path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
        path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
        path.closeSubpath()
        return path
    }
}

struct Wave: Shape {
    var amplitude: Double = 20
    var frequency: Double = 2

    func path(in rect: CGRect) -> Path {
        var path = Path()
        path.move(to: CGPoint(x: 0, y: rect.midY))

        for x in stride(from: 0, to: rect.width, by: 1) {
            let angle = (x / rect.width) * frequency * .pi * 2
            let y = rect.midY + amplitude * sin(angle)
            path.addLine(to: CGPoint(x: x, y: y))
        }
        return path
    }
}

// Shapes are Animatable — animate their properties
struct AnimatedWave: View {
    @State private var amplitude = 10.0

    var body: some View {
        Wave(amplitude: amplitude)
            .stroke(.blue, lineWidth: 2)
            .animation(.easeInOut(duration: 1).repeatForever(), value: amplitude)
            .onAppear { amplitude = 40 }
    }
}

15.2 Animatable Shapes

To animate custom shape properties, conform to Animatable:

struct AnimatableWave: Shape {
    var amplitude: Double

    var animatableData: Double {
        get { amplitude }
        set { amplitude = newValue }
    }

    func path(in rect: CGRect) -> Path { /* ... */ }
}

15.3 Canvas

Canvas gives you a direct drawing context — ideal for particle systems, charts, or high-performance graphics:

struct ConstellationView: View {
    let stars: [Star]

    var body: some View {
        Canvas { context, size in
            // Draw connection lines
            for (i, star) in stars.enumerated() {
                guard i + 1 < stars.count else { break }
                let next = stars[i + 1]
                var path = Path()
                path.move(to: CGPoint(x: star.x * size.width, y: star.y * size.height))
                path.addLine(to: CGPoint(x: next.x * size.width, y: next.y * size.height))
                context.stroke(path, with: .color(.white.opacity(0.3)), lineWidth: 0.5)
            }

            // Draw stars
            for star in stars {
                let point = CGPoint(x: star.x * size.width, y: star.y * size.height)
                let radius = star.magnitude * 3
                let starRect = CGRect(
                    x: point.x - radius, y: point.y - radius,
                    width: radius * 2, height: radius * 2
                )
                context.fill(Path(ellipseIn: starRect), with: .color(.white))
            }
        }
        .background(.black)
    }
}

Canvas resolves all views to bitmaps at draw time, making it extremely fast for rendering many elements.

15.4 TimelineView

TimelineView redraws its content at specific intervals — perfect for clocks, animations, or live data:

struct ClockView: View {
    var body: some View {
        TimelineView(.periodic(from: .now, by: 1.0)) { context in
            AnalogClockFace(date: context.date)
        }
    }
}

// Combine with Canvas for animated particle systems
TimelineView(.animation) { context in
    Canvas { drawContext, size in
        let elapsed = context.date.timeIntervalSinceReferenceDate
        for particle in particles {
            let position = particle.position(at: elapsed)
            // draw particle...
        }
    }
}

16. Async/Await in SwiftUI

16.1 .task Modifier

.task is SwiftUI's bridge to async code. It launches an async task when the view appears and cancels it when the view disappears:

struct UserListView: View {
    @State private var users: [User] = []
    @State private var isLoading = false
    @State private var error: Error?

    var body: some View {
        Group {
            if isLoading {
                ProgressView()
            } else if let error {
                ErrorView(error: error)
            } else {
                List(users) { user in UserRow(user: user) }
            }
        }
        .task {
            await loadUsers()
        }
    }

    private func loadUsers() async {
        isLoading = true
        defer { isLoading = false }
        do {
            users = try await UserService.fetchAll()
        } catch {
            self.error = error
        }
    }
}

16.2 .task(id:)

When you pass an id parameter, the task is cancelled and restarted whenever id changes:

struct SearchView: View {
    @State private var query = ""
    @State private var results: [Item] = []

    var body: some View {
        VStack {
            TextField("Search", text: $query)
            List(results) { item in ItemRow(item: item) }
        }
        .task(id: query) {
            // Cancelled and restarted every time query changes
            guard !query.isEmpty else { results = []; return }
            try? await Task.sleep(for: .milliseconds(300)) // debounce
            guard !Task.isCancelled else { return }
            results = try await SearchService.search(query: query)
        }
    }
}

16.3 AsyncImage

AsyncImage(url: URL(string: "https://example.com/image.jpg")) { phase in
    switch phase {
    case .empty:
        ProgressView()
    case .success(let image):
        image
            .resizable()
            .scaledToFill()
    case .failure:
        Image(systemName: "photo")
            .foregroundStyle(.secondary)
    @unknown default:
        EmptyView()
    }
}
.frame(width: 100, height: 100)
.clipShape(RoundedRectangle(cornerRadius: 8))

16.4 Actors & MainActor

UI updates must happen on the main thread. With async/await:

// @MainActor ensures methods run on main thread
@MainActor
class ViewModel: ObservableObject {
    @Published var data: [Item] = []

    func load() async {
        // This runs on main actor — safe to update @Published
        let fetched = await fetchFromServer()  // suspends, other work can happen
        data = fetched  // back on main — safe
    }
}

// Or switch explicitly
func loadInBackground() async {
    let data = await Task.detached(priority: .background) {
        await expensiveComputation()
    }.value

    await MainActor.run {
        self.data = data
    }
}

17. Task Management & Lifecycle

17.1 onAppear & onDisappear

struct TrackableView: View {
    var body: some View {
        Text("Hello")
            .onAppear {
                Analytics.track(.viewAppeared("TrackableView"))
            }
            .onDisappear {
                Analytics.track(.viewDisappeared("TrackableView"))
            }
    }
}

onAppear fires when the view is inserted into the hierarchy. In a List, this means as the cell scrolls into view. .task is generally preferred over .onAppear for async work because it handles cancellation automatically.

17.2 onChange

struct FilteredList: View {
    @State private var searchText = ""
    @State private var filteredItems: [Item] = items

    var body: some View {
        List(filteredItems) { item in ItemRow(item: item) }
            .searchable(text: $searchText)
            .onChange(of: searchText) { _, newValue in
                // iOS 17 syntax: oldValue, newValue
                filteredItems = newValue.isEmpty
                    ? items
                    : items.filter { $0.name.contains(newValue) }
            }
    }
}

In iOS 17+, onChange receives both old and new values. In iOS 16, it receives only the new value.

17.3 View Lifecycle Summary

init()           → View struct created (happens frequently — don't put logic here)
onAppear()       → View inserted into hierarchy
task()           → Async task launched
body             → Re-evaluated when state/environment changes
onChange()       → Specific value changed
onDisappear()    → View removed from hierarchy
task cancelled   → Async task cancelled when view disappears

17.4 Structured Concurrency in Views

struct DataDashboard: View {
    @State private var users: [User] = []
    @State private var posts: [Post] = []

    var body: some View {
        DashboardContent(users: users, posts: posts)
            .task {
                // Fetch both concurrently
                async let fetchedUsers = UserService.fetchAll()
                async let fetchedPosts = PostService.fetchRecent()

                do {
                    (users, posts) = try await (fetchedUsers, fetchedPosts)
                } catch {
                    // handle
                }
            }
    }
}

18. Custom Layout Protocol

iOS 16+ exposes Layout, letting you implement completely custom layout algorithms:

struct RadialLayout: Layout {
    var radius: CGFloat = 100

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
        // Return the size this layout needs
        let diameter = radius * 2
        return CGSize(width: diameter + 50, height: diameter + 50)
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
        let center = CGPoint(x: bounds.midX, y: bounds.midY)
        let count = subviews.count
        let angleStep = (2 * Double.pi) / Double(count)

        for (index, subview) in subviews.enumerated() {
            let angle = angleStep * Double(index) - (.pi / 2)
            let x = center.x + radius * cos(angle)
            let y = center.y + radius * sin(angle)

            let size = subview.sizeThatFits(.unspecified)
            subview.place(
                at: CGPoint(x: x - size.width / 2, y: y - size.height / 2),
                proposal: .unspecified
            )
        }
    }
}

// Usage
RadialLayout(radius: 120) {
    ForEach(menuItems) { item in
        MenuButton(item: item)
    }
}

Custom layouts support AnyLayout for smooth animated transitions between layout types:

@State private var isCircular = false

var layout: AnyLayout {
    isCircular ? AnyLayout(RadialLayout()) : AnyLayout(HStackLayout())
}

layout {
    ForEach(items) { item in ItemView(item: item) }
}

19. Accessibility

Good accessibility is not optional — it's a mark of quality engineering. SwiftUI provides powerful built-in support.

19.1 Accessibility Modifiers

Button(action: deleteItem) {
    Image(systemName: "trash")
}
.accessibilityLabel("Delete item")
.accessibilityHint("Removes this item from your list")
.accessibilityIdentifier("deleteButton") // for UI testing

// Hide decorative elements from VoiceOver
Image("decorativeDivider")
    .accessibilityHidden(true)

// Group related elements
VStack {
    Text("3.5 stars")
    StarRatingView(rating: 3.5)
}
.accessibilityElement(children: .combine)  // read as one element

// Custom value for sliders/steppers
Slider(value: $volume, in: 0...1)
    .accessibilityValue("\(Int(volume * 100)) percent")

19.2 Custom Accessibility Actions

MessageBubble(message: message)
    .accessibilityAction(named: "Reply") {
        replyToMessage(message)
    }
    .accessibilityAction(named: "Delete") {
        deleteMessage(message)
    }
    .accessibilityAction(.magicTap) {
        // Magic tap — two-finger double tap
        togglePlayback()
    }

19.3 Dynamic Type

Always support Dynamic Type:

// ✅ Uses dynamic scaling
Text("Hello").font(.body)
Text("Title").font(.headline)

// ✅ Custom font with dynamic scaling
Text("Custom")
    .font(.custom("Georgia", size: 17, relativeTo: .body))

// ✅ Adaptive layout for large text
@Environment(\.dynamicTypeSize) var typeSize

HStack {
    if typeSize.isAccessibilitySize {
        VStack { content }  // stack vertically for large text
    } else {
        HStack { content }  // stack horizontally normally
    }
}

19.4 Reduce Motion

@Environment(\.accessibilityReduceMotion) var reduceMotion

Button("Animate") {
    if reduceMotion {
        isExpanded.toggle()  // instant change
    } else {
        withAnimation(.spring()) {
            isExpanded.toggle()  // animated change
        }
    }
}

20. Performance & Architecture Patterns

20.1 View Update Optimization

SwiftUI re-renders a view whenever any of its dependencies change. Keep views small and focused:

// ❌ Bad: monolithic view re-renders on any property change
struct BadView: View {
    @ObservedObject var viewModel: HeavyViewModel  // many @Published properties

    var body: some View {
        // This re-renders when ANY viewModel property changes
        VStack {
            NameView(name: viewModel.name)
            StatisticsView(stats: viewModel.stats)
            ChartView(data: viewModel.chartData)
        }
    }
}

// ✅ Better: Extract subviews with only the data they need
struct BetterView: View {
    @ObservedObject var viewModel: HeavyViewModel

    var body: some View {
        VStack {
            NameView(name: viewModel.name)        // only re-renders when name changes
            StatisticsView(stats: viewModel.stats)  // only when stats change
            ChartView(data: viewModel.chartData)    // only when chartData changes
        }
    }
}

With @Observable (iOS 17+), SwiftUI tracks per-property access, so this optimization is more automatic. With ObservableObject, extracting views is critical.

20.2 Equatable Views

Mark views as Equatable to give SwiftUI explicit equality checking:

struct ExpensiveRowView: View, Equatable {
    let item: Item

    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.item.id == rhs.item.id &&
        lhs.item.updatedAt == rhs.item.updatedAt
    }

    var body: some View { /* expensive computation */ }
}

// Wrap with .equatable() modifier
ExpensiveRowView(item: item)
    .equatable()

20.3 Architecture Patterns

MVVM is the conventional SwiftUI pattern:

// Model
struct User: Identifiable, Codable {
    let id: UUID
    var name: String
    var email: String
}

// ViewModel
@Observable
class UserListViewModel {
    var users: [User] = []
    var isLoading = false
    var error: Error?

    private let service: UserService

    init(service: UserService = .shared) {
        self.service = service
    }

    func loadUsers() async {
        isLoading = true
        defer { isLoading = false }
        do {
            users = try await service.fetchAll()
        } catch {
            self.error = error
        }
    }
}

// View
struct UserListView: View {
    @State private var viewModel = UserListViewModel()

    var body: some View {
        List(viewModel.users) { user in
            UserRow(user: user)
        }
        .overlay {
            if viewModel.isLoading { ProgressView() }
        }
        .task { await viewModel.loadUsers() }
    }
}

20.4 Avoiding Common Pitfalls

// ❌ Side effects in body
var body: some View {
    let _ = analytics.track("view rendered") // DON'T DO THIS
    return Text("Hello")
}

// ✅ Side effects in lifecycle events
Text("Hello")
    .onAppear { analytics.track("view appeared") }

// ❌ Creating objects in body
var body: some View {
    let service = NetworkService() // recreated every render!
    return Text(service.baseURL)
}

// ✅ Objects as properties or @State
@State private var service = NetworkService()

// ❌ Using AnyView unnecessarily
var content: AnyView {
    AnyView(Text("Hello"))
}

// ✅ Use @ViewBuilder or opaque return type
@ViewBuilder var content: some View {
    Text("Hello")
}

// ❌ Putting logic in view init
struct BadView: View {
    let data: [Item]
    let filtered: [Item] // computed in init

    init(data: [Item], filter: String) {
        self.data = data
        self.filtered = data.filter { $0.name.contains(filter) } // runs on every render
    }
}

// ✅ Use computed property or ViewModel
var filteredItems: [Item] {
    data.filter { $0.name.contains(filter) }
}

20.5 Instruments & Debugging

Use SwiftUI's debugging tools:

// Print when body evaluates (debugging only)
var body: some View {
    let _ = print("MyView rendered")
    return Text("Hello")
}

// Self._printChanges() — prints which property caused a re-render
var body: some View {
    let _ = Self._printChanges()
    return Text("Hello")
}

In Instruments, use the SwiftUI template to see:


Putting It All Together: A Real-World Example

Here's a complete, intermediate-level feature demonstrating many of these concepts working together:

// MARK: - Model
struct Post: Identifiable, Codable, Hashable {
    let id: UUID
    var title: String
    var content: String
    var author: String
    var likes: Int
    var createdAt: Date
}

// MARK: - ViewModel
@Observable
class PostFeedViewModel {
    var posts: [Post] = []
    var isLoading = false
    var error: Error?
    var searchText = ""
    var sortOrder: SortOrder = .newest

    enum SortOrder: String, CaseIterable {
        case newest = "Newest"
        case mostLiked = "Most Liked"
    }

    var filteredPosts: [Post] {
        let base = searchText.isEmpty ? posts : posts.filter {
            $0.title.localizedCaseInsensitiveContains(searchText) ||
            $0.author.localizedCaseInsensitiveContains(searchText)
        }
        return base.sorted {
            switch sortOrder {
            case .newest: return $0.createdAt > $1.createdAt
            case .mostLiked: return $0.likes > $1.likes
            }
        }
    }

    func load() async {
        isLoading = true
        defer { isLoading = false }
        do {
            posts = try await PostService.fetchFeed()
        } catch {
            self.error = error
        }
    }

    func like(post: Post) {
        guard let index = posts.firstIndex(where: { $0.id == post.id }) else { return }
        posts[index].likes += 1
    }
}

// MARK: - Views
struct PostFeedView: View {
    @State private var viewModel = PostFeedViewModel()
    @State private var selectedPost: Post?
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            Group {
                if viewModel.isLoading && viewModel.posts.isEmpty {
                    ProgressView("Loading posts...")
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                } else {
                    postList
                }
            }
            .navigationTitle("Feed")
            .navigationDestination(for: Post.self) { post in
                PostDetailView(post: post)
            }
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Menu {
                        Picker("Sort", selection: $viewModel.sortOrder) {
                            ForEach(PostFeedViewModel.SortOrder.allCases, id: \.self) { order in
                                Text(order.rawValue).tag(order)
                            }
                        }
                    } label: {
                        Image(systemName: "arrow.up.arrow.down")
                    }
                }
            }
        }
        .searchable(text: $viewModel.searchText, prompt: "Search posts")
        .task { await viewModel.load() }
        .refreshable { await viewModel.load() }
    }

    private var postList: some View {
        List {
            ForEach(viewModel.filteredPosts) { post in
                PostCard(post: post) {
                    viewModel.like(post: post)
                }
                .onTapGesture {
                    path.append(post)
                }
                .listRowSeparator(.hidden)
                .listRowBackground(Color.clear)
            }
        }
        .listStyle(.plain)
        .animation(.default, value: viewModel.filteredPosts.map(\.id))
    }
}

struct PostCard: View {
    let post: Post
    let onLike: () -> Void

    @State private var isLiked = false

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack {
                Circle()
                    .fill(.blue.gradient)
                    .frame(width: 36, height: 36)
                    .overlay(
                        Text(String(post.author.prefix(1)))
                            .foregroundStyle(.white)
                            .font(.headline)
                    )
                VStack(alignment: .leading, spacing: 2) {
                    Text(post.author).font(.subheadline).bold()
                    Text(post.createdAt, style: .relative).font(.caption).foregroundStyle(.secondary)
                }
                Spacer()
            }

            Text(post.title).font(.headline)
            Text(post.content)
                .font(.body)
                .foregroundStyle(.secondary)
                .lineLimit(3)

            HStack {
                Button {
                    withAnimation(.spring(response: 0.3)) {
                        isLiked.toggle()
                        onLike()
                    }
                } label: {
                    Label("\(post.likes)", systemImage: isLiked ? "heart.fill" : "heart")
                        .foregroundStyle(isLiked ? .red : .secondary)
                        .symbolEffect(.bounce, value: isLiked)
                }
                .buttonStyle(.plain)

                Spacer()

                Text(post.content.split(separator: " ").count.formatted() + " words")
                    .font(.caption)
                    .foregroundStyle(.tertiary)
            }
        }
        .padding()
        .background(.regularMaterial)
        .clipShape(RoundedRectangle(cornerRadius: 16))
        .shadow(color: .black.opacity(0.06), radius: 8, y: 2)
    }
}

Where to Go Next

After mastering these intermediate topics, your next steps:

Advanced SwiftUI: matchedGeometryEffect mastery, ScrollView with scrollPosition, scrollTargetBehavior, building custom UIViewRepresentable wrappers, Metal shaders in SwiftUI (iOS 17+).

Architecture at Scale: The Composable Architecture (TCA), Clean Architecture with SwiftUI, coordinator patterns with NavigationStack.

SwiftUI + UIKit Interop: UIViewRepresentable, UIViewControllerRepresentable, UIHostingController, mixing SwiftUI into legacy UIKit apps.

Testing: ViewInspector for unit-testing SwiftUI views, snapshot testing, async test patterns for ViewModels.

Widgets & Extensions: Building widgets with WidgetKit (a SwiftUI-only environment), App Clips, Live Activities.


Guide compiled for SwiftUI iOS 17 / Swift 5.9+. Most concepts apply to iOS 15+; iOS 16+ and iOS 17+ specific features are noted inline.