Quick Answer

SwiftUI is Apple's declarative UI framework for iOS, iPadOS, macOS, and watchOS: you build screens as Swift structs conforming to the View protocol instead of dragging scenes onto a Storyboard. Each view describes what it looks like as a function of its current state, and SwiftUI automatically re-renders any view whose state changes — no outlets, no manual UI updates, and no separate .storyboard file to keep in sync with your code.

Why SwiftUI Doesn't Use Storyboards

UIKit apps traditionally split a screen across three separate pieces: a .storyboard file describing the visual layout, a UIViewController subclass containing your logic, and @IBOutlet properties wiring specific views in the storyboard to specific properties in the controller. Changing what a screen looks like meant opening Interface Builder, dragging elements around a canvas, and hoping the outlet connections didn't silently break — a broken outlet only shows up as a crash at runtime, not a compile error.

SwiftUI collapses all three pieces into one: a Swift struct that conforms to the View protocol and implements a single computed property, body, that returns a description of the UI. There's no separate visual file, no outlets, and no runtime wiring to break, because the "wiring" is just normal Swift code referencing normal Swift properties. When the data behind a view changes, SwiftUI recalculates body for that view and updates only the parts of the rendered UI that actually differ — conceptually the same idea as Compose's recomposition or React's re-render, applied to native iOS.

This also means version control gets a lot friendlier. Storyboard files are XML blobs that produce painful, hard-to-review merge conflicts when two people touch the same screen. A SwiftUI view is ordinary Swift source — it diffs and merges the same way any other code file does.

The App Entry Point and Your First View

Every SwiftUI app has exactly one type conforming to the App protocol, marked with @main, which replaces the old AppDelegate/SceneDelegate boilerplate for simple apps:

import SwiftUI

@main
struct MyFirstApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

struct ContentView: View {
    var body: some View {
        VStack {
            Text("Hello, Priodemy!")
                .font(.title)
                .padding()
            Text("Welcome to SwiftUI")
                .foregroundStyle(.secondary)
        }
    }
}

WindowGroup is a Scene that manages one or more windows showing the same content — on iOS that's just your app's single window; on iPadOS or macOS it can mean multiple windows of the same app. ContentView is a plain struct conforming to View, and its body returns some View — an opaque return type meaning "some concrete type that conforms to View, but I'm not telling you exactly which one," which is what lets you freely nest VStack, Text, and modifier calls without writing out increasingly nested generic type signatures by hand.

Modifiers like .font(.title) and .padding() don't mutate the view in place — each one returns a new, wrapped view value. That's why chain order matters here too, the same way it does with Compose's Modifier: .padding().background(.blue) gives you a blue box padded around the text, while .background(.blue).padding() gives you padding around an already-blue-background box that's tighter to the text.

Local State with @State and Two-Way Binding with @Binding

A plain struct can't hold mutable state that survives across re-renders on its own — structs are value types, and SwiftUI can recreate a view struct at any time. @State is a property wrapper that tells SwiftUI to store a piece of data outside the struct itself, in storage SwiftUI manages, and to re-invoke body whenever that value changes:

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

    var body: some View {
        VStack(spacing: 12) {
            Text("Count: \(count)")
                .font(.title2)
            Button("Increment") {
                count += 1
            }
            .buttonStyle(.borderedProminent)
        }
    }
}

@State should almost always be private — it's meant to be a view's own internal state, not something another view reaches in and mutates directly. When a child view needs to both read and write a value owned by a parent, you pass a Binding instead of a plain value, using the $ prefix to get a binding from a @State property:

struct ToggleRow: View {
    @Binding var isOn: Bool

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

struct SettingsView: View {
    @State private var notificationsEnabled = true

    var body: some View {
        ToggleRow(isOn: $notificationsEnabled)
    }
}

ToggleRow doesn't own isOn — it holds a live reference back to SettingsView's state, so flipping the toggle inside ToggleRow updates notificationsEnabled in the parent directly, and both views stay in sync automatically. This is the same state-hoisting idea Compose uses, just with the ownership and the two-way link made explicit through the Binding type instead of a plain callback function.

Sharing State Across Views with @Observable

@State is scoped to a single view and its children. For state that needs to live longer or be shared across a whole feature — a logged-in user's profile, a shopping cart — SwiftUI's current approach, from the Observation framework introduced in iOS 17, is the @Observable macro applied to a class:

@Observable
class CartModel {
    var items: [String] = []

    var itemCount: Int {
        items.count
    }

    func add(_ item: String) {
        items.append(item)
    }
}

struct CartBadge: View {
    var cart: CartModel

    var body: some View {
        Text("\(cart.itemCount) items")
    }
}

@Observable only works on classes, not structs — you want reference semantics here, so that multiple views can share the same underlying instance rather than each getting an independent copy. The macro automatically tracks which properties each view actually reads inside body, and only re-renders a view when one of the properties it specifically used changes, which is a more precise, less over-triggering mechanism than the older ObservableObject/@Published combination it replaces. You still inject a shared instance into the view hierarchy with @Environment so deeply nested views can reach it without threading it through every intermediate view's initializer:

ContentView()
    .environment(CartModel())

// deep inside the hierarchy:
struct CheckoutButton: View {
    @Environment(CartModel.self) private var cart

    var body: some View {
        Button("Checkout (\(cart.itemCount))") { /* ... */ }
    }
}

If you read older SwiftUI tutorials, you'll see ObservableObject, @Published, and @StateObject everywhere — that pattern still compiles and works, but @Observable is the current recommended approach for new code, since it needs less boilerplate and avoids re-rendering views that don't actually read the property that changed.

Layout: VStack, HStack, ZStack, and Spacer

SwiftUI's layout primitives mirror Compose's almost exactly, which makes sense since both are solving the same problem: VStack arranges children vertically, HStack arranges them horizontally, and ZStack layers them on top of each other along the z-axis — useful for a badge over an icon or text over an image.

struct ProfileRow: View {
    let name: String
    let role: String

    var body: some View {
        HStack {
            Circle()
                .fill(.blue)
                .frame(width: 44, height: 44)
            VStack(alignment: .leading) {
                Text(name).font(.headline)
                Text(role).font(.subheadline).foregroundStyle(.secondary)
            }
            Spacer()
            Image(systemName: "chevron.right")
                .foregroundStyle(.gray)
        }
        .padding()
    }
}

Spacer() is the mechanism SwiftUI uses for flexible space — instead of a "weight" or "flex-grow" property on the other elements, you insert an actual Spacer view into the stack, and it expands to consume all the room its siblings don't need, pushing the chevron icon here all the way to the trailing edge. This is a genuinely different mental model from Compose, where you'd typically use Modifier.weight() on the flexible element itself rather than adding a separate spacing element — worth knowing so you don't go looking for a weight modifier that doesn't exist in SwiftUI's stack APIs.

Displaying Data with List and ForEach

List is SwiftUI's equivalent of a scrolling table — like LazyColumn, it only renders rows that are actually visible, and it comes with platform-native behavior like swipe-to-delete and pull-to-refresh built in. To show a collection of your own model objects, they need to conform to Identifiable, which just means having a stable id property SwiftUI can use to track each row across updates:

struct Contact: Identifiable {
    let id = UUID()
    let name: String
}

struct ContactListView: View {
    let contacts: [Contact]

    var body: some View {
        List(contacts) { contact in
            Text(contact.name)
        }
    }
}

That stable id is not optional decoration — it's how SwiftUI figures out which row is which across re-renders, so that inserting a contact in the middle of the array animates as a single row insertion instead of SwiftUI concluding that every subsequent row "changed" because it re-indexed by position. This is the exact same correctness issue Compose's LazyColumn has with its key parameter, just enforced here at the type level through the protocol instead of being an optional argument you can forget to pass.

Screen-to-screen navigation in SwiftUI is built around NavigationStack, which replaced the older NavigationView and gives you a real, inspectable navigation path instead of an implicit stack you can only push and pop imperatively:

struct ContactsScreen: View {
    let contacts: [Contact]

    var body: some View {
        NavigationStack {
            List(contacts) { contact in
                NavigationLink(value: contact) {
                    Text(contact.name)
                }
            }
            .navigationDestination(for: Contact.self) { contact in
                Text("Details for \(contact.name)")
                    .navigationTitle(contact.name)
            }
            .navigationTitle("Contacts")
        }
    }
}

NavigationLink(value:) doesn't hardcode a destination view directly at the call site — it just declares "tapping this pushes a Contact value onto the stack," and a separate .navigationDestination(for:) modifier decides what view to show for that type. This separation is what makes deep linking and programmatic navigation practical: you can push a screen by appending a value to a NavigationPath from anywhere in your code, say after a push notification arrives, without needing a reference to the specific NavigationLink that would normally trigger it.

Frequently Asked Questions

Do I need to learn UIKit before learning SwiftUI? No. SwiftUI is a complete, independent framework for building UI, and Apple recommends it for new apps. Some advanced or highly custom interactions still drop down into UIKit, but you can build and ship a full app knowing only SwiftUI.
What's the actual difference between @State and @Binding? @State owns a piece of data inside a single view. @Binding doesn't own the data — it's a reference to a @State value owned by some other, usually parent, view, letting a child view read and write it as if it owned it, keeping both views in sync.
Is @Observable the same thing as the older ObservableObject? They solve the same problem — sharing state across views — but @Observable only re-renders views based on the exact properties they read, while ObservableObject with @Published re-renders any observing view whenever any published property changes. @Observable is the current recommended approach.
Can SwiftUI apps still run on older iOS versions? SwiftUI itself works back to iOS 13, but individual newer APIs, like @Observable from iOS 17 or NavigationStack from iOS 16, require a corresponding minimum deployment target. Check each API's availability before using it if you need to support older devices.
Why did changing my modifier order change how my view looks? Each SwiftUI modifier wraps the view in a new view rather than mutating it, so modifiers apply in the order you write them, top to bottom. .padding().background(.blue) pads first and then colors the padded area; reversing the order colors a tighter area first.
Do I need a Mac to build and test a SwiftUI app? Yes, in practice — Xcode, which is required to compile and run SwiftUI apps in the iOS Simulator or on a device, only runs on macOS.