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.
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,ZStackand modifiers, and explain why their order matters - Keep state in
@State, pass it through@Bindingand move data into an@Observablemodel - Show data in a
Listand navigate to a detail screen withNavigationStack
Know beforehand
Verified with
Swift 6.4Swift Testing 6.4Xcode 27iOS 27 SDKiOS 17+
Checked against docs: October 11, 2026
Contents
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;Mutexin 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
Create the project
File → New → Project → App (or App from the New Project menu in the welcome window). Interface: SwiftUI, Language: Swift, name:
Services.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.
Add the model file
Add a new Swift file (File → New) named
Service.swift. It contains no SwiftUI at all.
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)"
}Set extension and the pluralization function. Checked with swift build and swift test.let / var
- What it does
letis a constant that cannot change once set;varis 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
Identifiableprovides anidfor lists;Hashableis required for value-based navigation- Where in the code
Service: Identifiable, Hashable
extension, mutating
- What it does
- adds methods to an existing type;
mutatingallows 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.
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)
}
}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.tintare 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 fromSpacer..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.#Previewshows 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.
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()
}
}
}@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.
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)
}
}@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.
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)
}
}class Model: ObservableObject
- Now (Observation)
@Observable final class Model
@Published var on every property
- Now (Observation)
- plain
varproperties 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
bodyread 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.
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()
}@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.import SwiftUI
@main
struct ServicesApp: App {
var body: some Scene {
WindowGroup {
ServicesView()
}
}
}@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@Observableobject. Child views get the model or parts of it as parameters.$model.selectedIDsis a binding to a property of the object stored in@State. The detail screen receives a@Bindingand changes the selection, and the list and bar update on their own..navigationDestination(for:)goes inside theNavigationStack, 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
Preview
Open a file with
#Previewand click Show Canvas in the upper-right corner. The preview updates as you edit;@Previewablelets you declare state right inside the preview.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.
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.
Tests
Add a unit test target (Unit Testing Bundle, testing system: Swift Testing) and run Product → Test (⌘U).
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 })
}
}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
@Observablemacro or conforms toObservableby hand - Fix
- add
@Observablebeforeclass
a child view cannot change the value
- Cause
- a value was passed instead of a binding
- Fix
@Bindingin the child and$at the call site
tapping a link opens nothing
- Cause
- no
navigationDestination(for:)for that type, or it is outside theNavigationStack - 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.