All topics
Mobile · Learning hub

SwiftUI notes for developers

Master SwiftUI with a curated set of 1 developer notes — core concepts, patterns, and interview prep. Maintained by the DevRecall team.

Save this stack to your DevRecallTest yourself — SwiftUI quizMore Mobile notes
SwiftUI

SwiftUI Essentials

SwiftUI Essentials View Protocol & Composition Every SwiftUI screen is built from types conforming to the View protocol, whose single requirement is a computed

SwiftUI Essentials

View Protocol & Composition

Every SwiftUI screen is built from types conforming to the View protocol, whose single requirement is a computed body property describing what to render. Views are structs, not classes — they are cheap, immutable value types that SwiftUI recreates and diffs on every state change rather than mutating in place, which is why view initializers should stay side-effect free.

Composition is the core building block: instead of one large view, you break UI into small, focused views and combine them. SwiftUI recomputes only the parts of the view tree affected by a state change, so smaller subviews also help performance by narrowing what has to re-render.

struct ProfileScreen: View {
    let user: User

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            AvatarView(imageURL: user.avatarURL)
            NameAndTitle(name: user.name, title: user.title)
            StatsRow(followers: user.followers, following: user.following)
        }
        .padding()
    }
}

struct AvatarView: View {
    let imageURL: URL?

    var body: some View {
        AsyncImage(url: imageURL) { image in
            image.resizable().scaledToFill()
        } placeholder: {
            Circle().fill(.gray.opacity(0.2))
        }
        .frame(width: 64, height: 64)
        .clipShape(Circle())
    }
}

struct StatsRow: View {
    let followers: Int
    let following: Int

    var body: some View {
        HStack(spacing: 24) {
            Label("\(followers) followers", systemImage: "person.2")
            Label("\(following) following", systemImage: "person.crop.circle")
        }
        .font(.footnote)
        .foregroundStyle(.secondary)
    }
}

State Management

@State owns a small piece of local, view-private state and drives re-rendering when it changes — use it for things like a toggle or text field value that belongs to a single view. @Binding is not storage itself; it is a two-way reference to state owned somewhere else, letting a child view read and write a parent's @State without owning a copy.

For reference-type view models, @StateObject creates and owns an ObservableObject for the lifetime of the view (use it where the object is created), while @ObservedObject references one passed in from outside without owning its lifecycle — using @ObservedObject where @StateObject was needed can cause the object to be recreated unexpectedly on every parent re-render. @EnvironmentObject injects a shared object down an entire view subtree without threading it through every initializer, at the cost of a runtime crash if no ancestor actually provided one.

final class SessionViewModel: ObservableObject {
    @Published var isLoggedIn = false
    @Published var username = ""

    func logIn(username: String) {
        self.username = username
        isLoggedIn = true
    }
}

struct RootView: View {
    @StateObject private var session = SessionViewModel()

    var body: some View {
        ContentView()
            .environmentObject(session)
    }
}

struct ContentView: View {
    @EnvironmentObject var session: SessionViewModel
    @State private var searchText = ""

    var body: some View {
        if session.isLoggedIn {
            SearchField(text: $searchText)
        } else {
            LoginView()
        }
    }
}

struct SearchField: View {
    @Binding var text: String

    var body: some View {
        TextField("Search", text: $text)
            .textFieldStyle(.roundedBorder)
    }
}

Layout System

VStack, HStack, and ZStack are the primary layout containers — vertical, horizontal, and depth (overlapping) stacking respectively. Layout in SwiftUI flows top-down: a parent proposes a size to its children, each child chooses its own size within that proposal, and the parent then positions its children — modifiers like .frame() and .padding() participate in this negotiation rather than setting absolute pixel positions.

The order modifiers are applied in matters, because each one wraps the view in a new view with its own layout behavior. .padding().background(.blue) pads first, then draws the background around the padded area; .background(.blue).padding() draws the background first at the original size, then pads outside it — visually different results from the same two modifiers in a different order. GeometryReader gives access to the exact size and coordinate space a view was given, useful for proportional or coordinate-based layouts, but it always fills the space it's offered, which can surprise you if used carelessly inside a stack.

struct DashboardCard: View {
    var body: some View {
        ZStack(alignment: .bottomTrailing) {
            RoundedRectangle(cornerRadius: 16)
                .fill(.blue.gradient)

            GeometryReader { proxy in
                VStack(alignment: .leading, spacing: 8) {
                    Text("Revenue")
                        .font(.headline)
                    Text("$42,300")
                        .font(.system(size: proxy.size.width * 0.12, weight: .bold))
                }
                .padding()
                .foregroundStyle(.white)
            }

            Image(systemName: "chart.line.uptrend.xyaxis")
                .font(.title)
                .padding()
                .foregroundStyle(.white.opacity(0.6))
        }
        .frame(height: 160)
        .padding(.horizontal)
    }
}

Navigation

NavigationStack (replacing the older NavigationView) manages a stack of views tied to a path — either an implicit stack built from NavigationLink pushes, or an explicit, bindable array of values you control programmatically. Driving the path with your own @State array of typed route values, rather than only relying on NavigationLink destinations, is what makes deep linking and programmatic 'pop to root' or 'push three screens at once' straightforward.

enum Route: Hashable {
    case productDetail(id: String)
    case checkout
}

struct StoreView: View {
    @State private var path: [Route] = []

    var body: some View {
        NavigationStack(path: $path) {
            List(products) { product in
                NavigationLink(product.name, value: Route.productDetail(id: product.id))
            }
            .navigationDestination(for: Route.self) { route in
                switch route {
                case .productDetail(let id):
                    ProductDetailView(id: id) {
                        path.append(.checkout)
                    }
                case .checkout:
                    CheckoutView(onComplete: { path.removeAll() })
                }
            }
            .navigationTitle("Store")
        }
    }
}

Animations

Implicit animation via .animation(_:value:) tells SwiftUI to animate any visual change caused by that one value, while withAnimation wraps a state mutation so every resulting visual change across the affected views animates together — the more common choice once more than a single property is involved. Views entering or leaving the hierarchy (e.g. behind an if) use .transition() to describe how, but a transition only takes effect when the change itself happens inside a withAnimation block or an implicitly animated context.

struct LikeButton: View {
    @State private var isLiked = false

    var body: some View {
        Button {
            withAnimation(.spring(response: 0.35, dampingFraction: 0.6)) {
                isLiked.toggle()
            }
        } label: {
            Image(systemName: isLiked ? "heart.fill" : "heart")
                .foregroundStyle(isLiked ? .red : .gray)
                .scaleEffect(isLiked ? 1.2 : 1.0)
        }
    }
}

struct ToastBanner: View {
    let message: String?

    var body: some View {
        VStack {
            if let message {
                Text(message)
                    .padding()
                    .background(.black.opacity(0.85), in: Capsule())
                    .foregroundStyle(.white)
                    .transition(.move(edge: .top).combined(with: .opacity))
            }
            Spacer()
        }
        .animation(.easeOut(duration: 0.25), value: message)
    }
}

Common Pitfalls

  • Using @ObservedObject for a view model the view itself creates — that recreates the object (losing its state) on every parent re-render; use @StateObject at the point of creation instead.

  • Reaching for @EnvironmentObject as a default instead of explicit parameters or @Binding — it hides a view's real dependencies and crashes at runtime with 'No ObservableObject found' if an ancestor forgets to provide it.

  • Forgetting that modifier order changes behavior — .padding().background(...) and .background(...).padding() produce visually different results, since each modifier wraps the view in a new layout node.

  • Wrapping a GeometryReader around content that does not need it — GeometryReader always expands to fill the space offered to it, which can silently break the sizing of a stack it's placed inside.

  • Mutating @Published properties off the main thread — UI-driving state should be updated on the main actor, or SwiftUI's rendering can behave unpredictably.

  • Applying .transition() to a view without wrapping the state change that adds/removes it in withAnimation (or an implicit .animation modifier) — the transition is defined but never actually animates.

Keep your SwiftUI knowledge sharp.

Save this stack to your personal DevRecall — add your own notes, track what you're learning, and share what you know with the community.

Get started — free forever