Skip to content
Commitwise
Menu
iOS Development

SwiftUI Basics

Build your first SwiftUI screens from views, modifiers and stacks, understand why modifier order matters and how layout decides sizes, and make a screen react to taps.

beginner

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

You add .padding() and .background(.blue) to a button, and the blue hugs the text instead of filling the button. You swap the two lines, and suddenly it’s right. SwiftUI isn’t being random: every modifier wraps the view before it, and once you see that, layout stops being guesswork.

New to some of these? Open what you need, or skip ahead.

Basics:What are structs and protocols?

A struct is a type that groups a few values together, like struct User { let name: String; let age: Int }. Structs are value types: assigning one to another variable makes a copy.

A protocol is a list of requirements a type promises to meet. View is a protocol: any struct that has a body property describing some UI counts as a view.

Basics:What is a computed property?

A computed property doesn’t store a value; it calculates one every time you read it:

struct Rectangle {
    let width: Double
    let height: Double
    var area: Double { width * height }   // computed on every read
}

A view’s body is a computed property: SwiftUI reads it whenever it needs to know what the view looks like.

Basics:What are lambdas and closures?

A lambda (Kotlin, Java) or closure (Swift) is a small function without a name that you can store in a variable or pass to another function:

Swift:   numbers.filter { $0 > 10 }           // $0 is the first parameter
Kotlin:  numbers.filter { it > 10 }           // it is the single parameter
Java:    numbers.stream().filter(n -> n > 10)

The code inside the braces (or after ->) runs later, when the function you passed it to decides to call it: once per number here, or when a button is tapped in UI code. A closure can also use variables from the code around it.

Part Not started

Views and modifiers

Think of ordering a coffee. You don’t walk behind the counter and operate the machine; you say “a large latte with oat milk”, and the barista makes it. If you change your mind, you describe the new drink.

SwiftUI is declarativeDeclarative UIA UI style where you describe what the screen should look like for the current data, and the framework updates the screen when the data changes.Open in glossary → in the same way. You describe what the screen should look like for the current data, and SwiftUI builds it. When the data changes, you don’t edit labels by hand: SwiftUI asks your code for a new description and updates the screen to match.

A SwiftUI View is a lightweight value that describes UI; it isn’t the UI. SwiftUI keeps its own long-lived tree (the attribute graph), diffs new descriptions against it, and renders through UIKit/AppKit or its own drawing. View structs are created and thrown away constantly, which is why they must be cheap and free of side effects.

body is declared @ViewBuilder and returns some View: an opaque type whose concrete form is a deeply nested generic like ModifiedContent<ModifiedContent<Text, _PaddingLayout>, _BackgroundStyleModifier<Color>>. That static type is what lets SwiftUI diff efficiently, and it’s why modifier order is part of the type.

Your first view

import SwiftUI

struct GreetingView: View {
    let name: String

    var body: some View {
        Text("Hello, \(name)!")
            .font(.title)
            .foregroundStyle(.blue)
    }
}

#Preview {
    GreetingView(name: "Ana")
}

Line by line:

Modifiers wrap views

Every modifier returns a new view that wraps the one before it. So the order you write them in is the order they’re applied, from the inside out:

// Blue fills the padding too: padding first, then the background behind it all
Text("Continue")
    .padding()
    .background(.blue)

// Blue only behind the text: the padding is added outside the blue
Text("Continue")
    .background(.blue)
    .padding()

Read a chain of modifiers like wrapping a present: .padding() puts the text in a slightly bigger box, and .background(.blue) paints whatever is inside its box. Paint first and then add the box, and the extra space stays unpainted.

Common views and modifiers

ViewWhat it shows
Text("Hi")Text
Image(systemName: "star.fill")An SF Symbol icon (thousands come with iOS)
Image("logo")An image from your asset catalog
Button("Save") { … }A tappable button; the closure runs on tap
Color.red, Rectangle(), Circle()Colors and shapes; they fill the space they’re given
ModifierEffect
.font(.headline)Text style; .title, .body, .caption… follow the user’s text size setting
.foregroundStyle(.secondary)Color of text and icons
.padding(), .padding(.horizontal, 24)Space around the view
.background(.yellow)Something drawn behind the view
.frame(width: 120, height: 44)A box of a given size around the view
.clipShape(.rect(cornerRadius: 12))Cuts the view to a shape

Some modifiers, like .font, also work on a container: VStack { … }.font(.caption) sets the font for every text inside it. Others, like .padding(), only affect the view they’re attached to.

Predict the output

How big is the yellow area?

Text("Hi")
    .frame(width: 200, height: 100)
    .background(.yellow)

What a modifier chain really is

Each modifier produces a ModifiedContent<Content, Modifier>. Some modifiers are layout wrappers (padding, frame, offset), some are environment writers (font, tint, foregroundStyle as a style), and some attach behavior (onTapGesture, task). Environment modifiers flow down to every descendant, which is why .font on a VStack works; layout modifiers only affect their own child.

Two consequences that show up in reviews:

  • .frame doesn’t resize the view; it proposes a size to its child and places the child inside a new box. Text still takes only the space it needs, centered by default.
  • if in a modifier chain changes identity. if highlighted { view.border(.red) } else { view } gives two branches with different types; SwiftUI treats them as two views, tearing down state and animating with insert/remove instead of a smooth change. Prefer an always-present modifier with a changing value: .border(highlighted ? .red : .clear).
Deep diveWhy body can contain if and forInterview favorite

body is implicitly marked with @ViewBuilder, a result builder. The compiler rewrites the statements inside it into calls like buildBlock(a, b), buildEither(first:) for if/else and buildOptional for if without else. Each produces a concrete generic type (TupleView<(Text, Image)>, _ConditionalContent<A, B>, Optional<A>).

That’s why body can list several views without a container, and why a plain for loop doesn’t work: a result builder needs a statically known shape, so collections go through ForEach, which is itself a view.

  • Views are cheap, short-lived value descriptions; body is a @ViewBuilder returning an opaque some View. No side effects in init or body.
  • Each modifier wraps the view in ModifiedContent: order is part of the type and of the result (.padding().background() ≠ .background().padding()).
  • Environment modifiers (.font, .tint) flow down to descendants; layout modifiers (.padding, .frame) affect only their child.
  • if around a modifier creates two identities; use a conditional value instead.

Check this part

  1. Which code shows a blue background that includes the padding?

  2. What does a view's body do?

  3. Why should if highlighted { v.border(.red) } else { v } be avoided?

  4. VStack { … }.font(.caption) changes the font of every Text inside. Why?

A view is a small struct whose body describes UI. Modifiers like .padding() and .background() each wrap the view in a new one, so their order changes the result.

Part Not started

Stacks and layout

Real screens combine views. Three stacks do most of the work:

struct ProductRow: View {
    var body: some View {
        HStack(spacing: 12) {                       // side by side
            Image(systemName: "cup.and.saucer.fill")
                .font(.largeTitle)
            VStack(alignment: .leading) {           // on top of each other, left-aligned
                Text("Flat white")
                    .font(.headline)
                Text("Double shot, steamed milk")
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }
            Spacer()                                // takes all the free space
            Text("€3.40")
                .bold()
        }
        .padding()
    }
}
  • HStack puts views in a row, VStack in a column, and ZStack on top of each other (like layers, for a badge over an icon).
  • Spacer() is an empty view that grows as much as it can. Here it pushes the price to the right edge.
  • alignment: .leading lines up the texts on their left edge; the default is .center. spacing: sets the gap between items.

Who decides the size?

Layout in SwiftUI is a short conversation between a parent and each child:

  1. The parent offers a size: “you can have up to 300 × 50 points”.
  2. The child picks its own size: a Text takes only what its words need; a Color or Spacer takes everything offered.
  3. The parent places the child inside its own space (centered, unless you set an alignment).

That’s why .frame(width: 200) on a Text doesn’t stretch the words: the frame offers 200 points, the text still takes only what it needs, and the frame centers it.

Layout is a three-step negotiation: the parent proposes a size (any dimension may be nil, meaning “your ideal size”, or .infinity), the child chooses its size, and the parent places it. A child’s choice is final; the parent can’t force it, only propose and position.

Stacks propose to their least flexible children first, so a Text is measured before a Spacer. When space runs out, priority goes by .layoutPriority, and Text truncates or wraps. .fixedSize() makes a view ignore the proposal and use its ideal size; .frame(maxWidth: .infinity) makes a wrapper that accepts any width offered.

Filling the width

To make a button as wide as the screen, give its content a flexible frame before the background:

Button {
    checkout()
} label: {
    Text("Pay now")
        .frame(maxWidth: .infinity)    // accept all the width offered
        .padding()
        .background(.blue)
        .foregroundStyle(.white)
        .clipShape(.rect(cornerRadius: 12))
}
.padding(.horizontal)

maxWidth: .infinity means “take as much width as the parent offers”, so the blue background, which comes after it, fills the whole row.

ZStack for layers

ZStack(alignment: .topTrailing) {
    Image(systemName: "bell.fill")
        .font(.largeTitle)
    Text("3")
        .font(.caption2.bold())
        .foregroundStyle(.white)
        .padding(5)
        .background(.red, in: .circle)
        .offset(x: 8, y: -8)
}

The first view in a ZStack is at the back; later ones are drawn on top. alignment: .topTrailing places the badge in the top-right corner, and .offset nudges it a little outward.

Tools beyond stacks

NeedTool
Same alignment across rows (labels and values in a form)Grid with GridRow (iOS 16+)
Pick the first layout that fits (horizontal, else vertical)ViewThatFits
Read the offered sizeGeometryReader (it takes all space offered, so it changes layout; prefer containerRelativeFrame or onGeometryChange on iOS 17/18)
A custom arrangement (flow layout of tags)The Layout protocol: sizeThatFits + placeSubviews
Long or dynamic listsLazyVStack / List; plain stacks create every child up front

A plain VStack inside a ScrollView builds all of its children immediately: fine for 30 rows, a memory and launch-time problem for 3,000. LazyVStack creates rows as they scroll into view, at the cost of less predictable sizing.

  • Layout: parent proposes, child chooses, parent places. A child’s size is final; .frame wraps and positions, it doesn’t stretch.
  • .frame(maxWidth: .infinity) before .background to fill a row; .fixedSize() to use the ideal size; .layoutPriority to decide who truncates.
  • Grid, ViewThatFits, the Layout protocol; LazyVStack for long content (a plain VStack builds every child).
  • GeometryReader takes all offered space and reshapes the layout; prefer newer size APIs where available.
HStack {
    Text(title).lineLimit(1).layoutPriority(1)   // keep the title, let others shrink
    Spacer(minLength: 8)
    Text(price).monospacedDigit()
}

ViewThatFits {                                   // horizontal if it fits, else vertical
    HStack { buttons }
    VStack { buttons }
}

ScrollView {
    LazyVStack { ForEach(items) { ItemRow(item: $0) } }
}

Check this part

  1. You want a price on the right edge of a row and a title on the left. What goes between them in the HStack?

  2. You add .frame(width: 300) to a short Text. What happens to the text?

  3. Who decides a view's final size in SwiftUI layout?

  4. A ScrollView shows 3,000 rows in a VStack and the screen opens slowly. The first fix?

VStack, HStack and ZStack arrange views; Spacer pushes them apart. A parent proposes a size, the child picks its own size, and the parent places it.

Part Not started

Making it react

A screen that never changes isn’t much of an app. To react to taps, a view needs state: a value SwiftUI keeps for it and watches for changes.

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

    var body: some View {
        Button {
            isLiked.toggle()
        } label: {
            Label(isLiked ? "Liked" : "Like",
                  systemImage: isLiked ? "heart.fill" : "heart")
        }
        .tint(isLiked ? .red : .gray)
    }
}

@State tells SwiftUI: “keep this value for me, and redraw this view when it changes”. When you tap, isLiked flips, SwiftUI reads body again, and the label, icon and color all follow from the new value. You never write “set the icon to a filled heart”: you describe what the button looks like for each value, and SwiftUI does the rest.

Without @State, the line isLiked.toggle() doesn’t even compile: a view struct can’t change its own plain properties. That’s on purpose, because SwiftUI throws view structs away all the time; state has to live somewhere it controls.

@State storage lives in SwiftUI’s graph, keyed by the view’s identity, not in the struct. Writes invalidate the views that read the value, and SwiftUI re-evaluates their body on the next update. Ownership, @Binding, @Observable models and identity are covered in State and Data Flow.

Showing, hiding and repeating

struct OrderView: View {
    let items = ["Flat white", "Croissant", "Orange juice"]
    @State private var showNote = false

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            ForEach(items, id: \.self) { item in
                Text("• \(item)")
            }
            Toggle("Add a note", isOn: $showNote)
            if showNote {
                Text("Please no sugar")
                    .italic()
            }
        }
        .padding()
    }
}
  • if inside body shows a view only when a condition is true.
  • ForEach repeats a view once per item. id: \.self tells SwiftUI to tell items apart by their value; for your own types you’ll usually make them Identifiable instead (see Lists and Navigation).
  • $showNote passes the toggle a connection to the state, so it can read and change it.

Practice

Build a profile card, step by step

  1. Start with the content. An Image(systemName: "person.crop.circle.fill") and two Texts for the name and job title.
  2. Arrange it. Put the texts in a VStack(alignment: .leading) and that next to the image in an HStack.
  3. Style it. .font(.largeTitle) on the image, .font(.headline) on the name, .foregroundStyle(.secondary) on the title.
  4. Box it. On the HStack: .padding(), then .background(.gray.opacity(0.15)), then .clipShape(.rect(cornerRadius: 16)). Padding first, so the grey includes it.
  5. Preview it. Add #Preview { ProfileCard() } and check it in light and dark mode from the canvas.

Predict the output

The user taps the button three times. What does it say?

struct TapCounter: View {
    var count = 0

    var body: some View {
        Button("Tapped \(count) times") {
            count += 1
        }
    }
}
Spot the bugA button with a thin blue line

Tap the line with the bug.

Text("Continue")
    .background(.blue)
    .padding()
    .foregroundStyle(.white)
    .clipShape(.rect(cornerRadius: 12))

Exerciseeasy

A coffee order card

Build a card for one drink: an icon, the drink’s name and price, and a size picker with three buttons, S, M and L. The selected size has a filled background; the price updates with the size (S €2.80, M €3.40, L €3.90).

›

There’s one piece of state: the selected size. The price is calculated from it, so it’s not state. A String like "M" is enough for the size.

›

Build the three buttons with ForEach(["S", "M", "L"], id: \.self). Each button sets the state to its own size, and its background depends on whether it’s the selected one. Put the frame and background on the button’s label (Button { … } label: { Text(option)… }), so the whole circle reacts to taps, not just the letter.

SolutionTry it yourself first
struct DrinkCard: View {
    @State private var size = "M"

    private let prices = ["S": 2.80, "M": 3.40, "L": 3.90]
    private var price: Double { prices[size] ?? 0 }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack {
                Image(systemName: "cup.and.saucer.fill")
                    .font(.largeTitle)
                Text("Flat white")
                    .font(.headline)
                Spacer()
                Text(price, format: .currency(code: "EUR"))
                    .bold()
            }
            HStack {
                ForEach(["S", "M", "L"], id: \.self) { option in
                    Button {
                        size = option
                    } label: {
                        Text(option)                         // style the label, so the whole circle is tappable
                            .frame(width: 44, height: 44)
                            .background(option == size ? Color.accentColor : Color.gray.opacity(0.2))
                            .foregroundStyle(option == size ? .white : .primary)
                            .clipShape(.circle)
                    }
                    .buttonStyle(.plain)
                }
            }
        }
        .padding()
        .background(.gray.opacity(0.1))
        .clipShape(.rect(cornerRadius: 16))
    }
}

One @State, one computed price, and every visual difference (background, text color, price) is described as a function of size. There’s no code that “selects” a button: tapping changes the state, and the screen follows.

Production puzzles

Predict the output

Where does SwiftUI place the word Subtitle?

VStack(alignment: .leading) {
    Text("Title")
        .font(.headline)
    Text("Subtitle")
        .frame(maxWidth: .infinity)
}
.padding()
Spot the bugProduct views counted forty times

Tap the line with the bug.

struct ProductScreen: View {
    let product: Product
    let analytics: Analytics

    var body: some View {
        analytics.track("product_viewed", id: product.id)
        return VStack {
            ProductHeader(product: product)
            PriceView(product: product)
        }
    }
}

Exercisemedium

A reusable card component

Your app has a dozen screens that wrap content in the same card: 16 points of padding, a secondary-system background, rounded corners and an optional title. Each screen copies the five modifiers, and they’ve drifted apart. Design a reusable API so a screen writes:

Card(title: "Delivery") {
    AddressView(address: order.address)
    Text("Arrives tomorrow")
}

and a single view can opt in with .cardStyle(). Where should the shared style live, and why is it a generic type rather than AnyView?

›

The content parameter is a closure marked @ViewBuilder, so callers can list several views, use if, and get a concrete type back.

›

A ViewModifier holds the padding, background and clip once. Card uses it, and a View extension exposes it as .cardStyle().

SolutionTry it yourself first
struct CardStyle: ViewModifier {
    func body(content: Content) -> some View {
        content
            .padding(16)
            .frame(maxWidth: .infinity, alignment: .leading)
            .background(Color(.secondarySystemBackground))
            .clipShape(.rect(cornerRadius: 16))
    }
}

extension View {
    func cardStyle() -> some View { modifier(CardStyle()) }
}

struct Card<Content: View>: View {
    let title: String?
    let content: Content

    init(title: String? = nil, @ViewBuilder content: () -> Content) {
        self.title = title
        self.content = content()
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            if let title {
                Text(title).font(.headline)
            }
            content
        }
        .cardStyle()
    }
}
  • Generic Content, not AnyView. The concrete type is kept, so SwiftUI can diff the card’s children structurally and keep their identity and state. AnyView erases the type: SwiftUI compares the wrapped types at runtime and replaces the subtree when they differ.
  • The content is built once in init and stored as a value, which is the usual SwiftUI convention for containers.
  • One modifier, one place to change. Design-system rules (dark mode, Dynamic Type, corner radius) live in CardStyle, and screens can’t drift.
  • @State storage lives in SwiftUI’s graph, keyed by identity; writes redraw the views that read it. See State and Data Flow.
  • if in body uses _ConditionalContent (branches are different identities); collections go through ForEach with stable ids.
  • body and init must be pure: SwiftUI calls them an unspecified number of times. Side effects go in .task(id:), .onAppear, .onChange(of:).
  • Reusable containers: a generic Content: View with @ViewBuilder, styles in a ViewModifier; avoid AnyView.
struct Row: View {
    let item: Item
    @State private var expanded = false

    var body: some View {
        VStack(alignment: .leading) {
            Button(item.title) { expanded.toggle() }
            if expanded { Text(item.details) }
        }
        .task(id: item.id) { await loadThumbnail(for: item) }   // side effect, not in body
    }
}

Check this part

  1. What does @State private var isLiked = false do?

  2. How do you show a view only when a condition is true?

  3. Where should a "screen viewed" analytics event be sent?

  4. Why prefer a generic Content: View over AnyView in a reusable container?

@State gives a view a value that SwiftUI keeps and watches; changing it redraws the view. if shows or hides views, ForEach repeats one for each item. body must stay free of side effects.

Key takeaways

  • A SwiftUI view is a struct whose body describes what to show; SwiftUI builds and updates the screen.
  • Modifiers wrap the view before them, so order matters: pad first, then paint.
  • HStack, VStack and ZStack arrange views; Spacer pushes them apart; .frame places a view in a box.
  • @State holds values that change; change the state and the screen follows.
  • Views are cheap value descriptions; modifier order is part of the static type and of the layout.
  • Layout: parent proposes, child chooses, parent places. .frame wraps and positions.
  • if branches are separate identities; use conditional values on one modifier when state must survive.
  • body and init must be pure. Reusable containers use generics and ViewModifier, not AnyView.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.