SwiftUI Introduction
Section 1: List, VStack & HStack & VGrid
List
List is SwiftUI’s equivalent of UITableView. It provides a scrollable, vertically stacked collection of rows grouped into sections. Note that List is inherently single-column (like UITableView) — it does not support multiple items per row. For grid/collection-view layouts, use LazyVGrid instead.
struct FruitListView: View {
let tropical = ["Mango", "Pineapple", "Papaya"]
let berries = ["Strawberry", "Blueberry", "Raspberry", "Blackberry"]
var body: some View {
List {
Section(header: Text("Tropical")) {
ForEach(tropical, id: \.self) { fruit in
Text(fruit)
// .frame(height: 60) // specify cell item height
}
}
// .listSectionSpacing(20) // distance between sections (iOS 17+)
Section(header: Text("Berries")) {
ForEach(berries, id: \.self) { fruit in
Text(fruit)
// .frame(height: 60)
}
}
// .listSectionSpacing(20)
}
.listStyle(.plain)
// .listRowSeparator(.hidden) // remove the separator line between rows
}
}
Annotated Layout Mockup:
┌──────────────────────────────────┐
│ List (scrollable) │
│┌────────────────────────────────┐│
││ Section Header: "Tropical" ││
│├────────────────────────────────┤│
││ ┌────────────────────────────┐ ││
││ │ Mango │ ││ ← row height controlled by .frame(height:)
││ └────────────────────────────┘ ││
││ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ││ ← separator (hidden with .listRowSeparator(.hidden))
││ ┌────────────────────────────┐ ││
││ │ Pineapple │ ││
││ └────────────────────────────┘ ││
││ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ││
││ ┌────────────────────────────┐ ││
││ │ Papaya │ ││
││ └────────────────────────────┘ ││
││ ... (N items via ││
││ ForEach loop) ││
│└────────────────────────────────┘│
│ │
│ ↕ spacing controlled by │
│ .listSectionSpacing(20) │
│ │
│┌────────────────────────────────┐│
││ Section Header: "Berries" ││
│├────────────────────────────────┤│
││ ┌────────────────────────────┐ ││
││ │ Strawberry │ ││
││ └────────────────────────────┘ ││
││ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ││
││ ┌────────────────────────────┐ ││
││ │ Blueberry │ ││
││ └────────────────────────────┘ ││
││ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ││
││ ┌────────────────────────────┐ ││
││ │ Raspberry │ ││
││ └────────────────────────────┘ ││
││ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ││
││ ┌────────────────────────────┐ ││
││ │ Blackberry │ ││
││ └────────────────────────────┘ ││
││ ... (N items via ││
││ ForEach loop) ││
│└────────────────────────────────┘│
└──────────────────────────────────┘
VStack & HStack
VStack arranges views vertically; HStack arranges views horizontally. They can be nested to build complex layouts.
struct ProfileCardView: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("User Profile")
.font(.title)
HStack(spacing: 16) {
Image(systemName: "person.circle.fill")
.resizable()
.frame(width: 50, height: 50)
VStack(alignment: .leading, spacing: 4) {
Text("Alice")
.font(.headline)
Text("iOS Developer")
.font(.subheadline)
.foregroundColor(.gray)
}
Spacer()
Image(systemName: "chevron.right")
}
HStack(spacing: 12) {
Label("42 Posts", systemImage: "doc.text")
Label("128 Followers", systemImage: "person.2")
}
.font(.caption)
}
.padding()
}
}
Annotated Layout Mockup:
┌─── VStack (alignment: .leading, spacing: 12) ───────────────────┐
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ "User Profile" (.title) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ↕ 12pt spacing │
│ │
│ ┌─── HStack (spacing: 16) ─────────────────────────────────────┐│
│ │ ││
│ │ ┌──────┐ ┌─ VStack ──────────┐ ┌───┐ ││
│ │ │ │ │ "Alice" │ │ > │ ││
│ │ │ 👤 │ │ (.headline) │ Spacer() │ │ ││
│ │ │ │ │ "iOS Developer" │ ←─────→ └───┘ ││
│ │ │50x50 │ │ (.subheadline) │ ││
│ │ └──────┘ └───────────────────┘ ││
│ │ ││
│ │ ←─ 16 ─→ ←──── 16 ────→ ││
│ └───────────────────────────────────────────────────────────────┘│
│ │
│ ↕ 12pt spacing │
│ │
│ ┌─── HStack (spacing: 12) ────────────────────┐ │
│ │ 📄 "42 Posts" 👥 "128 Followers" │ │
│ │ ←──── 12 ────→ │ │
│ └──────────────────────────────────────────────┘ │
│ │
└───────────────────────────────────────────────────────────────────┘
LazyVGrid
LazyVGrid is SwiftUI’s equivalent of UICollectionView. It supports multiple items per row by defining columns with GridItem. Views are created lazily — only when they scroll into the visible area.
struct FruitGridView: View {
let fruits = ["Mango", "Pineapple", "Papaya", "Strawberry",
"Blueberry", "Raspberry", "Blackberry", "Guava", "Lychee"]
// Define 3 flexible columns → 3 items per row
let columns = Array(repeating: GridItem(.flexible(), spacing: 12), count: 3)
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(fruits, id: \.self) { fruit in
Text(fruit)
.frame(maxWidth: .infinity, minHeight: 60)
.background(Color.blue.opacity(0.1))
.cornerRadius(8)
}
}
.padding()
}
}
}
Annotated Layout Mockup:
┌─── ScrollView ──────────────────────────────────────────────┐
│ │
│ ┌─── LazyVGrid (3 columns, spacing: 16) ────────────────┐ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ Mango │ │Pineapple │ │ Papaya │ ← row 1 │ │
│ │ │ 60pt h │ │ 60pt h │ │ 60pt h │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
│ │ │ │
│ │ ↕ 16pt vertical spacing │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │Strawberry│ │Blueberry │ │Raspberry │ ← row 2 │ │
│ │ │ 60pt h │ │ 60pt h │ │ 60pt h │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
│ │ │ │
│ │ ↕ 16pt vertical spacing │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │Blackberry│ │ Guava │ │ Lychee │ ← row 3 │ │
│ │ │ 60pt h │ │ 60pt h │ │ 60pt h │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
│ │ │ │
│ │ ←─ 12 ─→ ←─ 12 ─→ │ │
│ │ (horizontal spacing between columns) │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
Section 2: View Modifiers — Padding, Foreground, Background, SafeAreaInset, Color & Margin
View modifiers in SwiftUI are applied in order — each modifier wraps the result of the previous one. The order matters!
Padding
.padding() adds space inside the view’s frame, pushing content inward.
struct PaddingExample: View {
var body: some View {
Text("Hello, SwiftUI!")
.padding(.horizontal, 20) // 20pt left & right
.padding(.vertical, 12) // 12pt top & bottom
.background(Color.yellow)
// Uniform padding on all sides:
Text("Uniform Padding")
.padding(16) // 16pt on all edges
.background(Color.mint)
}
}
Annotated Layout Mockup:
┌─── .background(Color.yellow) ─────────────────────┐
│ │
│ ←── 20pt ──→┌────────────────┐←── 20pt ──→ │
│ │ │ │
│ ↕ 12pt │ Hello, SwiftUI!│ ↕ 12pt │
│ │ │ │
│ └────────────────┘ │
│ │
└────────────────────────────────────────────────────┘
padding adds space BETWEEN content and background
Foreground & Background
.foregroundStyle() sets the content color (text, icons). .background() places a view behind the modified view.
struct ForegroundBackgroundExample: View {
var body: some View {
Text("Important Notice")
.font(.headline)
.foregroundStyle(.white) // text color
.padding(16)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color.red)
)
// Layered background with overlay
Text("Layered")
.foregroundStyle(.blue)
.padding()
.background(Color.blue.opacity(0.1)) // light fill
.background( // outer shadow layer
RoundedRectangle(cornerRadius: 8)
.stroke(Color.blue, lineWidth: 2)
)
}
}
Annotated Layout Mockup:
┌─── .background(RoundedRectangle, fill: red) ──────┐
│ ╭─────────────────────────────────────────────╮ │
│ │ │ │
│ │ "Important Notice" │ │
│ │ foregroundStyle(.white) → white text │ │
│ │ │ │
│ ╰─────────────────────────────────────────────╯ │
│ cornerRadius: 10 │
└─────────────────────────────────────────────────────┘
┌─── outer .background (stroke: blue, 2pt) ─────────┐
│ ┌─── inner .background (blue @ 0.1 opacity) ───┐ │
│ │ │ │
│ │ "Layered" │ │
│ │ foregroundStyle(.blue) → blue text │ │
│ │ │ │
│ └───────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────┘
backgrounds stack: first applied = closest to content
Color
SwiftUI provides system colors, custom colors, and color modifiers.
struct ColorExample: View {
var body: some View {
VStack(spacing: 12) {
// System colors
Text("System Red")
.foregroundStyle(.red)
// Custom color from hex
Text("Custom Color")
.foregroundStyle(Color(red: 0.2, green: 0.5, blue: 0.8))
// Adaptive colors (light/dark mode)
Text("Adaptive")
.foregroundStyle(.primary) // adapts to light/dark
.background(Color(.systemBackground))
// Gradient as foreground
Text("Gradient Text")
.font(.largeTitle)
.foregroundStyle(
LinearGradient(
colors: [.purple, .pink],
startPoint: .leading,
endPoint: .trailing
)
)
}
}
}
Annotated Layout Mockup:
┌─── VStack (spacing: 12) ─────────────────────────┐
│ │
│ "System Red" ← .red (fixed color) │
│ │
│ "Custom Color" ← rgb(0.2, 0.5, 0.8) │
│ │
│ "Adaptive" ← .primary (auto light/dark)│
│ ░░░░░░░░░░░░░░░░░░░ ← .systemBackground │
│ │
│ "Gradient Text" ← purple ──────→ pink │
│ │
└────────────────────────────────────────────────────┘
SafeAreaInset
.safeAreaInset() lets you attach persistent content (like a bottom bar) to the edge of a scrollable view while adjusting the safe area so content scrolls without being obscured.
struct SafeAreaInsetExample: View {
let items = Array(1...20).map { "Item \($0)" }
var body: some View {
List(items, id: \.self) { item in
Text(item)
}
.safeAreaInset(edge: .bottom, spacing: 0) {
// This bar stays pinned at the bottom
HStack {
Text("3 items selected")
Spacer()
Button("Delete") { }
.foregroundStyle(.red)
}
.padding()
.background(.ultraThinMaterial)
}
.safeAreaInset(edge: .top, spacing: 0) {
// Pinned search bar at top
TextField("Search...", text: .constant(""))
.textFieldStyle(.roundedBorder)
.padding()
.background(.bar)
}
}
}
Annotated Layout Mockup:
┌─── Screen ───────────────────────────────────────┐
│ ┌─── .safeAreaInset(edge: .top) ──────────────┐ │
│ │ ┌──────────────────────────────────────┐ │ │
│ │ │ 🔍 Search... │ │ │ ← pinned top bar
│ │ └──────────────────────────────────────┘ │ │
│ │ ░░░░░░░░░ .background(.bar) ░░░░░░░░░░░ │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ ┌─── List (scrollable area) ──────────────────┐ │
│ │ Item 1 │ │
│ │ Item 2 │ │
│ │ Item 3 │ │ ← content scrolls
│ │ ... │ │ freely between
│ │ Item 18 │ │ top & bottom bars
│ │ Item 19 │ │
│ │ Item 20 ← not obscured by bottom bar │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ ┌─── .safeAreaInset(edge: .bottom) ───────────┐ │
│ │ ░░░░░░ .ultraThinMaterial ░░░░░░░░░░░░░░░ │ │
│ │ "3 items selected" [Delete] │ │ ← pinned bottom bar
│ └─────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────┘
Safe area is adjusted → last list item scrolls
above the bottom bar (not hidden behind it)
Modifier Order Matters (Padding vs Background vs Margin)
SwiftUI has no explicit .margin() modifier. Instead, you achieve margin-like spacing by applying .padding() after .background() — the outer padding acts as margin.
struct ModifierOrderExample: View {
var body: some View {
VStack(spacing: 20) {
// Inner padding → background → outer padding (margin)
Text("Padding + Margin")
.padding(12) // inner padding (inside background)
.background(Color.orange) // background wraps padded content
.padding(20) // outer padding = acts as MARGIN
.background(Color.gray.opacity(0.2)) // reveals the "margin" area
// Demonstrates order dependency
Text("Order Matters!")
.background(Color.green) // tight around text (no padding yet)
.padding(16) // padding OUTSIDE the green background
}
}
}
Annotated Layout Mockup:
"Padding + Margin" example:
┌─── .background(gray 0.2) — reveals margin area ────────────┐
│ │
│ ←────── 20pt "margin" ──────→ │
│ │
│ ┌─── .background(orange) ─────────────────────────┐ │
│ │ │ │
│ │ ←─ 12pt padding ─→ │ │
│ │ ┌──────────────────────────────────────┐ │ │
│ │ │ "Padding + Margin" │ │ │
│ │ └──────────────────────────────────────┘ │ │
│ │ ←─ 12pt padding ─→ │ │
│ │ │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ ←────── 20pt "margin" ──────→ │
│ │
└─────────────────────────────────────────────────────────────┘
"Order Matters!" example:
┌─ .background(green) ─┐
← 16pt → │ "Order Matters!" │ ← 16pt →
└──────────────────────┘
^^^^^^^^^^^^ ^^^^^^^^^^^^
This padding is OUTSIDE the green background,
so the green box stays tight around the text.
Section 3: Async Work with .task and Pull-to-Refresh with .refreshable
.task
.task attaches an async operation to a view’s lifecycle. The task starts when the view appears and is automatically cancelled when the view disappears. It replaces the pattern of calling async work inside .onAppear with manual cancellation.
struct UserListView: View {
@State private var users: [String] = []
@State private var isLoading = true
@State private var errorMessage: String?
var body: some View {
NavigationStack {
Group {
if isLoading {
ProgressView("Loading...")
} else if let error = errorMessage {
Text(error)
.foregroundStyle(.red)
} else {
List(users, id: \.self) { user in
Text(user)
}
}
}
.navigationTitle("Users")
}
// .task starts when view appears, cancels when view disappears
.task {
await fetchUsers()
}
}
func fetchUsers() async {
do {
// Simulating network request
let url = URL(string: "https://api.example.com/users")!
let (data, _) = try await URLSession.shared.data(from: url)
let decoded = try JSONDecoder().decode([String].self, from: data)
users = decoded
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
}
Key behaviors of .task:
| Behavior | Description |
|---|---|
| Auto-start | Runs when the view appears |
| Auto-cancel | Cancelled when the view is removed from hierarchy |
| Async context | Body is async — you can await directly |
| Main actor | Runs on @MainActor by default (safe to update @State) |
.task(id:) — Re-run on Value Change
.task(id:) re-triggers the async work whenever the id value changes. The previous task is cancelled before the new one starts.
struct UserDetailView: View {
@State private var selectedUserID: Int = 1
@State private var userDetail: String = ""
var body: some View {
VStack(spacing: 20) {
Picker("User", selection: $selectedUserID) {
Text("User 1").tag(1)
Text("User 2").tag(2)
Text("User 3").tag(3)
}
.pickerStyle(.segmented)
Text(userDetail)
.padding()
}
// Re-fetches every time selectedUserID changes
// Previous in-flight request is automatically cancelled
.task(id: selectedUserID) {
userDetail = "Loading..."
await fetchDetail(for: selectedUserID)
}
}
func fetchDetail(for id: Int) async {
// Simulate network delay
try? await Task.sleep(for: .seconds(1))
userDetail = "Detail for user \(id)"
}
}
Lifecycle diagram:
Timeline:
─────────────────────────────────────────────────────────────────
View appears selectedUserID = 1 → 2 View disappears
│ │ │
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌────────┐
│ .task │ cancelled → │ .task │ cancelled → │ clean │
│ id: 1 │──────────────│ id: 2 │────────────────────│ up │
│ starts │ │ starts │ │ │
└─────────┘ └─────────┘ └────────┘
• Old task cancelled BEFORE new task starts
• View disappearance cancels any running task
.refreshable (Pull-to-Refresh)
.refreshable adds native pull-to-refresh to scrollable views. The refresh indicator automatically shows while the async closure is running and hides when it completes.
struct RefreshableListView: View {
@State private var posts: [String] = ["Post 1", "Post 2", "Post 3"]
@State private var lastUpdated = Date()
var body: some View {
NavigationStack {
List {
Section {
ForEach(posts, id: \.self) { post in
Text(post)
}
} footer: {
Text("Last updated: \(lastUpdated.formatted(date: .omitted, time: .standard))")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.navigationTitle("Feed")
// Pull-to-refresh
.refreshable {
await refreshPosts()
}
}
}
func refreshPosts() async {
// Simulate network fetch
try? await Task.sleep(for: .seconds(2))
// Update data
let newPost = "Post \(posts.count + 1)"
posts.append(newPost)
lastUpdated = Date()
}
}
Interaction flow:
┌─── NavigationStack ────────────────────────────────┐
│ ┌─── "Feed" ──────────────────────────────────┐ │
│ │ │ │
│ │ ┌─ User pulls down ─────────────────────┐ │ │
│ │ │ ↓ ↓ ↓ │ │ │
│ │ │ ┌──────────────┐ │ │ │
│ │ │ │ ◠ Spinner │ ← shows while │ │ │
│ │ │ └──────────────┘ awaiting │ │ │
│ │ └────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────────────────────────────┐ │ │
│ │ │ Post 1 │ │ │
│ │ │ Post 2 │ │ │
│ │ │ Post 3 │ │ │
│ │ │ Post 4 ← newly appended after refresh│ │ │
│ │ ├────────────────────────────────────────┤ │ │
│ │ │ Last updated: 10:30:45 AM │ │ │
│ │ └────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
Combining .task and .refreshable
A common pattern: use .task for the initial load and .refreshable for user-triggered refresh.
struct CombinedExample: View {
@State private var articles: [String] = []
@State private var isLoading = true
var body: some View {
NavigationStack {
Group {
if isLoading {
ProgressView()
} else {
List(articles, id: \.self) { article in
Text(article)
}
.refreshable {
// User-triggered refresh (pull down)
await loadArticles()
}
}
}
.navigationTitle("Articles")
}
.task {
// Initial load when view appears
await loadArticles()
isLoading = false
}
}
func loadArticles() async {
try? await Task.sleep(for: .seconds(1))
articles = (1...10).map { "Article \($0) - \(Date().formatted(date: .omitted, time: .standard))" }
}
}
Lifecycle summary:
View Appears
│
▼
┌──────────────────┐
│ .task fires │ → initial data load
│ isLoading = true│ → shows ProgressView
└────────┬─────────┘
│ await completes
▼
┌──────────────────┐
│ List displayed │ → .refreshable now available
│ isLoading = false│
└────────┬─────────┘
│ user pulls down
▼
┌──────────────────┐
│ .refreshable │ → spinner shown automatically
│ await fires │ → data refreshed
└──────────────────┘ → spinner hides when done
Section 4: NavigationPath — Programmatic Navigation
NavigationPath is a type-erased collection that drives programmatic navigation in a NavigationStack. You can push, pop, and reset the navigation stack by mutating the path.
Basic NavigationPath Usage
struct AppRootView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
List {
Section("Fruits") {
Button("Go to Mango") {
path.append("Mango") // push by appending
}
Button("Go to Pineapple") {
path.append("Pineapple")
}
}
Section("Actions") {
Button("Deep link: Mango → Detail #42") {
// Push multiple screens at once
path.append("Mango")
path.append(42) // pushes a different type
}
}
}
.navigationTitle("Home")
// Define destinations for each data type
.navigationDestination(for: String.self) { fruit in
FruitDetailView(fruit: fruit, path: $path)
}
.navigationDestination(for: Int.self) { id in
ItemDetailView(itemID: id)
}
}
}
}
struct FruitDetailView: View {
let fruit: String
@Binding var path: NavigationPath
var body: some View {
VStack(spacing: 20) {
Text(fruit)
.font(.largeTitle)
Button("Go deeper → Item #7") {
path.append(7) // push another screen
}
Button("Back to root") {
path = NavigationPath() // reset = pop to root
}
}
.navigationTitle(fruit)
}
}
struct ItemDetailView: View {
let itemID: Int
var body: some View {
Text("Item Detail #\(itemID)")
.font(.title)
.navigationTitle("Item \(itemID)")
}
}
Navigation stack diagram:
path = [] path = ["Mango"] path = ["Mango", 42]
┌────────────┐ ┌────────────┐ ┌────────────┐
│ │ append │ │ append │ │
│ Home │──"Mango"──→ │ Fruit │────42────→ │ Item │
│ (List) │ │ Detail │ │ Detail │
│ │ │ "Mango" │ │ #42 │
└────────────┘ └────────────┘ └────────────┘
Pop to root: path = NavigationPath() → returns to Home instantly
Pop one: path.removeLast() → goes back one screen
Pop Control
struct PopExamplesView: View {
@Binding var path: NavigationPath
var body: some View {
VStack(spacing: 16) {
Button("Pop one screen") {
path.removeLast() // go back 1
}
Button("Pop 2 screens") {
path.removeLast(2) // go back 2
}
Button("Pop to root") {
path = NavigationPath() // clear entire stack
}
}
}
}
Pop behavior:
Before: path = ["Mango", 42, 99]
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│ Home │→ │Mango │→ │ #42 │→ │ #99 │ (current)
└──────┘ └──────┘ └──────┘ └──────┘
removeLast(): path = ["Mango", 42] → back to #42
removeLast(2): path = ["Mango"] → back to Mango
path = .init(): path = [] → back to Home
NavigationPath with Enums (Multiple Destination Types)
A clean pattern for apps with many screen types:
enum Route: Hashable {
case profile(userID: String)
case settings
case postDetail(postID: Int)
}
struct MainAppView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
HomeTabView(path: $path)
.navigationDestination(for: Route.self) { route in
switch route {
case .profile(let userID):
ProfileView(userID: userID)
case .settings:
SettingsView()
case .postDetail(let postID):
PostDetailView(postID: postID)
}
}
}
}
}
struct HomeTabView: View {
@Binding var path: NavigationPath
var body: some View {
List {
Button("My Profile") {
path.append(Route.profile(userID: "user_123"))
}
Button("Settings") {
path.append(Route.settings)
}
Button("View Post #5") {
path.append(Route.postDetail(postID: 5))
}
}
.navigationTitle("Home")
}
}
Route-based navigation flow:
┌─────────────────────────────────────────────────────────────────┐
│ NavigationStack(path: $path) │
│ │
│ ┌─── .navigationDestination(for: Route.self) ───────────────┐ │
│ │ │ │
│ │ Route.profile("user_123") → ProfileView │ │
│ │ Route.settings → SettingsView │ │
│ │ Route.postDetail(5) → PostDetailView │ │
│ │ │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ path.append(Route.xxx) → push │
│ path.removeLast() → pop │
│ path = NavigationPath() → pop to root │
└─────────────────────────────────────────────────────────────────┘
Section 5: Animation & Transition
Animation
Animations in SwiftUI automatically interpolate between state changes. You can apply them implicitly (.animation() modifier) or explicitly (withAnimation {}).
Implicit Animation
Attaches an animation to a specific view — animates whenever a tracked value changes.
struct ImplicitAnimationView: View {
@State private var scale: CGFloat = 1.0
@State private var rotation: Double = 0
var body: some View {
VStack(spacing: 40) {
Image(systemName: "star.fill")
.font(.system(size: 60))
.foregroundStyle(.yellow)
.scaleEffect(scale)
.rotationEffect(.degrees(rotation))
// Animates ANY change to this view's properties
.animation(.easeInOut(duration: 0.5), value: scale)
.animation(.spring(response: 0.4, dampingFraction: 0.6), value: rotation)
HStack(spacing: 20) {
Button("Scale") {
scale = scale == 1.0 ? 1.5 : 1.0
}
Button("Rotate") {
rotation += 90
}
}
}
}
}
Animation timeline:
Button tap: scale = 1.0 → 1.5
Frame 0 Frame 5 Frame 10 Frame 15
┌──────┐ ┌────────┐ ┌──────────┐ ┌────────────┐
│ ★ │ → │ ★ │ → │ ★ │ → │ ★ │
│ 1.0x │ │ 1.15x │ │ 1.35x │ │ 1.5x │
└──────┘ └────────┘ └──────────┘ └────────────┘
.easeInOut: starts slow, speeds up, slows down at end
.spring: overshoots slightly, then settles
Explicit Animation (withAnimation)
Wraps a state change — everything that depends on that state animates together.
struct ExplicitAnimationView: View {
@State private var isExpanded = false
@State private var opacity: Double = 1.0
var body: some View {
VStack(spacing: 20) {
RoundedRectangle(cornerRadius: isExpanded ? 20 : 50)
.fill(.blue)
.frame(
width: isExpanded ? 300 : 100,
height: isExpanded ? 200 : 100
)
.opacity(opacity)
Button("Toggle") {
// Everything that reads `isExpanded` animates
withAnimation(.spring(response: 0.6, dampingFraction: 0.7)) {
isExpanded.toggle()
}
}
Button("Fade") {
withAnimation(.easeOut(duration: 0.3)) {
opacity = opacity == 1.0 ? 0.3 : 1.0
}
}
}
}
}
State change visualization:
isExpanded = false isExpanded = true
┌──────────────────┐ ┌──────────────────────────────────┐
│ │ │ │
│ ┌──────┐ │ withAnimation │ ┌────────────────────────┐ │
│ │ ● │ │ ───────────→ │ │ │ │
│ │100x100│ │ .spring() │ │ 300 x 200 │ │
│ │ r:50 │ │ │ │ r: 20 │ │
│ └──────┘ │ │ └────────────────────────┘ │
│ │ │ │
└──────────────────┘ └──────────────────────────────────┘
circle (r=50) rounded rect (r=20)
Animation Types
struct AnimationTypesView: View {
@State private var move = false
var body: some View {
VStack(spacing: 16) {
// Linear — constant speed
Circle().fill(.red).frame(width: 30)
.offset(x: move ? 100 : -100)
.animation(.linear(duration: 1), value: move)
// Ease In Out — smooth start and end
Circle().fill(.orange).frame(width: 30)
.offset(x: move ? 100 : -100)
.animation(.easeInOut(duration: 1), value: move)
// Spring — bouncy, natural feel
Circle().fill(.green).frame(width: 30)
.offset(x: move ? 100 : -100)
.animation(.spring(response: 0.5, dampingFraction: 0.3), value: move)
// Interpolating spring — no bounce
Circle().fill(.blue).frame(width: 30)
.offset(x: move ? 100 : -100)
.animation(.interpolatingSpring(stiffness: 50, damping: 10), value: move)
Button("Move") { move.toggle() }
}
}
}
Comparison of animation curves:
Position over time:
●━━━━━━━━━━━━━━━━━━● linear (constant speed)
╱ ╲
╱ ╲
Start ●───╮ ╭───● End easeInOut (S-curve)
╰────────────────╯
Start ●───────────────────────●──●──● End spring (overshoots, settles)
↑
overshoot
.linear constant velocity
.easeInOut accelerates then decelerates
.spring physics-based, can bounce
.interpolatingSpring physics-based, no overshoot
Transition
Transitions define how a view appears and disappears when inserted into or removed from the view hierarchy. They are paired with if/else or conditional rendering and require an animation context.
Built-in Transitions
struct TransitionExamplesView: View {
@State private var showCard = false
var body: some View {
VStack(spacing: 30) {
Button(showCard ? "Hide" : "Show") {
withAnimation(.easeInOut(duration: 0.4)) {
showCard.toggle()
}
}
if showCard {
// Slide in from leading edge, slide out to trailing
Text("Slide")
.padding()
.background(Color.blue)
.foregroundStyle(.white)
.transition(.slide)
}
if showCard {
// Fade in/out
Text("Opacity")
.padding()
.background(Color.green)
.foregroundStyle(.white)
.transition(.opacity)
}
if showCard {
// Scale up from center
Text("Scale")
.padding()
.background(Color.purple)
.foregroundStyle(.white)
.transition(.scale)
}
if showCard {
// Move in from a specific edge
Text("Move")
.padding()
.background(Color.orange)
.foregroundStyle(.white)
.transition(.move(edge: .bottom))
}
}
}
}
Transition behavior:
showCard = false → true (insertion) showCard = true → false (removal)
.slide:
←────────│ Slide │ │ Slide │────────→
enters from leading exits to trailing
.opacity:
░░░░░░░░ → █████████ █████████ → ░░░░░░░░
alpha 0 → 1 alpha 1 → 0
.scale:
· → ■ → ████ ████ → ■ → ·
scale 0 → 1 scale 1 → 0
.move(edge: .bottom):
┌────┐ ┌────┐
│Move│ │Move│
↑ └────┘ └────┘ ↓
enters from below exits downward
Asymmetric Transitions
Different animation for insertion vs removal:
struct AsymmetricTransitionView: View {
@State private var showNotification = false
var body: some View {
VStack {
Button("Toggle Notification") {
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
showNotification.toggle()
}
}
Spacer()
if showNotification {
HStack {
Image(systemName: "bell.fill")
Text("New message received!")
}
.padding()
.background(Color.blue)
.foregroundStyle(.white)
.cornerRadius(12)
.transition(.asymmetric(
insertion: .move(edge: .top).combined(with: .opacity),
removal: .scale.combined(with: .opacity)
))
}
Spacer()
}
}
}
Asymmetric flow:
Insertion (appears): Removal (disappears):
┌───────────────────┐ ┌───────────────────┐
│ 🔔 New message! │ ← slides │ 🔔 New message! │
└───────────────────┘ down from └───────────────────┘
↓ top + ↓
┌───────────────────┐ fades in ┌───────────────────┐
│ 🔔 New message! │ │ 🔔 New message! │ ← shrinks
└───────────────────┘ └───────────────────┘ + fades
↓ ↓
(settled) · (gone)
.combined(with:) merges multiple transitions together
Custom Transitions with .modifier
struct RotateAndFadeModifier: ViewModifier {
let isActive: Bool
func body(content: Content) -> some View {
content
.rotationEffect(.degrees(isActive ? 90 : 0))
.opacity(isActive ? 0 : 1)
.scaleEffect(isActive ? 0.5 : 1)
}
}
extension AnyTransition {
static var rotateAndFade: AnyTransition {
.modifier(
active: RotateAndFadeModifier(isActive: true),
identity: RotateAndFadeModifier(isActive: false)
)
}
}
struct CustomTransitionView: View {
@State private var showElement = false
var body: some View {
VStack(spacing: 30) {
Button("Toggle") {
withAnimation(.easeInOut(duration: 0.6)) {
showElement.toggle()
}
}
if showElement {
RoundedRectangle(cornerRadius: 16)
.fill(.indigo)
.frame(width: 150, height: 150)
.transition(.rotateAndFade) // custom transition
}
}
}
}
Custom transition states:
identity (visible): active (hidden):
┌──────────────────┐ · ╱╲
│ │ ╱ ╲ ← rotated 90°
│ ████████ │ ╱ 50% ╲ ← scaled 0.5x
│ ████████ │ ╲ opacity ╱ ← faded
│ │ ╲ ╱
└──────────────────┘ · ╲╱
SwiftUI interpolates between identity ↔ active states
Section 6: State Management — How SwiftUI Works Behind the Scenes
The Reactive View Hierarchy
SwiftUI is a declarative, reactive framework. You describe what the UI should look like for a given state, and SwiftUI figures out how to update the screen efficiently. Under the hood, it builds a dependency graph that connects dynamic properties (data) to view bodies (UI).
Views Are Value Types, Not Persistent Objects
Unlike UIKit where UIView instances persist in memory and you mutate them imperatively, SwiftUI View structs are lightweight descriptions — they’re created, diffed, and discarded every render cycle. The persistent state lives in the framework’s internal storage (the attribute graph), not in your view structs.
struct CounterView: View {
@State private var count = 0 // ← stored in SwiftUI's internal graph, NOT in struct
var body: some View { // ← called when dependency changes
VStack {
Text("Count: \(count)") // ← depends on `count`
Button("+1") { count += 1 }
}
}
}
What actually happens in memory:
Your code (value types): SwiftUI's internal storage (persistent):
┌─────────────────────────┐ ┌──────────────────────────────────┐
│ struct CounterView │ │ Attribute Graph │
│ (recreated each render) │ │ ┌────────────────────────────┐ │
│ │ │ │ Node: count = 0 │ │
│ @State var count ───────────────→ │ │ Subscribers: [body] │ │
│ │ │ └────────────────────────────┘ │
│ var body: some View │ │ │
│ → VStack │ │ Rendered View Tree (persistent) │
│ → Text │ │ ┌────────────────────────────┐ │
│ → Button │ │ │ VStack │ │
│ │ │ │ ├── Text("Count: 0") │ │
└─────────────────────────┘ │ │ └── Button("+1") │ │
│ └────────────────────────────┘ │
└──────────────────────────────────┘
The Dependency Graph
SwiftUI maintains an attribute graph — a directed graph that connects property dependencies (sources of truth) to view body dependents (consumers of that data).
How the Dependency Graph is Built
When SwiftUI first evaluates a view’s body, it tracks which dynamic properties are read. This creates edges in the dependency graph:
struct ProfileView: View {
@State private var name = "Alice" // dependency A
@State private var age = 30 // dependency B
@State private var showBadge = false // dependency C
var body: some View {
VStack {
Text(name) // reads A
Text("Age: \(age)") // reads B
if showBadge { // reads C
Image(systemName: "star") // conditionally exists
}
}
}
}
Dependency graph constructed:
┌─── Property Dependencies (Sources of Truth) ───────────────────────┐
│ │
│ ┌─────────┐ ┌─────────┐ ┌──────────────┐ │
│ │ @State │ │ @State │ │ @State │ │
│ │ name │ │ age │ │ showBadge │ │
│ │ "Alice" │ │ 30 │ │ false │ │
│ └────┬────┘ └────┬────┘ └──────┬───────┘ │
│ │ │ │ │
└───────│──────────────────│───────────────────│───────────────────────┘
│ │ │
│ subscribes │ subscribes │ subscribes
└──────────────────┼───────────────────┘
│
▼
┌─── View Body (the single subscriber) ───────────────────────────────┐
│ │
│ ProfileView.body │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ VStack { │ │
│ │ Text(name) ← reads A │ │
│ │ Text("Age: \(age)") ← reads B │ │
│ │ if showBadge { ... } ← reads C │ │
│ │ } │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────┘
The `body` computed property is the subscriber — NOT individual subviews.
Since `body` reads name, age, AND showBadge during evaluation,
changing ANY of them invalidates the ENTIRE body for re-evaluation.
name changes → body re-evaluated (entire VStack re-described)
age changes → body re-evaluated (entire VStack re-described)
showBadge changes → body re-evaluated (entire VStack re-described)
After re-evaluation, the DIFF mechanism determines which render
nodes actually changed and need updating on screen.
Registration Happens at Read Time
The dependency tracking is automatic and implicit. SwiftUI doesn’t scan your code statically — it observes which properties are actually accessed during body evaluation. The subscriber is the view’s body as a whole:
body evaluation starts
│
├── reads `name` → registers ProfileView.body as subscriber of `name`
├── reads `age` → registers ProfileView.body as subscriber of `age`
├── reads `showBadge`→ registers ProfileView.body as subscriber of `showBadge`
│ └── showBadge == false → does NOT evaluate inner Image
│ (Image never produced in this pass)
│
body evaluation ends
Result: ProfileView.body is subscribed to ALL THREE properties.
Any single change → entire body re-evaluated → then diffed.
Property Wrappers: Access-Based vs Declaration-Based Dependency Tracking
Not all property wrappers track dependencies the same way. The critical distinction:
- Declaration-based: The view subscribes to the property merely by declaring it. Even if
bodynever reads the property, any change will still trigger re-evaluation. - Access-based: The view subscribes only when body actually accesses a specific property on the observed object. Unread properties don’t create dependencies.
Overview
| Property Wrapper | Tracking | |
|---|---|---|
@State |
Access-based | value-level diffing before sending invalidation signal |
@Binding |
Declaration-based | value-level diffing before sending invalidation signal |
@Bindable |
Access-based | |
@Observable (macro) |
Access-based | |
@Environment (value-type key) |
Declaration-based | |
@Environment (@Observable object) |
Access-based | |
@StateObject |
Declaration-based | |
@ObservedObject |
Declaration-based | |
@EnvironmentObject |
Declaration-based |
Detailed Comparison
| Property Wrapper | Tracking Method | Invalidation Trigger |
|---|---|---|
@State |
Access-based | Dependency registered when body reads the value. Since @State holds a single value, reading it = subscribing to it. The tracking mechanism is access-based in SwiftUI’s attribute graph |
@Binding |
Declaration-based | The parent pushes a new value down the hierarchy. When the underlying source changes, the child view always re-evaluates — regardless of whether body reads the binding |
@Bindable |
Access-based | Only the specific properties read in body are tracked. Unread properties do NOT trigger re-evaluation |
@Observable (macro) |
Access-based | When a view reads an @Observable object’s properties, only those specific properties create subscriptions |
@Environment (value-type key) |
Declaration-based | Dependency registered when the view declares the environment key. The view subscribes to that key regardless of whether body reads it — merely declaring @Environment(\.key) creates the subscription |
@Environment (@Observable object) |
Access-based | When the environment value is an @Observable object, the Observation framework takes over. Merely declaring the property does NOT subscribe — only properties actually read during body evaluation create dependencies |
@StateObject |
Declaration-based | Any @Published property change on the object invalidates the view, regardless of which property body reads. Uses Combine’s objectWillChange — a single coarse signal |
@ObservedObject |
Declaration-based | Same as @StateObject — any objectWillChange signal invalidates the view |
@EnvironmentObject |
Declaration-based | Any @Published change on the injected object invalidates the view. Same Combine-based coarse signal |
@State — Access-Based
@State creates a source of truth owned by the view. The dependency is registered when body reads the value — this is access-based tracking in SwiftUI’s attribute graph. Since @State wraps a single value, reading it subscribes you to that one value.
┌─── Access-based ──────────────────────────────────────────┐
│ │
│ @State var count = 0 │
│ │
│ var body: some View { │
│ Text("\(count)") ← reading `count` registers the │
│ } dependency in the attribute graph │
│ │
│ The subscription is created at the moment of ACCESS │
│ (when body reads the wrappedValue), not at declaration. │
│ │
│ In practice, you almost always read @State in body, │
│ so it always triggers re-evaluation on change. │
│ But the mechanism itself is access-based. │
│ │
└────────────────────────────────────────────────────────────┘
@StateObject / @ObservedObject — Declaration-Based
Both observe an ObservableObject (using Combine’s objectWillChange publisher). The subscription is coarse-grained — when any @Published property on the object emits, the view is invalidated, even if body only reads one property.
┌─── Declaration-based (coarse-grained) ────────────────────┐
│ │
│ class UserModel: ObservableObject { │
│ @Published var name = "Alice" // property X │
│ @Published var email = "a@b.com" // property Y │
│ @Published var avatar = UIImage()// property Z │
│ } │
│ │
│ struct ProfileView: View { │
│ @StateObject var user = UserModel() │
│ │
│ var body: some View { │
│ Text(user.name) // only reads X │
│ } │
│ } │
│ │
│ user.email = "new@b.com" → body RE-EVALUATED! │
│ user.avatar = newImage → body RE-EVALUATED! │
│ │
│ Even though body only reads `name`, ANY @Published │
│ change fires `objectWillChange` → invalidates view. │
│ │
└────────────────────────────────────────────────────────────┘
@StateObject vs @ObservedObject:
| @StateObject | @ObservedObject | |
|---|---|---|
| Ownership | View creates & owns the object | View receives it (doesn’t own) |
| Lifecycle | Survives view re-creation (persisted by SwiftUI) | May be destroyed if parent re-creates the view |
| Use when | This view is the source of truth | Parent passes the object down |
@EnvironmentObject — Declaration-Based
Same Combine-based objectWillChange mechanism as @StateObject/@ObservedObject, but injected via the environment. Still declaration-based — any @Published change invalidates all views that declared @EnvironmentObject of that type.
┌─── Declaration-based (injected via environment) ──────────┐
│ │
│ class ThemeManager: ObservableObject { │
│ @Published var primaryColor = Color.blue │
│ @Published var fontSize: CGFloat = 16 │
│ @Published var isDark = false │
│ } │
│ │
│ struct TitleView: View { │
│ @EnvironmentObject var theme: ThemeManager │
│ │
│ var body: some View { │
│ Text("Hello") │
│ .foregroundStyle(theme.primaryColor) │
│ } │
│ } │
│ │
│ theme.isDark = true → TitleView.body RE-EVALUATED! │
│ │
│ body only reads `primaryColor`, but `isDark` change │
│ still fires objectWillChange → invalidates TitleView. │
│ │
└────────────────────────────────────────────────────────────┘
@Environment — Two Scenarios
@Environment behaves differently depending on what type of value it holds:
Scenario 1: Value-Type Environment Keys — Declaration-Based
For built-in keys (e.g., \.colorScheme, \.locale) or custom EnvironmentKey with value types, the dependency is registered when the view declares the key. The view subscribes regardless of whether body reads it.
┌─── Declaration-based (value-type key) ────────────────────┐
│ │
│ struct AdaptiveView: View { │
│ @Environment(\.colorScheme) var colorScheme │
│ │
│ var body: some View { │
│ Text("Hello") │
│ .foregroundStyle( │
│ colorScheme == .dark ? .white : .black │
│ ) │
│ } │
│ } │
│ │
│ System switches light↔dark → body re-evaluated │
│ (subscribed because @Environment(\.colorScheme) was │
│ declared — even if body never read `colorScheme`) │
│ │
│ Note: @Environment(\.locale) on a DIFFERENT view │
│ would NOT invalidate AdaptiveView — each view only │
│ subscribes to the keys it declares. │
│ │
└────────────────────────────────────────────────────────────┘
Scenario 2: @Observable Objects via Environment — Access-Based
When the environment value is an @Observable object (iOS 17+), the Observation framework takes over dependency tracking. Merely declaring the property does NOT subscribe the view to the object’s changes. The view is only invalidated if a specific property on the object was read during body evaluation and that property is mutated.
┌─── Access-based (@Observable in environment) ─────────────┐
│ │
│ @Observable │
│ class AppSettings { │
│ var fontSize: CGFloat = 14 │
│ var theme: String = "default" │
│ var debugMode: Bool = false │
│ } │
│ │
│ struct ContentView: View { │
│ @Environment(AppSettings.self) var settings │
│ │
│ var body: some View { │
│ Text("Hello") │
│ .font(.system(size: settings.fontSize)) │
│ // only reads `fontSize` │
│ } │
│ } │
│ │
│ settings.fontSize = 18 → body RE-EVALUATED ✓ │
│ settings.theme = "dark" → body NOT re-evaluated ✗ │
│ settings.debugMode = true→ body NOT re-evaluated ✗ │
│ │
│ Observation framework tracks per-property access. │
│ Only `fontSize` was read → only `fontSize` mutations │
│ trigger invalidation. │
│ │
└────────────────────────────────────────────────────────────┘
@Binding — Declaration-Based
@Binding is a two-way reference to a source of truth owned elsewhere. The parent view pushes a new value down the hierarchy. When the underlying source changes, the child view always re-evaluates — regardless of whether body actually reads the binding’s value.
┌─── Declaration-based (parent pushes value) ───────────────┐
│ │
│ struct ToggleRow: View { │
│ @Binding var isOn: Bool // connected to parent's │
│ // @State │
│ var body: some View { │
│ Toggle("Enable", isOn: $isOn) │
│ } │
│ } │
│ │
│ Parent changes the @State that this binding points to │
│ → ToggleRow.body is ALWAYS re-evaluated │
│ (even if body never read `isOn` directly) │
│ │
│ The binding is a live connection to the parent's source │
│ of truth. Declaring it subscribes the child to changes │
│ from the parent — no body access required. │
│ │
└────────────────────────────────────────────────────────────┘
@Bindable — Access-Based (Observation framework, iOS 17+)
@Bindable is used with @Observable classes (the new Observation framework). Unlike Combine-based wrappers, tracking is access-based — SwiftUI tracks exactly which properties are read during body and only subscribes to those.
┌─── Access-based (fine-grained, per-property) ─────────────┐
│ │
│ @Observable │
│ class UserModel { │
│ var name = "Alice" // property X │
│ var email = "a@b.com" // property Y │
│ var avatar = UIImage() // property Z │
│ } │
│ │
│ struct ProfileView: View { │
│ @Bindable var user: UserModel │
│ │
│ var body: some View { │
│ Text(user.name) // READS X directly │
│ TextField("Email", text: $user.email) │
│ // $user.email is a Binding — does NOT read Y │
│ } │
│ } │
│ │
│ user.name = "Bob" → body RE-EVALUATED ✓ (read in │
│ body via Text(user.name)) │
│ user.email = "b@c.com"→ body NOT re-evaluated ✗ │
│ ($user.email is a binding, not │
│ a read — TextField subscribes │
│ internally in its own body) │
│ user.avatar = img → body NOT re-evaluated ✗ │
│ │
│ Only `name` was accessed during body evaluation, │
│ so only `name` changes trigger ProfileView invalidation. │
│ │
└────────────────────────────────────────────────────────────┘
@Observable (without wrapper) — Access-Based
When you pass an @Observable object as a plain property (no wrapper needed in many cases), SwiftUI still does access-based tracking automatically:
┌─── Access-based (automatic, no wrapper needed) ───────────┐
│ │
│ @Observable │
│ class Counter { │
│ var count = 0 │
│ var label = "Items" │
│ } │
│ │
│ struct CounterView: View { │
│ var counter: Counter // no property wrapper! │
│ │
│ var body: some View { │
│ Text("\(counter.count)") // only reads `count` │
│ } │
│ } │
│ │
│ counter.count = 5 → body RE-EVALUATED ✓ │
│ counter.label = "X" → body NOT re-evaluated ✗ │
│ │
│ SwiftUI's observation tracking sees that only `count` │
│ was accessed → only subscribes to `count`. │
│ │
└────────────────────────────────────────────────────────────┘
Side-by-Side Comparison: Declaration-Based vs Access-Based
┌─────────────── Declaration-Based ──────────────────────────────────────┐
│ │
│ ObservableObject + Combine │
│ │
│ class Model: ObservableObject { │
│ @Published var a = 1 ─┐ │
│ @Published var b = 2 ├──→ ALL fire objectWillChange │
│ @Published var c = 3 ─┘ (single signal, no granularity) │
│ } │
│ │
│ @StateObject / @ObservedObject / @EnvironmentObject var model │
│ │
│ body reads only `a`: │
│ model.b changes → body re-evaluated anyway ⚠️ │
│ model.c changes → body re-evaluated anyway ⚠️ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────── Access-Based ───────────────────────────────────────────┐
│ │
│ @Observable (Observation framework, iOS 17+) │
│ │
│ @Observable │
│ class Model { │
│ var a = 1 ← tracked individually │
│ var b = 2 ← tracked individually │
│ var c = 3 ← tracked individually │
│ } │
│ │
│ @Bindable var model (or just `var model`) │
│ │
│ body reads only `a`: │
│ model.b changes → body NOT re-evaluated ✓ (efficient) │
│ model.c changes → body NOT re-evaluated ✓ (efficient) │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Complete Example: All Property Wrappers in Action
import SwiftUI
import Observation
// ──────────────────────────────────────────────
// MARK: - Combine-based (Declaration-Based)
// ──────────────────────────────────────────────
class SessionManager: ObservableObject {
@Published var isLoggedIn = false
@Published var username = "Guest"
@Published var tokenExpiry = Date()
}
class ThemeSettings: ObservableObject {
@Published var accentColor = Color.blue
@Published var cornerRadius: CGFloat = 8
}
// ──────────────────────────────────────────────
// MARK: - Observation-based (Access-Based)
// ──────────────────────────────────────────────
@Observable
class UserProfile {
var displayName = "Alice"
var bio = "iOS Developer"
var followerCount = 42
var avatarURL: URL? = nil
}
// ──────────────────────────────────────────────
// MARK: - Root View
// ──────────────────────────────────────────────
struct RootView: View {
// @StateObject — owns the object, declaration-based
@StateObject private var session = SessionManager()
@StateObject private var theme = ThemeSettings()
// @State — simple value, access-based
@State private var tabIndex = 0
var body: some View {
TabView(selection: $tabIndex) {
HomeTab()
.tabItem { Label("Home", systemImage: "house") }
.tag(0)
ProfileTab(profile: UserProfile())
.tabItem { Label("Profile", systemImage: "person") }
.tag(1)
}
// Inject into environment for any descendant to use
.environmentObject(session)
.environmentObject(theme)
}
}
// ──────────────────────────────────────────────
// MARK: - @EnvironmentObject usage
// ──────────────────────────────────────────────
struct HomeTab: View {
// @EnvironmentObject — declaration-based
// ANY @Published change on session → this body re-evaluates
@EnvironmentObject var session: SessionManager
// @Environment — declaration-based (single key)
@Environment(\.colorScheme) var colorScheme
var body: some View {
VStack {
Text("Welcome, \(session.username)")
.foregroundStyle(colorScheme == .dark ? .white : .black)
LoginButton(isLoggedIn: $session.isLoggedIn)
}
}
}
// ──────────────────────────────────────────────
// MARK: - @Binding usage
// ──────────────────────────────────────────────
struct LoginButton: View {
// @Binding — declaration-based
// Connected to parent's session.isLoggedIn
// LoginButton (Child): Re-evaluates only when the specific value passed into its @Binding changes (Value-level diffing).
@Binding var isLoggedIn: Bool
var body: some View {
Button(isLoggedIn ? "Log Out" : "Log In") {
isLoggedIn.toggle()
}
}
}
// ──────────────────────────────────────────────
// MARK: - @Bindable usage (Access-Based)
// ──────────────────────────────────────────────
struct ProfileTab: View {
// @Bindable — access-based (Observation framework)
// Only properties READ in body create subscriptions
@Bindable var profile: UserProfile
var body: some View {
VStack(spacing: 16) {
// $profile.displayName passes a Binding — does NOT read the value
TextField("Name", text: $profile.displayName)
// $profile.bio passes a Binding — does NOT read the value
TextField("Bio", text: $profile.bio)
// ProfileTab.body itself reads NO properties on `profile`,
// so mutating profile.displayName, profile.bio, etc.
// will NOT invalidate ProfileTab.body.
// (TextField's own body reads the binding internally —
// that's where the subscription lives.)
}
.padding()
}
}
// ──────────────────────────────────────────────
// MARK: - @Observable without wrapper (Access-Based)
// ──────────────────────────────────────────────
struct FollowerCountBadge: View {
// Plain property — access-based tracking is automatic
var profile: UserProfile
var body: some View {
// Only reads `followerCount` → only subscribes to that
Text("\(profile.followerCount) followers")
.font(.caption)
.padding(6)
.background(Capsule().fill(.blue.opacity(0.2)))
}
// profile.displayName changes → NOT re-evaluated
// profile.followerCount changes → RE-EVALUATED
}
Dependency map for the example above:
┌─── Declaration-Based Dependencies ──────────────────────────────────────┐
│ │
│ SessionManager.objectWillChange ──→ RootView.body (@StateObject owner) │
│ SessionManager.objectWillChange ──→ HomeTab.body (@EnvironmentObject) │
│ ThemeSettings.objectWillChange ──→ RootView.body (@StateObject owner) │
│ ThemeSettings.objectWillChange ──→ (any view with @EnvironmentObject) │
│ \.colorScheme ──→ HomeTab.body (declared key) │
│ session.isLoggedIn (Binding) ──→ LoginButton.body (parent pushes) │
│ │
└──────────────────────────────────────────────────────────────────────────┘
┌─── Access-Based Dependencies ───────────────────────────────────────────┐
│ │
│ @State tabIndex ──→ (not read — only $tabIndex binding │
│ is passed, so NO dependency registered)│
│ profile.displayName ──→ TextField.body (TextField reads the │
│ binding internally, NOT ProfileTab) │
│ profile.bio ──→ TextField.body (same as above) │
│ profile.followerCount ──→ FollowerCountBadge.body │
│ │
│ profile.avatarURL ──→ (nobody reads it → no subscriber) │
│ │
└──────────────────────────────────────────────────────────────────────────┘
The Diff Mechanism — When Does Re-rendering Happen?
SwiftUI does NOT re-render the entire view tree on every state change. It uses a multi-level diff process:
Step 1: Invalidation
When a dynamic property changes, SwiftUI marks only the views whose body subscribed to it as “dirty” (needing re-evaluation). The granularity is at the view level — the entire body is re-evaluated, not individual subviews.
count changes: 0 → 1
Attribute Graph:
┌────────────────────────────────────────────────────────┐
│ @State count ──→ subscriber: CounterView.body │
│ │ │
│ ▼ │
│ mark CounterView as DIRTY │
│ (its body needs re-evaluation) │
└────────────────────────────────────────────────────────┘
Step 2: Body Re-evaluation
SwiftUI calls body only on invalidated views, not the entire tree.
View Hierarchy:
AppView.body ← NOT called (no dependency changed)
│
ProfileView.body ← CALLED (count changed, it's a subscriber)
│
├── VStack ← structural container, always re-evaluated with parent
│ ├── Text ← new value: "Count: 1"
│ └── Button ← unchanged
│
OtherView.body ← NOT called (no dependency changed)
Step 3: Structural Diff (View Tree Comparison)
After re-evaluating body, SwiftUI compares the new view description with the previous one:
// Previous body result: // New body result:
VStack { VStack {
Text("Count: 0") ──diff──→ Text("Count: 1") ← VALUE changed
Button("+1") { ... } Button("+1") { ... } ← IDENTICAL (skip)
} }
Diff rules:
| Comparison | Result | Action |
|---|---|---|
| Same type, same identity | Check properties | Update if properties differ |
| Same type, different identity | Treat as different view | Remove old, insert new |
| Different type | Different view | Remove old, insert new |
| Identical (value equality) | No change | Skip entirely — no render update |
Step 4: Render Tree Update
Only the actually changed nodes are sent to the render layer:
Diff result: Text("Count: 0") → Text("Count: 1")
│
▼
┌──────────────────────────────────┐
│ Render layer: │
│ Update text node content only │
│ Position, size, color unchanged │
│ → minimal GPU/CoreAnimation work │
└──────────────────────────────────┘
Full Lifecycle — Putting It All Together
┌─────────────────────────────────────────────────────────────────────────┐
│ SwiftUI Update Cycle │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. STATE CHANGE │
│ ┌───────────┐ │
│ │ count = 1 │ (user taps button) │
│ └─────┬─────┘ │
│ │ │
│ 2. INVALIDATION │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ Look up subscribers of `count` │ │
│ │ Mark them as dirty │ │
│ └─────────────────┬───────────────────┘ │
│ │ │
│ 3. RE-EVALUATE BODY (only dirty views) │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ Call body on CounterView │ │
│ │ Track new dependency subscriptions │ │
│ │ Produce new view description │ │
│ └─────────────────┬───────────────────┘ │
│ │ │
│ 4. DIFF │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ Compare old tree vs new tree │ │
│ │ Text("Count: 0") ≠ Text("Count: 1")│ │
│ │ Button("+1") == Button("+1") ✓ skip │ │
│ └─────────────────┬───────────────────┘ │
│ │ │
│ 5. RENDER UPDATE (minimal) │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ Patch only Text node in render tree │ │
│ │ Commit to display │ │
│ └─────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Why This Matters — Performance Implications
struct ParentView: View {
@State private var counter = 0
var body: some View {
VStack {
Text("Counter: \(counter)") // ← re-evaluated (reads counter)
Button("+1") { counter += 1 }
ExpensiveChildView() // ← body NOT called if it doesn't
// read `counter`
}
}
}
struct ExpensiveChildView: View {
// No dependency on parent's @State
var body: some View {
// Complex view tree...
Text("I'm expensive but I don't re-render!")
}
}
Key insight: ExpensiveChildView.body is not re-called when counter changes because it has no subscription to that property. SwiftUI’s dependency tracking ensures only relevant views are updated.
counter changes: 0 → 1
ParentView.body ← CALLED (subscriber of counter)
│
├── Text("Counter: 1") ← updated
├── Button ← diffed, unchanged, skip
└── ExpensiveChildView ← struct re-created (value type)
BUT .body NOT called
(no dependency on counter)
SwiftUI compares struct fields:
no inputs changed → skip body entirely
Section 7: Identity in SwiftUI
SwiftUI uses identity to track views across re-evaluations. Identity determines whether a view appearing in a new body result is the same view as before (and should preserve its state) or a different view (and should be created fresh). There are two forms of identity:
Explicit Identity
Explicit identity is assigned by the developer using the .id() modifier or through data-driven constructs like ForEach with Identifiable items. It tells SwiftUI: “this view corresponds to this specific identifier.”
struct ExplicitIdentityDemo: View {
@State private var activeTab = 0
@State private var messages: [Message] = [
Message(id: "msg-001", text: "Hello"),
Message(id: "msg-002", text: "World"),
Message(id: "msg-003", text: "SwiftUI"),
]
var body: some View {
VStack(spacing: 20) {
// ─── Explicit identity via .id() modifier ───
// Changing the id forces SwiftUI to DESTROY the old view
// and CREATE a new one (resetting all internal state)
ScrollViewReader { proxy in
ScrollView {
Text("Content for tab \(activeTab)")
.id(activeTab) // ← explicit identity
// When activeTab changes: old Text is destroyed,
// new Text is created (animations, transitions,
// and internal state all reset)
}
}
// ─── Explicit identity via ForEach + Identifiable ───
// Each Message is identified by its `id` property.
// SwiftUI tracks insertions, deletions, and reordering
// by matching identifiers across re-evaluations.
List {
ForEach(messages) { message in
// identity = message.id ("msg-001", "msg-002", etc.)
MessageRow(message: message)
}
.onDelete { offsets in
messages.remove(atOffsets: offsets)
}
}
Button("Switch Tab") {
activeTab += 1
}
}
}
}
struct Message: Identifiable {
let id: String
var text: String
}
struct MessageRow: View {
let message: Message
@State private var isExpanded = false // state tied to identity
var body: some View {
Text(message.text)
.onTapGesture { isExpanded.toggle() }
}
}
Annotated Layout Mockup:
┌─── ExplicitIdentityDemo ───────────────────────────────────────────────┐
│ │
│ ┌─── ScrollView ────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Text("Content for tab 0") │ │
│ │ .id(activeTab) ← identity = 0 │ │
│ │ │ │
│ │ When activeTab changes 0 → 1: │ │
│ │ • SwiftUI sees .id(0) is GONE, .id(1) is NEW │ │
│ │ • Old Text (id=0) is DESTROYED (state lost) │ │
│ │ • New Text (id=1) is CREATED (fresh state) │ │
│ │ • Transition animations play (remove old, insert new) │ │
│ │ │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─── List + ForEach (identity = Message.id) ────────────────────────┐ │
│ │ │ │
│ │ ┌──────────────────────────────────┐ identity: "msg-001" │ │
│ │ │ "Hello" │ @State isExpanded is │ │
│ │ └──────────────────────────────────┘ tied to this identity │ │
│ │ ┌──────────────────────────────────┐ identity: "msg-002" │ │
│ │ │ "World" │ │ │
│ │ └──────────────────────────────────┘ │ │
│ │ ┌──────────────────────────────────┐ identity: "msg-003" │ │
│ │ │ "SwiftUI" │ │ │
│ │ └──────────────────────────────────┘ │ │
│ │ │ │
│ │ If list reorders (msg-003 moves to top): │ │
│ │ • SwiftUI matches by id, NOT by position │ │
│ │ • "msg-003" keeps its @State (isExpanded) │ │
│ │ • Animation shows the row sliding up │ │
│ │ │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────┐ │
│ │ Switch Tab │ │
│ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Key insight — .id() as a state reset tool:
activeTab: 0 activeTab: 1
┌─────────────────┐ ┌─────────────────┐
│ Text (id=0) │ ──X │ Text (id=1) │ ← brand new instance
│ state: { ... } │ GONE │ state: { } │ (state reset)
└─────────────────┘ └─────────────────┘
Structural Identity
Structural identity is determined by a view’s position in the view hierarchy (its path in the body’s result tree). SwiftUI uses the type and position of a view to identify it when no explicit identity is given. This means views in different branches of if/else are different views with distinct identities.
struct StructuralIdentityDemo: View {
@State private var isLoggedIn = false
var body: some View {
VStack(spacing: 20) {
// ─── if/else = TWO DIFFERENT views (structural identity) ───
// The view in the `if` branch and the view in the `else` branch
// have DIFFERENT structural identities. Toggling `isLoggedIn`
// DESTROYS one and CREATES the other (state does NOT transfer).
if isLoggedIn {
// Structural identity: VStack/[0]/ConditionalContent/TrueContent
WelcomeView()
} else {
// Structural identity: VStack/[0]/ConditionalContent/FalseContent
LoginPromptView()
}
// ─── Same position, same type = SAME view (preserved state) ───
// Both branches produce a Text at the same structural position
// with the same type. SwiftUI treats it as the SAME view —
// only the parameters change, state is PRESERVED.
Text(isLoggedIn ? "Welcome back!" : "Please log in")
.font(isLoggedIn ? .title : .body)
// This is ONE view that gets updated, not two different views.
// Internal state (if any) would be preserved.
Button(isLoggedIn ? "Log Out" : "Log In") {
withAnimation {
isLoggedIn.toggle()
}
}
}
}
}
struct WelcomeView: View {
@State private var animationProgress: CGFloat = 0
var body: some View {
VStack {
Text("Welcome!")
.font(.largeTitle)
ProgressView(value: animationProgress)
.onAppear { animationProgress = 0.8 }
}
}
}
struct LoginPromptView: View {
@State private var shake = false
var body: some View {
VStack {
Image(systemName: "lock.fill")
.font(.largeTitle)
Text("Sign in to continue")
}
}
}
Annotated Layout Mockup:
┌─── StructuralIdentityDemo.body ────────────────────────────────────────┐
│ │
│ VStack │
│ ├── [position 0]: ConditionalContent (if/else) │
│ ├── [position 1]: Text │
│ └── [position 2]: Button │
│ │
│ ═══════════════════════════════════════════════════════════════════════ │
│ │
│ When isLoggedIn = false: │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ ConditionalContent → FalseContent branch │ │
│ │ ┌──────────────────────────────────────────┐ │ │
│ │ │ LoginPromptView │ │ │
│ │ │ 🔒 │ │ │
│ │ │ "Sign in to continue" │ │ │
│ │ │ @State shake = false (own state) │ │ │
│ │ └──────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────┐ │
│ │ "Please log in" (.body font) │ ← same Text view always │
│ └────────────────────────────────────────┘ │
│ ┌──────────┐ │
│ │ Log In │ │
│ └──────────┘ │
│ │
│ ═══════════════════════════════════════════════════════════════════════ │
│ │
│ Toggle isLoggedIn → true: │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ ConditionalContent → TrueContent branch │ │
│ │ ┌──────────────────────────────────────────┐ │ │
│ │ │ WelcomeView ← BRAND NEW instance │ │ │
│ │ │ "Welcome!" │ │ │
│ │ │ ProgressView │ │ │
│ │ │ @State animationProgress = 0 (fresh) │ │ │
│ │ └──────────────────────────────────────────┘ │ │
│ │ │ │
│ │ LoginPromptView is DESTROYED │ │
│ │ (its @State shake is gone forever) │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────┐ │
│ │ "Welcome back!" (.title font) │ ← same Text, updated │
│ └────────────────────────────────────────┘ │
│ ┌──────────┐ │
│ │ Log Out │ │
│ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Structural identity tree:
VStack
├── ConditionalContent ← compiler turns if/else into this
│ ├── TrueContent: WelcomeView ← identity A
│ └── FalseContent: LoginPromptView ← identity B (different!)
│
├── Text(...) ← identity C (always the same view,
│ regardless of the string content)
└── Button(...) ← identity D
Key rule: Same type + same position = same identity = state preserved
Different branch of if/else = different identity = state reset
Section 8: Bridging UIKit and SwiftUI
Using a UIKit View in SwiftUI — UIViewRepresentable
Wrap any UIView subclass so it can be used as a SwiftUI view. You implement makeUIView (creation) and updateUIView (sync SwiftUI state → UIKit).
Context is a struct (value type). Its full type is UIViewRepresentable.Context, which is a typealias for UIViewRepresentableContext
Who Owns It?
SwiftUI owns it. You never create it yourself — the framework constructs it and passes it to you as a parameter:
func makeUIView(context: Context) -> UIView // SwiftUI gives it to you
func updateUIView(_ uiView: UIView, context: Context) // SwiftUI gives it to you
Where Is It Stored?
It's not stored anywhere you can access. SwiftUI constructs it on-the-fly each time it calls your methods. It bundles together:
- A reference to your Coordinator (which SwiftUI stores internally and keeps alive for the view's lifetime)
- A copy of the current EnvironmentValues (which flows down the view tree)
- The current Transaction (animation state for this update pass)
Mental Model
Think of Context as a read-only briefcase SwiftUI hands you each time it calls your functions. You open it, take what you need, and you're done. You don't keep it,
you don't mutate it, you don't create it.
SwiftUI (owner of everything)
├── Creates & stores Coordinator (long-lived, class)
├── Holds EnvironmentValues (value type, flows down)
├── Holds current Transaction (value type, per-update)
│
└── On each call: bundles these into Context (struct) → passes to you
The Coordinator inside it is a class (reference type, long-lived). The Context wrapper itself is a struct (value type, ephemeral).
import SwiftUI
import UIKit
import MapKit
// ─── Wrapping MKMapView (UIKit) for use in SwiftUI ───
struct MapView: UIViewRepresentable {
// SwiftUI state flowing IN
var centerCoordinate: CLLocationCoordinate2D
var zoomLevel: Double
// ─── 1. Create the UIKit view ───
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.delegate = context.coordinator // connect delegate
return mapView
}
// ─── 2. Update UIKit view when SwiftUI state changes ───
func updateUIView(_ mapView: MKMapView, context: Context) {
let region = MKCoordinateRegion(
center: centerCoordinate,
span: MKCoordinateSpan(
latitudeDelta: zoomLevel,
longitudeDelta: zoomLevel
)
)
mapView.setRegion(region, animated: true)
}
// ─── 3. Coordinator handles UIKit delegates/callbacks → SwiftUI ───
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapView
init(_ parent: MapView) {
self.parent = parent
}
func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
// UIKit callback → can update SwiftUI state via parent
// e.g., parent.centerCoordinate = mapView.centerCoordinate
}
}
}
// ─── Usage in SwiftUI ───
struct ContentView: View {
@State private var center = CLLocationCoordinate2D(
latitude: 37.7749, longitude: -122.4194
)
@State private var zoom = 0.05
var body: some View {
VStack {
// Use the UIKit MKMapView as if it were a native SwiftUI view
MapView(centerCoordinate: center, zoomLevel: zoom)
.frame(height: 300)
.cornerRadius(12)
Slider(value: $zoom, in: 0.01...1.0)
.padding()
Text("Zoom: \(zoom, specifier: "%.3f")")
}
}
}
Annotated Layout Mockup:
┌─── ContentView (SwiftUI) ──────────────────────────────────────────────┐
│ │
│ ┌─── MapView: UIViewRepresentable ──────────────────────────────────┐ │
│ │ │ │
│ │ ┌────────────────────────────────────────────────────────────┐ │ │
│ │ │ │ │ │
│ │ │ MKMapView (UIKit) │ │ │
│ │ │ rendered natively inside SwiftUI │ │ │
│ │ │ │ │ │
│ │ │ 📍 San Francisco │ │ │
│ │ │ │ │ │
│ │ └────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ Data flow: │ │
│ │ SwiftUI @State ──→ updateUIView() ──→ MKMapView properties │ │
│ │ MKMapView delegate ──→ Coordinator ──→ (can update @State) │ │
│ │ │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ ──────────●──────────────────── Slider (zoom) │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ "Zoom: 0.050" │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
UIViewRepresentable lifecycle:
SwiftUI re-evaluates body
│
▼
┌─────────────────────────┐ (first time) ┌──────────────────┐
│ centerCoordinate │ ──────────────────→ │ makeUIView() │
│ zoomLevel │ │ (create once) │
└─────────────────────────┘ └──────────────────┘
│
▼ (subsequent updates)
┌─────────────────────────┐
│ updateUIView() │ ← called every time SwiftUI state changes
│ sync state → UIKit │
└─────────────────────────┘
Using a SwiftUI View in UIKit — UIHostingController
Embed any SwiftUI view inside a UIKit view hierarchy using UIHostingController. It acts as a UIViewController whose content is a SwiftUI view.
import SwiftUI
import UIKit
// ─── A SwiftUI view to embed ───
struct ProfileBadge: View {
let username: String
@State private var isExpanded = false
var body: some View {
VStack(spacing: 8) {
Image(systemName: "person.crop.circle.fill")
.font(.system(size: 60))
.foregroundStyle(.blue)
Text(username)
.font(.headline)
if isExpanded {
Text("iOS Developer | SwiftUI Enthusiast")
.font(.caption)
.foregroundStyle(.secondary)
}
Button(isExpanded ? "Less" : "More") {
withAnimation { isExpanded.toggle() }
}
.font(.caption)
}
.padding()
.background(RoundedRectangle(cornerRadius: 12).fill(.ultraThinMaterial))
}
}
// ─── UIKit ViewController embedding the SwiftUI view ───
class ProfileViewController: UIViewController {
private var hostingController: UIHostingController<ProfileBadge>!
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
title = "Profile"
// 1. Create the hosting controller with a SwiftUI view
let swiftUIView = ProfileBadge(username: "Alice")
hostingController = UIHostingController(rootView: swiftUIView)
// 2. Add as child view controller
addChild(hostingController)
view.addSubview(hostingController.view)
hostingController.didMove(toParent: self)
// 3. Set up constraints (treat it like any UIView)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hostingController.view.centerXAnchor.constraint(
equalTo: view.centerXAnchor),
hostingController.view.centerYAnchor.constraint(
equalTo: view.centerYAnchor),
hostingController.view.leadingAnchor.constraint(
greaterThanOrEqualTo: view.leadingAnchor, constant: 20),
hostingController.view.trailingAnchor.constraint(
lessThanOrEqualTo: view.trailingAnchor, constant: -20),
])
}
// ─── Update the SwiftUI view from UIKit side ───
func updateUsername(_ name: String) {
hostingController.rootView = ProfileBadge(username: name)
}
}
// ─── Also works inline with SwiftUI views in UIKit navigation ───
class AppCoordinator {
let navigationController: UINavigationController
init(navigationController: UINavigationController) {
self.navigationController = navigationController
}
func showSettings() {
// Directly push a SwiftUI view onto a UIKit navigation stack
let settingsView = SettingsView()
let hostingVC = UIHostingController(rootView: settingsView)
hostingVC.title = "Settings"
navigationController.pushViewController(hostingVC, animated: true)
}
}
struct SettingsView: View {
@State private var notificationsOn = true
@State private var darkMode = false
var body: some View {
Form {
Toggle("Notifications", isOn: $notificationsOn)
Toggle("Dark Mode", isOn: $darkMode)
}
}
}
Annotated Layout Mockup:
┌─── ProfileViewController (UIKit) ─────────────────────────────────────┐
│ │
│ UINavigationBar: "Profile" │
│ ┌─────────────────────────────────────────────────────────────────────┐│
│ │ ││
│ │ ││
│ │ ┌─── UIHostingController.view ───────────────┐ ││
│ │ │ │ ││
│ │ │ ┌─── ProfileBadge (SwiftUI) ───────────┐ │ ││
│ │ │ │ │ │ ││
│ │ │ │ 👤 (60pt icon) │ │ ││
│ │ │ │ │ │ ││
│ │ │ │ "Alice" │ │ ││
│ │ │ │ (.headline) │ │ ││
│ │ │ │ │ │ ││
│ │ │ │ [More] │ │ ││
│ │ │ │ │ │ ││
│ │ │ └──────────────────────────────────────┘ │ ││
│ │ │ .background(RoundedRectangle + material) │ ││
│ │ └────────────────────────────────────────────┘ ││
│ │ ││
│ │ Constraints: centerX, centerY, leading ≥ 20, trailing ≤ -20 ││
│ │ ││
│ └─────────────────────────────────────────────────────────────────────┘│
│ │
└─────────────────────────────────────────────────────────────────────────┘
UIHostingController integration pattern:
┌──────────────────────────────────────────────────────────────────────┐
│ │
│ UIKit World SwiftUI World │
│ │
│ UIViewController View (struct) │
│ │ ▲ │
│ │ addChild(hostingVC) │ │
│ │ view.addSubview(hostingVC.view) │ │
│ ▼ │ │
│ UIHostingController ─── rootView: ────────┘ │
│ │ │
│ │ Bridges: │
│ │ • UIKit layout (Auto Layout) ↔ SwiftUI layout │
│ │ • UIKit lifecycle ↔ SwiftUI lifecycle (.onAppear, etc.) │
│ │ • UIKit traits ↔ SwiftUI Environment │
│ │ │
│ │ To update SwiftUI from UIKit: │
│ │ hostingVC.rootView = NewView(newProps) │
│ │ │
└──────────────────────────────────────────────────────────────────────┘