
Introduction
...and after writing tableView(_:cellForRowAt:) for the thousandth time, SwiftUI starts looking pretty good. But it is not a UIKit replacement — it is a different way of thinking about iOS UIs, and the transition is rougher than Apple's WWDC demos suggest.
The fundamental shift: your view is a function of state. You do not create views, configure them, lay them out, and update them when data changes. You describe what the screen looks like for a given state, and the framework handles everything else. If you have used React or Jetpack Compose, the idea is familiar. If you are coming from pure UIKit, the mental model change is the hard part. Not the syntax.
The code reduction on a typical screen is real — fewer lines, fewer files, less boilerplate. But SwiftUI has its own sharp edges, and they show up exactly when you try to do something UIKit made easy.
SwiftUI View Basics
A SwiftUI view is a struct. Not a class. The framework creates and destroys these constantly, so they need to be cheap — lightweight value types with no stored mutable state. Mutable state goes in property wrappers (@State, @Binding, @Environment), which the framework manages separately from the struct lifecycle. This is fundamentally different from UIViewController, where the controller owns its state and persists across view updates.
Text and Image
import SwiftUI
struct ProfileHeader: View {
var body: some View {
VStack(spacing: 12) {
Image(systemName: "person.circle.fill")
.font(.system(size: 80))
.foregroundStyle(.blue)
Text("Priya Sharma")
.font(.title)
.fontWeight(.bold)
Text("iOS Developer")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}No addSubview. No constraints. No frame calculations.
Layout Stacks: VStack, HStack, and ZStack
Three containers do most of the work. Vertical, horizontal, z-axis layering. Nest them:
struct NotificationCard: View {
let title: String
let message: String
let time: String
var body: some View {
HStack(alignment: .top, spacing: 14) {
// Icon with badge overlay using ZStack
ZStack(alignment: .topTrailing) {
Image(systemName: "bell.fill")
.font(.title2)
.foregroundStyle(.white)
.frame(width: 48, height: 48)
.background(Color.blue)
.clipShape(Circle())
Circle()
.fill(Color.red)
.frame(width: 14, height: 14)
}
// Text content stacked vertically
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.headline)
Text(message)
.font(.body)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer()
Text(time)
.font(.caption)
.foregroundStyle(.gray)
}
.padding()
.background(Color(.systemBackground))
.clipShape(.rect(cornerRadius: 12))
.shadow(radius: 2)
}
}A notification card that would be dozens of lines in UIKit. Spacer pushes the timestamp to the trailing edge. This nesting pattern is how most SwiftUI layouts work — and once you internalize it, going back to Auto Layout feels like writing assembly.
Modifiers and Styling
.font(), .foregroundStyle(), .padding() — these are modifiers. Each one wraps the view in a new view. Order matters, and this trips up everyone coming from UIKit where property order is irrelevant.
.padding() then .background(): background fills the padded area. Reverse them: background only covers the content. Modifiers wrap outward, like layers of an onion. Once that clicks, a whole category of "why does my layout look wrong" goes away.
struct ModifierDemo: View {
var body: some View {
VStack(spacing: 30) {
// Padding THEN background: blue fills the padded area
Text("Padding First")
.padding()
.background(Color.blue)
.foregroundStyle(.white)
.clipShape(.rect(cornerRadius: 8))
// Background THEN padding: blue only fills text area
Text("Background First")
.background(Color.blue)
.foregroundStyle(.white)
.padding()
.clipShape(.rect(cornerRadius: 8))
// Building a reusable button style
Text("Get Started")
.font(.headline)
.foregroundStyle(.white)
.padding(.horizontal, 32)
.padding(.vertical, 14)
.background(
LinearGradient(
colors: [.blue, .purple],
startPoint: .leading,
endPoint: .trailing
)
)
.clipShape(.rect(cornerRadius: 25))
.shadow(color: .blue.opacity(0.4), radius: 10, y: 5)
}
}
}The examples use the iOS 17 modifiers: .foregroundStyle() replaces the deprecated .foregroundColor(), and .clipShape(.rect(cornerRadius:)) replaces .cornerRadius(). For reusable styling, custom ViewModifier conformances. Encapsulate modifier chains. Apply in one place. Same idea as CSS classes, but type-safe.
State Management
In UIKit: label.text = "new value". Forget one update and the UI drifts out of sync with the data. You have probably shipped this bug. Everyone has.
SwiftUI inverts it. Your view is a function of state. Change the state, the framework re-renders the affected views. You never touch the UI directly. The mental model is closer to React than to UIKit, and if you have done any React work, you will feel at home faster than you expect.
Picking the right property wrapper matters. The examples here use the Observation framework (@Observable), which Apple recommends for apps targeting iOS 17 and later. @State stores values in framework-managed storage that persists across view struct recreations. It is not stored on the struct itself — the framework keeps it alive separately. This is the key difference from UIKit, where your view controller owns its state directly.
@State: Local View State
struct CounterView: View {
@State private var count = 0
@State private var username = ""
@State private var showingAlert = false
var body: some View {
VStack(spacing: 20) {
Text("Count: \(count)")
.font(.largeTitle)
.fontWeight(.bold)
HStack(spacing: 16) {
Button("Subtract") { count -= 1 }
.buttonStyle(.bordered)
Button("Add") { count += 1 }
.buttonStyle(.borderedProminent)
}
TextField("Enter your name", text: $username)
.textFieldStyle(.roundedBorder)
.padding(.horizontal)
Button("Greet Me") {
showingAlert = true
}
.disabled(username.isEmpty)
}
.alert("Hello, \(username)!", isPresented: $showingAlert) {
Button("OK", role: .cancel) { }
}
}
}$ prefix creates a binding — two-way connection. TextField reads and writes. The alert reads showingAlert and resets on dismissal. Without bindings, declarative UI cannot handle interactive controls. Period.
@Binding: Sharing State with Child Views
Child needs to read and write parent's state. @Binding. The child does not own the data. One source of truth.
struct ReminderSettings: View {
@State private var remindersOn = true
var body: some View {
Form {
// Pass a binding with $, not a copy of the value
ReminderToggle(isOn: $remindersOn)
Text(remindersOn ? "You will get reminders" : "Reminders are off")
}
}
}
struct ReminderToggle: View {
// Reads and writes the parent's state; the parent still owns it
@Binding var isOn: Bool
var body: some View {
Toggle("Task reminders", isOn: $isOn)
}
}@Observable: External Data Models
Data that outlives a single view — user profiles, API results, app settings. On iOS 17 and later, mark the class @Observable. Its stored properties are tracked automatically, with no @Published needed:
import Observation
// iOS 17+: @Observable tracks every stored property automatically.
// @MainActor keeps all UI-facing mutations on the main thread.
@MainActor
@Observable
final class TaskStore {
var tasks: [TaskItem] = []
var isLoading = false
var showCompleted = true
func addTask(title: String) {
let task = TaskItem(title: title, isComplete: false)
tasks.append(task)
}
func toggleComplete(task: TaskItem) {
if let index = tasks.firstIndex(where: { $0.id == task.id }) {
tasks[index].isComplete.toggle()
}
}
func fetchTasks() async {
isLoading = true
// Simulate a network request
try? await Task.sleep(for: .seconds(1))
tasks = [
TaskItem(title: "Review pull request", isComplete: false),
TaskItem(title: "Update dependencies", isComplete: true),
TaskItem(title: "Write unit tests", isComplete: false),
]
isLoading = false
}
}
struct TaskItem: Identifiable {
let id = UUID()
var title: String
var isComplete: Bool
}Change a property, and only the views that read that property re-render. No reloadData(). No manual refresh. @MainActor on the class keeps every mutation on the main thread, which Swift 6 strict concurrency checks expect for UI state. The async fetchTasks() then updates tasks safely.
Ownership is the part to get right. The view that creates the object holds it in @State, so it survives view struct recreation. Views that receive it from a parent take a plain property, or @Bindable when they need $ bindings to its properties.
Targeting iOS 16 or earlier? Use the older Combine-based model: a class conforming to ObservableObject with @Published properties. The view that creates it owns it with @StateObject, and children receive it with @ObservedObject. Do not create an object inside a view with @ObservedObject var store = TaskStore(): the object is recreated whenever the parent redraws and its state is lost. That is what @StateObject is for.
@Environment: Dependency Injection
Share an object without threading it through every initializer. Inject at the top with .environment(store), read anywhere below with @Environment(TaskStore.self) private var store. Authentication, theming, feature flags. If you forget the injection, the app crashes at runtime when a view reads it, so inject at the root. (Pre-iOS 17 code uses .environmentObject(store) and @EnvironmentObject for the same job.)
import SwiftUI
@main
struct TasksApp: App {
// The app creates the store, so it owns it: @State, not a plain property
@State private var store = TaskStore()
var body: some Scene {
WindowGroup {
NavigationStack {
TaskListView(store: store)
.navigationTitle("Tasks")
}
.environment(store)
}
}
}
// Any view below can read the store without an initializer parameter
struct TaskSummary: View {
@Environment(TaskStore.self) private var store
var body: some View {
Text("\(store.tasks.filter { !$0.isComplete }.count) tasks left")
}
}
// @Bindable turns an observable object's properties into bindings
struct TaskFilterToggle: View {
@Bindable var store: TaskStore
var body: some View {
Toggle("Show completed", isOn: $store.showCompleted)
}
}Navigation and NavigationStack
SwiftUI's navigation API has been rewritten twice. The current version, NavigationStack (iOS 16+), is the first one that actually works for real apps. Deep linking, stack resets, pushing multiple views at once — all through a typed path.
struct Recipe: Identifiable, Hashable {
let id = UUID()
let name: String
let cuisine: String
let cookTime: Int // minutes
let ingredients: [String]
}
struct RecipeListView: View {
@State private var path = NavigationPath()
let recipes = [
Recipe(name: "Pasta Carbonara", cuisine: "Italian",
cookTime: 25, ingredients: ["Spaghetti", "Eggs", "Pancetta"]),
Recipe(name: "Chicken Tikka", cuisine: "Indian",
cookTime: 40, ingredients: ["Chicken", "Yogurt", "Spices"]),
Recipe(name: "Sushi Roll", cuisine: "Japanese",
cookTime: 60, ingredients: ["Rice", "Nori", "Fish"]),
]
var body: some View {
NavigationStack(path: $path) {
List(recipes) { recipe in
NavigationLink(value: recipe) {
HStack {
VStack(alignment: .leading) {
Text(recipe.name)
.font(.headline)
Text(recipe.cuisine)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Text("\(recipe.cookTime) min")
.font(.caption)
.foregroundStyle(.gray)
}
}
}
.navigationTitle("Recipes")
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetailView(recipe: recipe)
}
}
}
}
struct RecipeDetailView: View {
let recipe: Recipe
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text(recipe.name)
.font(.largeTitle)
.fontWeight(.bold)
Label("\(recipe.cookTime) minutes",
systemImage: "clock")
.foregroundStyle(.secondary)
Text("Ingredients")
.font(.title2)
.fontWeight(.semibold)
ForEach(recipe.ingredients, id: \.self) { item in
Label(item, systemImage: "circle.fill")
.font(.body)
}
}
.padding()
}
.navigationTitle(recipe.cuisine)
.navigationBarTitleDisplayMode(.inline)
}
}NavigationLink(value:) associates a value. .navigationDestination(for:) maps the type to a view. The old API embedded the destination inside the link, which made programmatic navigation a mess. Now you append a Recipe to path from anywhere — a button action, a deep link handler, a notification response — and the stack pushes the right view.
Tab-based apps: wrap each NavigationStack in a TabView. Independent stacks per tab.
Lists, ForEach, and Dynamic Content
In UIKit: UITableViewDataSource, UITableViewDelegate, UISearchResultsUpdating. numberOfSections, cellForRowAt, trailingSwipeActionsConfigurationForRowAt. Hundreds of lines for a list with sections and swipe-to-delete.
SwiftUI's List does all of it. Still uses cell recycling. Sections, swipe actions, search:
struct TaskListView: View {
// Passed in by the parent that owns it; a plain property is enough
// for reading an @Observable object
let store: TaskStore
@State private var newTaskTitle = ""
@State private var searchText = ""
var pendingTasks: [TaskItem] {
store.tasks
.filter { !$0.isComplete }
.filter { searchText.isEmpty || $0.title.localizedCaseInsensitiveContains(searchText) }
}
var completedTasks: [TaskItem] {
store.tasks
.filter { $0.isComplete }
.filter { searchText.isEmpty || $0.title.localizedCaseInsensitiveContains(searchText) }
}
var body: some View {
List {
// Add new task section
Section {
HStack {
TextField("New task...", text: $newTaskTitle)
Button(action: addTask) {
Image(systemName: "plus.circle.fill")
.foregroundStyle(.blue)
.font(.title2)
}
.disabled(newTaskTitle.isEmpty)
.accessibilityLabel("Add task")
}
}
// Pending tasks section
Section("Pending (\(pendingTasks.count))") {
ForEach(pendingTasks) { task in
TaskRow(task: task) {
store.toggleComplete(task: task)
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
deleteTask(task)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
// Completed tasks section
if store.showCompleted {
Section("Completed (\(completedTasks.count))") {
ForEach(completedTasks) { task in
TaskRow(task: task) {
store.toggleComplete(task: task)
}
}
}
}
}
.searchable(text: $searchText, prompt: "Search tasks")
.task { await store.fetchTasks() }
}
private func addTask() {
store.addTask(title: newTaskTitle)
newTaskTitle = ""
}
private func deleteTask(_ task: TaskItem) {
store.tasks.removeAll { $0.id == task.id }
}
}
// One row: tap anywhere to toggle completion
struct TaskRow: View {
let task: TaskItem
let onToggle: () -> Void
var body: some View {
Button(action: onToggle) {
HStack {
Image(systemName: task.isComplete ? "checkmark.circle.fill" : "circle")
.foregroundStyle(task.isComplete ? Color.green : Color.secondary)
Text(task.title)
.strikethrough(task.isComplete)
.foregroundStyle(task.isComplete ? Color.secondary : Color.primary)
}
}
.buttonStyle(.plain)
}
}.searchable adds a native search bar. One modifier. .task runs async work on appear. .swipeActions declares gestures inline.
ForEach is not a Swift for loop. It is a view builder. Items must be Identifiable — get the identity wrong and you get broken animations, stale views, and bugs that only surface with specific data patterns. And you will spend an hour wondering why a row shows the wrong data before you realize two items have the same ID.
Animations and Transitions
One modifier. That is the whole API.
Wrap a state change in withAnimation, or attach .animation to a view. SwiftUI interpolates between the before and after states:
struct AnimationShowcase: View {
@State private var isExpanded = false
@State private var rotation: Double = 0
@State private var showDetails = false
var body: some View {
VStack(spacing: 30) {
// Implicit animation: reacts to state change
RoundedRectangle(cornerRadius: isExpanded ? 20 : 50)
.fill(isExpanded ? Color.blue : Color.orange)
.frame(
width: isExpanded ? 300 : 100,
height: isExpanded ? 200 : 100
)
.animation(.spring(response: 0.5, dampingFraction: 0.6), value: isExpanded)
.onTapGesture { isExpanded.toggle() }
// Explicit animation with withAnimation
Image(systemName: "arrow.triangle.2.circlepath")
.font(.system(size: 50))
.rotationEffect(.degrees(rotation))
.onTapGesture {
withAnimation(.easeInOut(duration: 0.8)) {
rotation += 360
}
}
// Transitions: animate views appearing/disappearing
Button(showDetails ? "Hide Details" : "Show Details") {
withAnimation(.easeInOut) {
showDetails.toggle()
}
}
if showDetails {
Text("Here are some extra details that slide in from the bottom and fade at the same time.")
.padding()
.background(Color.blue.opacity(0.1))
.clipShape(.rect(cornerRadius: 12))
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.padding()
}
}.spring for physics-based bounce. .transition for insert/remove animations. .combined(with:) to compose them.
If an animation is not working, the state change is not inside withAnimation and there is no .animation modifier. That is almost always it. SwiftUI needs before and after values to interpolate between. No values, no animation.
Custom curves: conform to Animatable. Continuous animations (progress indicators, clocks): TimelineView.
Integrating with UIKit
SwiftUI does not cover everything. Third-party libraries, components with no SwiftUI equivalent, edge cases where you need finer control. The interop exists for these.
UIViewRepresentable wraps a UIKit view. Two methods: makeUIView creates it, updateUIView updates it when SwiftUI state changes:
import SwiftUI
import UIKit
import MapKit
// Wrapping a UIKit view for use in SwiftUI
struct MapView: UIViewRepresentable {
let coordinate: CLLocationCoordinate2D
var span: Double = 0.05
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.isScrollEnabled = true
mapView.isZoomEnabled = true
return mapView
}
func updateUIView(_ mapView: MKMapView, context: Context) {
let region = MKCoordinateRegion(
center: coordinate,
span: MKCoordinateSpan(
latitudeDelta: span,
longitudeDelta: span
)
)
mapView.setRegion(region, animated: true)
// Add a pin annotation
mapView.removeAnnotations(mapView.annotations)
let pin = MKPointAnnotation()
pin.coordinate = coordinate
mapView.addAnnotation(pin)
}
}
// Using the wrapped view in SwiftUI
struct LocationScreen: View {
var body: some View {
VStack {
Text("San Francisco")
.font(.title)
.padding()
MapView(coordinate: CLLocationCoordinate2D(
latitude: 37.7749,
longitude: -122.4194
))
.frame(height: 300)
.clipShape(.rect(cornerRadius: 16))
.padding()
}
}
}UIHostingController goes the other direction — SwiftUI view inside UIKit. This is how most teams adopt SwiftUI: new screens in SwiftUI, old screens stay UIKit, gradual migration. No rewrite.
Gotchas: UIKit views inside SwiftUI do not auto-size. You may need explicit frames or intrinsicContentSize. Use a Coordinator for delegates and callbacks. Watch for retain cycles when coordinators hold closures.
UIViewControllerRepresentable wraps full view controllers. UIImagePickerController, SFSafariViewController, whatever you need.
What I Wish I Knew When I Started
Do not force UIKit patterns into SwiftUI. If you are writing UIHostingController more than twice, you are probably thinking about it wrong. Delegation does not belong here. Manual lifecycle management does not belong here. Imperative view updates do not belong here.
Think of your UI as a function of state. Start using SwiftUI for new screens. Wrap UIKit for the gaps. Migrate old screens when it makes sense, not all at once.
And if your designer gives you a pixel-perfect spec, SwiftUI will fight you. It is layout-first, not pixel-first.