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

BasicViews.swift
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:

StackLayouts.swift
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.

ModifierOrder.swift
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

StateExample.swift
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.

BindingExample.swift
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:

TaskStore.swift
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.)

TasksApp.swift
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)
    }
}

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.

NavigationExample.swift
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:

DynamicList.swift
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:

AnimationDemo.swift
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:

UIKitBridge.swift
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.

AS

Anurag Sinha

Founder & Lead Developer

Anurag is a full stack developer and self-taught programmer from India with over five years of experience building web applications, APIs, and cloud-native systems. He researches, writes and reviews every tutorial on Codertronix, and updates them when the tools change.