Экран в SwiftUI — это структуры, соответствующие протоколу View: свойство body описывает интерфейс, а SwiftUI сам обновляет его, когда меняются данные в @State или в модели с макросом @Observable. Для работы нужны Mac и Xcode (сейчас Xcode 27 с iOS 27 SDK и Swift 6.4); модель и логику на Swift можно тестировать и вне Mac, но сам SwiftUI собирается только в Xcode.
После материала вы сможете
- описать данные на Swift через
struct, Optional и расширения и покрыть логику тестами Swift Testing - собрать представление из
VStack,HStack,ZStackи модификаторов, понимая, почему важен их порядок - хранить состояние в
@State, передавать его через@Bindingи вынести данные в модель на@Observable - показать данные в
Listи сделать переход на экран подробностей черезNavigationStack
Что знать заранее
Проверено на
Swift 6.4Swift Testing 6.4Xcode 27iOS 27 SDKiOS 17+
Сверено с документацией: 11 октября 2026 г.
Содержание
Что нужно для iOS-разработки: Mac, Xcode, Apple Account
Итог урока — экран iOS-приложения со списком услуг студии: строки с буквой-аватаром, описанием и меткой, экран подробностей с кнопкой «Добавить в заявку» и нижняя панель «В заявке: 3 услуги». По пути разберём основу SwiftUI: View и body, стеки и модификаторы, @State и @Binding, модель на @Observable, List и NavigationStack. Тот же экран на Android собран в уроке про Jetpack Compose — их удобно сравнивать.
Компьютер
- Требование
- Mac с macOS Tahoe 26.6 или новее
- Комментарий
- так указано для Xcode 27 в таблице версий Apple
Xcode
- Требование
- 27
- Комментарий
- включает iOS 27 SDK и компилятор Swift 6.4; Xcode 27.1 на момент сверки — release candidate
Язык
- Требование
- Swift 6.4
- Комментарий
- режим языка Swift 6 со строгой проверкой конкурентности
Минимальная iOS
- Требование
- iOS 17
- Комментарий
- из-за
@Observable;Mutexв тесте требует iOS 18
Apple Account
- Требование
- для запуска на своём iPhone
- Комментарий
- Xcode регистрирует устройство и создаёт профиль подписи
Apple Developer Program
- Требование
- для публикации в App Store
- Комментарий
- подробнее — в материале о публикации в сторах
SwiftUI — фреймворк Apple для декларативного интерфейса: вы описываете, как экран выглядит при текущих данных, а система сама обновляет нужные части, когда данные меняются. Он есть только на платформах Apple. Сам язык Swift работает и на Linux, и на Windows, поэтому модель и логику можно писать и тестировать где угодно, а вот экран собирается только в Xcode на Mac.
Swift коротко: let/var, struct, Optional, замыкания
Создайте проект
File → New → Project → App (или App в меню New Project окна приветствия). Interface — SwiftUI, Language — Swift, имя
Services.Укажите Team и Organization Identifier
Из идентификатора организации Xcode строит bundle identifier — уникальное имя приложения в системе. Team нужен для подписи при запуске на устройстве.
Добавьте файл модели
Добавьте новый Swift-файл (File → New) с именем
Service.swift. В нём нет ни строчки SwiftUI.
struct Service: Identifiable, Hashable {
let id: Int
let title: String
let summary: String
var badge: String? = nil // не у каждой услуги есть метка — поэтому Optional
}
extension Service {
static let samples: [Service] = [
Service(id: 1, title: "Сайт компании", summary: "Страницы услуг, форма заявки, SEO-основа", badge: "Популярное"),
Service(id: 2, title: "Мобильное приложение", summary: "Нативное приложение для iPhone"),
Service(id: 3, title: "CRM-система", summary: "Клиенты, сделки и задачи в одном месте"),
Service(id: 4, title: "Интеграции", summary: "Связка сайта с 1С, оплатой и мессенджерами"),
Service(id: 5, title: "Аудит сайта", summary: "Скорость, SEO и доступность", badge: "Новое"),
]
}
extension Set {
/// Убирает элемент, если он есть, иначе добавляет.
mutating func toggle(_ member: Element) {
if contains(member) {
remove(member)
} else {
insert(member)
}
}
}
/// «В заявке: 1 услуга», «… 3 услуги», «… 5 услуг».
func selectedLabel(count: Int) -> String {
if count == 0 { return "Выберите услуги" }
let word: String
switch (count % 10, count % 100) {
case (1, let lastTwo) where lastTwo != 11:
word = "услуга"
case (2...4, let lastTwo) where !(12...14).contains(lastTwo):
word = "услуги"
default:
word = "услуг"
}
return "В заявке: \(count) \(word)"
}Set и функция склонения. Файл проверен swift build и swift test.let / var
- Что делает
let— константа, значение нельзя изменить после присваивания;var— переменная- Где в коде
let id: Int,var badge
struct
- Что делает
- тип-значение: при присваивании и передаче копируется. Инициализатор по всем полям создаётся автоматически
- Где в коде
struct Service
String?
- Что делает
- Optional: либо значение, либо
nil - Где в коде
var badge: String? = nil
if let
- Что делает
- безопасно извлекает значение из Optional
- Где в коде
if let badge = service.badge
протоколы
- Что делает
Identifiableдаётidдля списков,Hashableнужен для навигации по значению- Где в коде
Service: Identifiable, Hashable
extension, mutating
- Что делает
- добавляет методы существующему типу;
mutatingразрешает менять значение - Где в коде
extension Set { mutating func toggle }
switch + where
- Что делает
- сопоставление с образцом по кортежу с условиями
- Где в коде
- выбор формы слова
Почему Service — структура, а не класс? В Swift структуры и перечисления — типы-значения: изменённая копия не затрагивает оригинал. Это проверяет тест structIsCopiedOnAssignment ниже. Для данных, которые просто описывают «что есть», структура — естественный выбор. Классы — ссылочные типы: две переменные указывают на один объект. Класс понадобится для общей модели экрана, которую видят несколько View.
Замыкание (closure) — функция без имени, которую можно передать как значение. В SwiftUI они повсюду: действие кнопки Button { … }, содержимое строки списка List(…) { service in … }, условие фильтра filter { … }. Принудительное извлечение ! из Optional при nil вызывает ошибку во время выполнения, поэтому в уроке его нет — только if let.
View и body: стеки и модификаторы
Экран в SwiftUI собирается из структур, которые соответствуют протоколу View. Единственное требование — вычисляемое свойство body: оно возвращает другие представления. some View означает «конкретный тип, который знает компилятор, но вам его выписывать не нужно». Протокол View изолирован на главном акторе (@MainActor) — весь код интерфейса выполняется в главном потоке.
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
- Раскладка
- дети по горизонтали
- В строке
- аватар, текст, отметка
VStack(alignment: .leading)
- Раскладка
- дети сверху вниз, выровнены по левому краю
- В строке
- название, описание, метка
ZStack
- Раскладка
- дети друг поверх друга
- В строке
- буква поверх круга
Spacer()
- Раскладка
- занимает свободное место
- В строке
- прижимает отметку к правому краю
Модификатор — метод вроде .font(.headline) или .padding(). Документация Apple описывает механизм так: модификатор оборачивает представление в новое представление с нужными свойствами. Отсюда главное правило — порядок важен. .frame(width: 40, height: 40) применён к ZStack, поэтому размер получает весь аватар. Если написать .padding().background(.bar), фон займёт область вместе с отступом; .background(.bar).padding() — только содержимое.
.foregroundStyle(.secondary)и.tint— стили из системы, а не «зашитые» цвета: строка сама подстраивается под тёмную тему и акцентный цвет приложения..contentShape(Rectangle())делает нажимаемой всю строку, включая пустое место отSpacer..accessibilityElement(children: .combine)объединяет тексты строки в один элемент для VoiceOver, а.accessibilityAddTraits(.isSelected)сообщает, что услуга выбрана. Иконка отметки скрыта от VoiceOver, чтобы не дублировать это состояние.#Previewпоказывает представление на холсте Xcode рядом с кодом, без запуска приложения.
Состояние: @State и @Binding
Структуры View создаются заново при каждом обновлении, поэтому обычное свойство не может «помнить» выбор. @State передаёт хранение SwiftUI: значение живёт, пока представление на экране, и при его изменении SwiftUI обновляет части иерархии, которые от него зависят. Apple советует объявлять состояние private — в самом верхнем представлении, которому оно нужно, — и задавать начальное значение прямо в объявлении.
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, нажатие на строку переключает услугу. Дальше этот файл заменим. SwiftUI-код: сверен с документацией Apple, но не компилировался — SwiftUI доступен только в Xcode на Mac.Сравните с Kotlin: там мы возвращали новое множество, потому что Compose не видит изменений внутри изменяемой коллекции. В Swift Set — тип-значение, и selectedIDs.toggle(…) записывает в @State новое значение целиком; SwiftUI это замечает.
Если дочернему представлению нужно только читать состояние, передайте ему обычное значение — как isSelected в ServiceRow. Если нужно и менять — передайте @Binding: это ссылка на чужое состояние, через которую можно читать и записывать. Привязку получают префиксом $: $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("Метка", value: badge)
}
}
Section {
Button(isSelected ? "Убрать из заявки" : "Добавить в заявку") {
selectedIDs.toggle(service.id)
}
}
}
.navigationTitle(service.title)
}
}
#Preview {
@Previewable @State var selectedIDs: Set<Int> = [1]
NavigationStack {
ServiceDetailView(service: Service.samples[0], selectedIDs: $selectedIDs)
}
}@Binding, а в превью состояние создаёт @Previewable. SwiftUI-код: сверен с документацией Apple, но не компилировался — SwiftUI доступен только в Xcode на Mac.Модель на @Observable
Пока выбор живёт внутри представления, его не увидят другие экраны, а логику сложно тестировать. Вынесем данные в класс-модель. Макрос @Observable (фреймворк Observation, iOS 17+) на этапе компиляции добавляет классу наблюдаемость: SwiftUI запоминает, какие свойства прочитал body, и обновляет представление только при их изменении. Apple отдельно предупреждает: подписать класс на протокол Observable вручную недостаточно — нужен именно макрос.
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
- Сейчас (Observation)
@Observable final class Model
@Published var у каждого свойства
- Сейчас (Observation)
- обычные
var— наблюдаются автоматически
@StateObject во владельце
- Сейчас (Observation)
@State private var model = Model()
представление обновлялось при изменении любого @Published
- Сейчас (Observation)
- обновляется, только если
bodyчитал изменившееся свойство
Вычисляемое свойство label тоже отслеживается: оно читает selectedIDs, поэтому панель с подписью обновится при каждом выборе. Это проверяет тест viewsAreNotifiedAboutChanges через withObservationTracking — ту же функцию, на которой построено отслеживание в SwiftUI.
Список и навигация: List и NavigationStack
List показывает строки из коллекции. Если элементы соответствуют Identifiable, список сам различает их по id — ключ передавать не нужно. NavigationStack (iOS 16+) держит стек экранов: NavigationLink(value:) кладёт в стек значение, а navigationDestination(for:) говорит, какой экран показать для значения этого типа. Тип значения должен быть Hashable — поэтому Service подписан на него.
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("Услуги")
.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, список со ссылками, экран подробностей и панель снизу. SwiftUI-код: сверен с документацией Apple, но не компилировался — SwiftUI доступен только в Xcode на Mac.import SwiftUI
@main
struct ServicesApp: App {
var body: some Scene {
WindowGroup {
ServicesView()
}
}
}@main и сцена WindowGroup. Шаблон App в Xcode создаёт такой файл сам. SwiftUI-код: сверен с документацией Apple, но не компилировался — SwiftUI доступен только в Xcode на Mac.@State private var model = ServicesModel()— владелец модели; Apple показывает именно такой способ хранить объект@Observable. Дочерним представлениям модель или её части передаются параметрами.$model.selectedIDs— привязка к свойству объекта, хранящегося в@State. Экран подробностей получает@Bindingи меняет выбор, а список и панель обновляются сами..navigationDestination(for:)ставится внутриNavigationStack, на корневой список, а не на каждую строку..safeAreaInset(edge: .bottom)добавляет панель под список так, что последняя строка не прячется под ней;.background(.bar)— системный материал панелей.
Превью, симулятор, тесты и практика
Превью
Откройте файл с
#Previewи покажите холст кнопкой Show Canvas в правом верхнем углу. Превью обновляется по мере правки кода;@Previewableпозволяет объявить состояние прямо в превью.Симулятор
Выберите симулятор iPhone в меню назначения запуска и нажмите Run (⌘R). В Xcode 27 симуляторы работают в Device Hub. Apple напоминает: симулятор не воспроизводит производительность и все возможности реального устройства.
Своё устройство
Подключите iPhone через Manage Devices. При первом запуске Xcode регистрирует устройство и создаёт профиль подписи — нужен Team из вашей учётной записи.
Тесты
Добавьте таргет для unit-тестов (Unit Testing Bundle, система тестов — Swift Testing) и запустите Product → Test (⌘U).
import Observation
import Synchronization
import Testing
@testable import Services
@MainActor
struct ServicesTests {
@Test(arguments: [
(0, "Выберите услуги"),
(1, "В заявке: 1 услуга"),
(3, "В заявке: 3 услуги"),
(5, "В заявке: 5 услуг"),
(11, "В заявке: 11 услуг"),
(21, "В заявке: 21 услуга"),
])
func labelUsesRussianPlurals(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 == "В заявке: 2 услуги")
model.toggle(2)
#expect(model.selectedIDs == [5])
}
@Test func structIsCopiedOnAssignment() {
var copy = Service.samples[1]
copy.badge = "Хит"
#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 на Linux (5 тестов) — и в обычном режиме, и с изоляцией по умолчанию на MainActor.нажатие не меняет экран
- Причина
- значение хранится в обычном
var, а не в@State - Исправление
@State private var …в представлении-владельце
модель меняется, а экран — нет
- Причина
- класс без макроса
@Observableили подписан наObservableвручную - Исправление
- добавить
@Observableпередclass
дочернее представление не может изменить значение
- Причина
- передано значение вместо привязки
- Исправление
@Bindingв дочернем и$при вызове
переход по ссылке ничего не открывает
- Причина
- нет
navigationDestination(for:)для этого типа или он внеNavigationStack - Исправление
- добавить модификатор внутри стека
no such module 'SwiftUI'
- Причина
- сборка не в Xcode на Mac (Linux, Windows)
- Исправление
- SwiftUI-код собирать в Xcode; логику держать в файлах без
import SwiftUI
Дальше: сравните этот урок с версией для Android на Jetpack Compose — идеи совпадают почти один в один (состояние, подъём состояния, ленивые списки, модель экрана). Перед релизом пригодится материал о публикации в App Store и Google Play.
Как проверено2 · пройдено1 · не запускалосьсверено 11 октября 2026 г.
- запущено, работает
Service.swift, ServicesModel.swift, ServicesTests.swift (RU и EN-копия с английскими строками)
Docker swift:6.4 (Swift 6.4-RELEASE, Linux): Swift-пакет с модулем Services (тот же `@testable import Services`, что в Xcode), `swift build -Xswiftc -warnings-as-errors` в режиме Swift 6 + `swift test` — 5/5 тестов Swift Testing (склонения, Set.toggle, модель, копирование struct, уведомление через withObservationTracking); дополнительно с `.defaultIsolation(MainActor.self)` (как в новых app-таргетах Xcode) — тоже 5/5
- запущено, работает
решения упражнений: clear(), query/visibleServices, selectedServices, canSubmit — RU и EN
пакеты ru-ex/en-ex: swift build + swift test — 9/9 (4 ExercisesTests + 5 основных), в обоих режимах изоляции; фрагменты в статье извлекаются генератором с проверкой вхождения в проверенные файлы
- не запускалось
SwiftUI-файлы: ServiceRow, ServicesView (две версии), ServiceDetailView, ServicesApp, RequestView и фрагменты .toolbar/.searchable/.safeAreaInset
SwiftUI на Linux недоступен: `swiftc -typecheck` с `import SwiftUI` в swift:6.4 — `error: no such module 'SwiftUI'`; Mac/Xcode на этой машине нет. API сверены с документацией Apple (декларации и доступность: safeAreaInset iOS 15, searchable iOS 15, LabeledContent iOS 16, NavigationLink(value:)/navigationDestination iOS 16, Previewable iOS 17, ShapeStyle.tint, Material.bar). Ни симулятор, ни устройство не использовались — в статье это сказано в подписях к каждому блоку
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.