How to buildOctober 11, 202610 min read
Mobile DevelopmentStep 8 of 12

iOS with SwiftUI: your first screen

In short

A SwiftUI screen is made of structs that conform to the View protocol: the body property describes the UI, and SwiftUI updates it when data in @State or in an @Observable model changes. You need a Mac and Xcode (currently Xcode 27 with the iOS 27 SDK and Swift 6.4); Swift models and logic can be tested off the Mac, but SwiftUI itself builds only in Xcode.

TutorialLevel: BeginnerSwift & SwiftUIPractice: ~1.5–3 h

After this you will be able to

  • Describe data in Swift with a struct, Optionals and extensions, and cover the logic with Swift Testing
  • Build a view from VStack, HStack, ZStack and modifiers, and explain why their order matters
  • Keep state in @State, pass it through @Binding and move data into an @Observable model
  • Show data in a List and navigate to a detail screen with NavigationStack

Verified with

Swift 6.4Swift Testing 6.4Xcode 27iOS 27 SDKiOS 17+

Checked against docs: October 11, 2026

Contents
  1. 01What you need for iOS development: a Mac, Xcode, an Apple Account
  2. 02Swift in brief: let/var, struct, Optional, closures
  3. 03View and body: stacks and modifiers
  4. 04State: @State and @Binding
  5. 05An @Observable model
  6. 06Lists and navigation: List and NavigationStack
  7. 07Previews, the simulator, tests and practice

What you need for iOS development: a Mac, Xcode, an Apple Account

By the end of this lesson you will have an iOS screen listing a studio's services: rows with a letter avatar, a description and a label, a detail screen with an "Add to request" button, and a bottom bar that reads "In request: 3 services". Along the way we cover SwiftUI fundamentals: View and body, stacks and modifiers, @State and @Binding, an @Observable model, List and NavigationStack. The same screen is built for Android in the Jetpack Compose lesson, so the two are easy to compare.

Computer

Requirement
a Mac with macOS Tahoe 26.6 or later
Notes
as listed for Xcode 27 in Apple's version table

Xcode

Requirement
27
Notes
includes the iOS 27 SDK and the Swift 6.4 compiler; Xcode 27.1 was a release candidate at the time of checking

Language

Requirement
Swift 6.4
Notes
Swift 6 language mode with strict concurrency checking

Minimum iOS

Requirement
iOS 17
Notes
because of @Observable; Mutex in the test needs iOS 18

Apple Account

Requirement
to run on your own iPhone
Notes
Xcode registers the device and creates a signing profile

Apple Developer Program

Requirement
to publish on the App Store
Notes
see the guide to publishing in the stores

SwiftUI is Apple's declarative UI framework: you describe how the screen looks for the current data, and the system updates the affected parts when the data changes. It exists only on Apple platforms. The Swift language itself also runs on Linux and Windows, so the model and logic can be written and tested anywhere, but the screen is built only in Xcode on a Mac.

Swift in brief: let/var, struct, Optional, closures

  1. Create the project

    File → New → Project → App (or App from the New Project menu in the welcome window). Interface: SwiftUI, Language: Swift, name: Services.

  2. Set the Team and Organization Identifier

    Xcode builds the bundle identifier — the app's unique name in the system — from the organization identifier. The Team is needed for signing when you run on a device.

  3. Add the model file

    Add a new Swift file (File → New) named Service.swift. It contains no SwiftUI at all.

Services/Service.swift
struct Service: Identifiable, Hashable {
    let id: Int
    let title: String
    let summary: String
    var badge: String? = nil // not every service has a label, hence Optional
}

extension Service {
    static let samples: [Service] = [
        Service(id: 1, title: "Company website", summary: "Service pages, request form, SEO basics", badge: "Popular"),
        Service(id: 2, title: "Mobile app", summary: "Native iPhone app"),
        Service(id: 3, title: "CRM system", summary: "Clients, deals and tasks in one place"),
        Service(id: 4, title: "Integrations", summary: "Connecting the site to 1C, payments and messengers"),
        Service(id: 5, title: "Website audit", summary: "Speed, SEO and accessibility", badge: "New"),
    ]
}

extension Set {
    /// Removes the element if it is there, otherwise inserts it.
    mutating func toggle(_ member: Element) {
        if contains(member) {
            remove(member)
        } else {
            insert(member)
        }
    }
}

/// "In request: 1 service", "… 3 services".
func selectedLabel(count: Int) -> String {
    if count == 0 { return "Choose services" }
    let word = count == 1 ? "service" : "services"
    return "In request: \(count) \(word)"
}
The model, sample data, a Set extension and the pluralization function. Checked with swift build and swift test.

let / var

What it does
let is a constant that cannot change once set; var is a variable
Where in the code
let id: Int, var badge

struct

What it does
a value type: copied on assignment and when passed. A memberwise initializer is generated automatically
Where in the code
struct Service

String?

What it does
an Optional: either a value or nil
Where in the code
var badge: String? = nil

if let

What it does
safely unwraps an Optional
Where in the code
if let badge = service.badge

protocols

What it does
Identifiable provides an id for lists; Hashable is required for value-based navigation
Where in the code
Service: Identifiable, Hashable

extension, mutating

What it does
adds methods to an existing type; mutating allows changing the value
Where in the code
extension Set { mutating func toggle }

switch + where

What it does
pattern matching on a tuple with conditions
Where in the code
choosing the word form

Why is Service a struct and not a class? In Swift, structs and enums are value types: a modified copy does not affect the original. The structIsCopiedOnAssignment test below checks exactly that. For data that simply describes "what there is", a struct is the natural choice. Classes are reference types: two variables point to the same object. We will need a class for the shared screen model that several views read.

A closure is an unnamed function you can pass around as a value. SwiftUI uses them everywhere: a button action Button { … }, a list row List(…) { service in … }, a filter condition filter { … }. Force unwrapping an Optional with ! triggers a runtime error when the value is nil, so this lesson never uses it — only if let.

View and body: stacks and modifiers

A SwiftUI screen is built from structs that conform to the View protocol. The only requirement is a computed body property that returns other views. some View means "a concrete type the compiler knows, but you do not have to spell it out". The View protocol is isolated to the main actor (@MainActor), so all UI code runs on the main thread.

Services/ServiceRow.swift
import SwiftUI

struct ServiceRow: View {
    let service: Service
    let isSelected: Bool

    var body: some View {
        HStack(spacing: 16) {
            ZStack {
                Circle()
                    .fill(.tint)
                Text(service.title.prefix(1))
                    .font(.headline)
                    .foregroundStyle(.white)
            }
            .frame(width: 40, height: 40)

            VStack(alignment: .leading, spacing: 4) {
                Text(service.title)
                    .font(.headline)
                Text(service.summary)
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
                if let badge = service.badge {
                    Text(badge)
                        .font(.caption)
                        .foregroundStyle(.tint)
                }
            }

            Spacer()

            Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
                .foregroundStyle(isSelected ? Color.accentColor : Color.secondary)
                .accessibilityHidden(true)
        }
        .padding(.vertical, 4)
        .contentShape(Rectangle())
        .accessibilityElement(children: .combine)
        .accessibilityAddTraits(isSelected ? .isSelected : [])
    }
}

#Preview {
    List {
        ServiceRow(service: Service.samples[0], isSelected: true)
        ServiceRow(service: Service.samples[1], isSelected: false)
    }
}
SwiftUI code: checked against Apple's documentation but not compiled — SwiftUI is available only in Xcode on a Mac.

HStack

Layout
children side by side
In the row
avatar, text, checkmark

VStack(alignment: .leading)

Layout
children top to bottom, aligned to the leading edge
In the row
title, description, label

ZStack

Layout
children layered on top of each other
In the row
a letter on top of a circle

Spacer()

Layout
takes the free space
In the row
pushes the checkmark to the trailing edge

A modifier is a method such as .font(.headline) or .padding(). Apple's documentation describes the mechanism: a modifier wraps the view in a new view with the given characteristics. Hence the key rule: order matters. .frame(width: 40, height: 40) is applied to the ZStack, so the whole avatar gets the size. With .padding().background(.bar) the background covers the padding too; with .background(.bar).padding() it covers only the content.

  • .foregroundStyle(.secondary) and .tint are system styles rather than hardcoded colors, so the row adapts to dark mode and the app's accent color.
  • .contentShape(Rectangle()) makes the whole row tappable, including the empty space from Spacer.
  • .accessibilityElement(children: .combine) merges the row's texts into one VoiceOver element, and .accessibilityAddTraits(.isSelected) announces that the service is selected. The checkmark icon is hidden from VoiceOver so the state is not repeated.
  • #Preview shows the view on the Xcode canvas next to the code, without running the app.

State: @State and @Binding

View structs are recreated on every update, so a plain property cannot "remember" a selection. @State hands storage over to SwiftUI: the value lives while the view is on screen, and when it changes SwiftUI updates the parts of the hierarchy that depend on it. Apple recommends declaring state private, in the highest view that needs it, with the initial value right in the declaration.

Services/ServicesView.swift
import SwiftUI

struct ServicesView: View {
    @State private var selectedIDs: Set<Int> = []

    var body: some View {
        List(Service.samples) { service in
            Button {
                selectedIDs.toggle(service.id)
            } label: {
                ServiceRow(service: service, isSelected: selectedIDs.contains(service.id))
            }
            .buttonStyle(.plain)
        }
        .safeAreaInset(edge: .bottom) {
            Text(selectedLabel(count: selectedIDs.count))
                .font(.headline)
                .padding()
        }
    }
}
The first version of the screen: the selection lives in @State, and tapping a row toggles the service. We will replace this file later. SwiftUI code: checked against Apple's documentation but not compiled — SwiftUI is available only in Xcode on a Mac.

Compare this with Kotlin: there we returned a new set because Compose does not see changes inside a mutable collection. In Swift, Set is a value type, and selectedIDs.toggle(…) writes a whole new value into @State, which SwiftUI notices.

If a child view only needs to read state, pass a plain value — like isSelected in ServiceRow. If it also needs to change it, pass a @Binding: a reference to someone else's state that can be read and written. You get a binding with the $ prefix: $selectedIDs.

Services/ServiceDetailView.swift
import SwiftUI

struct ServiceDetailView: View {
    let service: Service
    @Binding var selectedIDs: Set<Int>

    private var isSelected: Bool { selectedIDs.contains(service.id) }

    var body: some View {
        Form {
            Section {
                Text(service.summary)
                if let badge = service.badge {
                    LabeledContent("Label", value: badge)
                }
            }
            Section {
                Button(isSelected ? "Remove from request" : "Add to request") {
                    selectedIDs.toggle(service.id)
                }
            }
        }
        .navigationTitle(service.title)
    }
}

#Preview {
    @Previewable @State var selectedIDs: Set<Int> = [1]
    NavigationStack {
        ServiceDetailView(service: Service.samples[0], selectedIDs: $selectedIDs)
    }
}
The detail screen changes the selection through a @Binding, and @Previewable creates the state for the preview. SwiftUI code: checked against Apple's documentation but not compiled — SwiftUI is available only in Xcode on a Mac.

An @Observable model

While the selection lives inside the view, other screens cannot see it and the logic is hard to test. Let us move the data into a model class. The @Observable macro (the Observation framework, iOS 17+) makes the class observable at compile time: SwiftUI records which properties body read and updates the view only when those change. Apple explicitly warns that conforming to the Observable protocol by hand is not enough — you need the macro.

Services/ServicesModel.swift
import Observation

@Observable
final class ServicesModel {
    var services: [Service] = Service.samples
    var selectedIDs: Set<Int> = []

    var label: String { selectedLabel(count: selectedIDs.count) }

    func isSelected(_ id: Int) -> Bool {
        selectedIDs.contains(id)
    }

    func toggle(_ id: Int) {
        selectedIDs.toggle(id)
    }
}
The screen model. It compiles and is tested on Linux: the Observation framework ships with Swift.

class Model: ObservableObject

Now (Observation)
@Observable final class Model

@Published var on every property

Now (Observation)
plain var properties are observed automatically

@StateObject in the owner

Now (Observation)
@State private var model = Model()

the view updated when any @Published property changed

Now (Observation)
it updates only if body read the changed property

The computed label property is tracked too: it reads selectedIDs, so the bar with the label updates on every selection. The viewsAreNotifiedAboutChanges test checks this with withObservationTracking — the same function SwiftUI's tracking is built on.

Lists and navigation: List and NavigationStack

List shows rows from a collection. If the elements are Identifiable, the list tells them apart by id — no key needed. NavigationStack (iOS 16+) holds a stack of screens: NavigationLink(value:) pushes a value onto the stack, and navigationDestination(for:) says which screen to show for values of that type. The value type must be Hashable, which is why Service conforms to it.

Services/ServicesView.swift
import SwiftUI

struct ServicesView: View {
    @State private var model = ServicesModel()

    var body: some View {
        NavigationStack {
            List(model.services) { service in
                NavigationLink(value: service) {
                    ServiceRow(service: service, isSelected: model.isSelected(service.id))
                }
            }
            .navigationTitle("Services")
            .navigationDestination(for: Service.self) { service in
                ServiceDetailView(service: service, selectedIDs: $model.selectedIDs)
            }
            .safeAreaInset(edge: .bottom) {
                Text(model.label)
                    .font(.headline)
                    .frame(maxWidth: .infinity)
                    .padding()
                    .background(.bar)
            }
        }
    }
}

#Preview {
    ServicesView()
}
The final screen: the model in @State, a list of links, a detail screen and a bottom bar. SwiftUI code: checked against Apple's documentation but not compiled — SwiftUI is available only in Xcode on a Mac.
Services/ServicesApp.swift
import SwiftUI

@main
struct ServicesApp: App {
    var body: some Scene {
        WindowGroup {
            ServicesView()
        }
    }
}
The app entry point: @main and a WindowGroup scene. Xcode's App template creates this file for you. SwiftUI code: checked against Apple's documentation but not compiled — SwiftUI is available only in Xcode on a Mac.
  • @State private var model = ServicesModel() owns the model; this is exactly how Apple shows storing an @Observable object. Child views get the model or parts of it as parameters.
  • $model.selectedIDs is a binding to a property of the object stored in @State. The detail screen receives a @Binding and changes the selection, and the list and bar update on their own.
  • .navigationDestination(for:) goes inside the NavigationStack, on the root list, not on every row.
  • .safeAreaInset(edge: .bottom) adds a bar below the list so the last row is not hidden behind it; .background(.bar) is the system bar material.

Previews, the simulator, tests and practice

  1. Preview

    Open a file with #Preview and click Show Canvas in the upper-right corner. The preview updates as you edit; @Previewable lets you declare state right inside the preview.

  2. Simulator

    Choose an iPhone simulator in the run destination menu and press Run (⌘R). In Xcode 27, simulators run in Device Hub. Apple notes that a simulator does not replicate the performance or all the features of a physical device.

  3. Your own device

    Pair your iPhone through Manage Devices. On the first run Xcode registers the device and creates a signing profile, so you need a Team from your account.

  4. Tests

    Add a unit test target (Unit Testing Bundle, testing system: Swift Testing) and run Product → Test (⌘U).

ServicesTests/ServicesTests.swift
import Observation
import Synchronization
import Testing
@testable import Services

@MainActor
struct ServicesTests {
    @Test(arguments: [
        (0, "Choose services"),
        (1, "In request: 1 service"),
        (3, "In request: 3 services"),
        (21, "In request: 21 services"),
    ])
    func labelUsesEnglishPlurals(count: Int, expected: String) {
        #expect(selectedLabel(count: count) == expected)
    }

    @Test func setToggle() {
        var ids: Set<Int> = []
        ids.toggle(3)
        #expect(ids == [3])
        ids.toggle(3)
        #expect(ids.isEmpty)
    }

    @Test func toggleAddsAndRemoves() {
        let model = ServicesModel()
        model.toggle(2)
        model.toggle(5)
        #expect(model.selectedIDs == [2, 5])
        #expect(model.label == "In request: 2 services")
        model.toggle(2)
        #expect(model.selectedIDs == [5])
    }

    @Test func structIsCopiedOnAssignment() {
        var copy = Service.samples[1]
        copy.badge = "Hit"
        #expect(Service.samples[1].badge == nil)
    }

    @Test func viewsAreNotifiedAboutChanges() {
        let model = ServicesModel()
        let changed = Mutex(false)
        withObservationTracking {
            _ = model.label
        } onChange: {
            changed.withLock { $0 = true }
        }
        model.toggle(3)
        #expect(changed.withLock { $0 })
    }
}
Tests with Swift Testing. This exact file passed swift test on Linux (5 tests), both in the default mode and with MainActor default isolation.

tapping does not change the screen

Cause
the value is a plain var, not @State
Fix
@State private var … in the owning view

the model changes but the screen does not

Cause
the class lacks the @Observable macro or conforms to Observable by hand
Fix
add @Observable before class

a child view cannot change the value

Cause
a value was passed instead of a binding
Fix
@Binding in the child and $ at the call site

tapping a link opens nothing

Cause
no navigationDestination(for:) for that type, or it is outside the NavigationStack
Fix
add the modifier inside the stack

no such module 'SwiftUI'

Cause
building outside Xcode on a Mac (Linux, Windows)
Fix
build SwiftUI code in Xcode; keep logic in files without import SwiftUI

Next, compare this lesson with the Android version in Jetpack Compose: the ideas match almost one to one (state, state hoisting, lazy lists, a screen model). Before release, see the guide to publishing on the App Store and Google Play.

How this was verified2 · passed1 · not runchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.