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 -- often 3x 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, @ObservedObject), 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
structProfileHeader: View {
var body: someView {
VStack(spacing: 12) {
Image(systemName: "person.circle.fill")
.font(.system(size: 80))
.foregroundColor(.blue)
Text("Priya Sharma")
.font(.title)
.fontWeight(.bold)
Text("iOS Developer")
.font(.subheadline)
.foregroundColor(.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:
structNotificationCard: View {
let title: Stringlet message: Stringlet time: Stringvar body: someView {
HStack(alignment: .top, spacing: 14) {
// Icon with badge overlay using ZStackZStack(alignment: .topTrailing) {
Image(systemName: "bell.fill")
.font(.title2)
.foregroundColor(.white)
.frame(width: 48, height: 48)
.background(Color.blue)
.clipShape(Circle())
Circle()
.fill(Color.red)
.frame(width: 14, height: 14)
}
// Text content stacked verticallyVStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.headline)
Text(message)
.font(.body)
.foregroundColor(.secondary)
.lineLimit(2)
}
Spacer()
Text(time)
.font(.caption)
.foregroundColor(.gray)
}
.padding()
.background(Color(.systemBackground))
.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(), .foregroundColor(), .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.
structModifierDemo: View {
var body: someView {
VStack(spacing: 30) {
// Padding THEN background: blue fills the padded areaText("Padding First")
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(8)
// Background THEN padding: blue only fills text areaText("Background First")
.background(Color.blue)
.foregroundColor(.white)
.padding()
.cornerRadius(8)
// Building a reusable button styleText("Get Started")
.font(.headline)
.foregroundColor(.white)
.padding(.horizontal, 32)
.padding(.vertical, 14)
.background(
LinearGradient(
colors: [.blue, .purple],
startPoint: .leading,
endPoint: .trailing
)
)
.cornerRadius(25)
.shadow(color: .blue.opacity(0.4), radius: 10, y: 5)
}
}
}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.
But picking the right property wrapper matters more than most people realize. @ObservedObject where @State would suffice means unnecessary re-renders and choppy scrolling. @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
structCounterView: View {
@State private var count = 0@State private var username = ""@State private var showingAlert = falsevar body: someView {
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.
@ObservedObject and ObservableObject: External Data Models
Data that outlives a single view -- user profiles, API results, app settings. Class conforming to ObservableObject, properties marked @Published:
import Foundation
classTaskStore: ObservableObject {
@Published var tasks: [TaskItem] = []
@Published var isLoading = falsefuncaddTask(title: String) {
let task = TaskItem(title: title, isComplete: false)
tasks.append(task)
}
functoggleComplete(task: TaskItem) {
if let index = tasks.firstIndex(where: { $0.id == task.id }) {
tasks[index].isComplete.toggle()
}
}
funcfetchTasks() async {
isLoading = true// Simulate a network requesttry? awaitTask.sleep(nanoseconds: 1_000_000_000)
tasks = [
TaskItem(title: "Review pull request", isComplete: false),
TaskItem(title: "Update dependencies", isComplete: true),
TaskItem(title: "Write unit tests", isComplete: false),
]
isLoading = false
}
}
structTaskItem: Identifiable {
let id = UUID()
var title: Stringvar isComplete: Bool
}@Published changes, views re-render. No reloadData(). No manual refresh. Change the model, the UI follows.
@EnvironmentObject: Dependency Injection
Like @ObservedObject but without threading it through every initializer. Inject at the top with .environmentObject(store), read anywhere below with @EnvironmentObject var store: TaskStore. Authentication, theming, feature flags. But if you forget the injection, it crashes at runtime with a message that does not tell you which environment object is missing. This has bitten me more than once.
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.
structRecipe: Identifiable, Hashable {
let id = UUID()
let name: Stringlet cuisine: Stringlet cookTime: Int// minuteslet ingredients: [String]
}
structRecipeListView: 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: someView {
NavigationStack(path: $path) {
List(recipes) { recipe inNavigationLink(value: recipe) {
HStack {
VStack(alignment: .leading) {
Text(recipe.name)
.font(.headline)
Text(recipe.cuisine)
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
Text("\(recipe.cookTime) min")
.font(.caption)
.foregroundColor(.gray)
}
}
}
.navigationTitle("Recipes")
.navigationDestination(for: Recipe.self) { recipe inRecipeDetailView(recipe: recipe)
}
}
}
}
structRecipeDetailView: View {
let recipe: Recipevar body: someView {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text(recipe.name)
.font(.largeTitle)
.fontWeight(.bold)
Label("\(recipe.cookTime) minutes",
systemImage: "clock")
.foregroundColor(.secondary)
Text("Ingredients")
.font(.title2)
.fontWeight(.semibold)
ForEach(recipe.ingredients, id: \.self) { item inLabel(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:
structTaskListView: View {
@ObservedObject var 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: someView {
List {
// Add new task sectionSection {
HStack {
TextField("New task...", text: $newTaskTitle)
Button(action: addTask) {
Image(systemName: "plus.circle.fill")
.foregroundColor(.blue)
.font(.title2)
}
.disabled(newTaskTitle.isEmpty)
}
}
// Pending tasks sectionSection("Pending (\(pendingTasks.count))") {
ForEach(pendingTasks) { task inTaskRow(task: task) {
store.toggleComplete(task: task)
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
deleteTask(task)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
// Completed tasks sectionSection("Completed (\(completedTasks.count))") {
ForEach(completedTasks) { task inTaskRow(task: task) {
store.toggleComplete(task: task)
}
}
}
}
.searchable(text: $searchText, prompt: "Search tasks")
.task { await store.fetchTasks() }
}
private funcaddTask() {
store.addTask(title: newTaskTitle)
newTaskTitle = ""
}
private funcdeleteTask(_ task: TaskItem) {
store.tasks.removeAll { $0.id == task.id }
}
}.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:
structAnimationShowcase: View {
@State private var isExpanded = false@State private var rotation: Double = 0@State private var showDetails = falsevar body: someView {
VStack(spacing: 30) {
// Implicit animation: reacts to state changeRoundedRectangle(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 withAnimationImage(systemName: "arrow.triangle.2.circlepath")
.font(.system(size: 50))
.rotationEffect(.degrees(rotation))
.onTapGesture {
withAnimation(.easeInOut(duration: 0.8)) {
rotation += 360
}
}
// Transitions: animate views appearing/disappearingButton(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))
.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 SwiftUIstructMapView: UIViewRepresentable {
let coordinate: CLLocationCoordinate2Dvar span: Double = 0.05funcmakeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.isScrollEnabled = true
mapView.isZoomEnabled = truereturn mapView
}
funcupdateUIView(_ 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 SwiftUIstructLocationScreen: View {
var body: someView {
VStack {
Text("San Francisco")
.font(.title)
.padding()
MapView(coordinate: CLLocationCoordinate2D(
latitude: 37.7749,
longitude: -122.4194
))
.frame(height: 300)
.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.