What you'll learn
- Quick answer
- Why SwiftUI Doesn't Use Storyboards
- The App Entry Point and Your First View
- Local State with @State and Two-Way Binding with @Binding
- Sharing State Across Views with @Observable
- Layout: VStack, HStack, ZStack, and Spacer
- Displaying Data with List and ForEach
- Navigation with NavigationStack
- FAQ
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.
Navigation with NavigationStack
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.
