Как делать11 октября 2026 г.8 мин чтения
Мобильная разработкаШаг 8 из 12

iOS на SwiftUI: первый экран

Коротко

Экран в SwiftUI — это структуры, соответствующие протоколу View: свойство body описывает интерфейс, а SwiftUI сам обновляет его, когда меняются данные в @State или в модели с макросом @Observable. Для работы нужны Mac и Xcode (сейчас Xcode 27 с iOS 27 SDK и Swift 6.4); модель и логику на Swift можно тестировать и вне Mac, но сам SwiftUI собирается только в Xcode.

Пошаговый урокУровень: НачальныйSwift и SwiftUIПрактика: ~1,5–3 ч

После материала вы сможете

  • описать данные на 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 г.

Содержание
  1. 01Что нужно для iOS-разработки: Mac, Xcode, Apple Account
  2. 02Swift коротко: let/var, struct, Optional, замыкания
  3. 03View и body: стеки и модификаторы
  4. 04Состояние: @State и @Binding
  5. 05Модель на @Observable
  6. 06Список и навигация: List и NavigationStack
  7. 07Превью, симулятор, тесты и практика

Что нужно для 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, замыкания

  1. Создайте проект

    File → New → Project → App (или App в меню New Project окна приветствия). Interface — SwiftUI, Language — Swift, имя Services.

  2. Укажите Team и Organization Identifier

    Из идентификатора организации Xcode строит bundle identifier — уникальное имя приложения в системе. Team нужен для подписи при запуске на устройстве.

  3. Добавьте файл модели

    Добавьте новый Swift-файл (File → New) с именем Service.swift. В нём нет ни строчки SwiftUI.

Services/Service.swift
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) — весь код интерфейса выполняется в главном потоке.

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-код: сверен с документацией Apple, но не компилировался — SwiftUI доступен только в Xcode на Mac.

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 — в самом верхнем представлении, которому оно нужно, — и задавать начальное значение прямо в объявлении.

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()
        }
    }
}
Первая версия экрана: выбор в @State, нажатие на строку переключает услугу. Дальше этот файл заменим. SwiftUI-код: сверен с документацией Apple, но не компилировался — SwiftUI доступен только в Xcode на Mac.

Сравните с Kotlin: там мы возвращали новое множество, потому что Compose не видит изменений внутри изменяемой коллекции. В Swift Set — тип-значение, и selectedIDs.toggle(…) записывает в @State новое значение целиком; SwiftUI это замечает.

Если дочернему представлению нужно только читать состояние, передайте ему обычное значение — как isSelected в ServiceRow. Если нужно и менять — передайте @Binding: это ссылка на чужое состояние, через которую можно читать и записывать. Привязку получают префиксом $: $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("Метка", 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 вручную недостаточно — нужен именно макрос.

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)
    }
}
Модель экрана. Компилируется и тестируется на Linux: фреймворк Observation входит в набор Swift.

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 подписан на него.

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("Услуги")
            .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.
Services/ServicesApp.swift
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) — системный материал панелей.

Превью, симулятор, тесты и практика

  1. Превью

    Откройте файл с #Preview и покажите холст кнопкой Show Canvas в правом верхнем углу. Превью обновляется по мере правки кода; @Previewable позволяет объявить состояние прямо в превью.

  2. Симулятор

    Выберите симулятор iPhone в меню назначения запуска и нажмите Run (⌘R). В Xcode 27 симуляторы работают в Device Hub. Apple напоминает: симулятор не воспроизводит производительность и все возможности реального устройства.

  3. Своё устройство

    Подключите iPhone через Manage Devices. При первом запуске Xcode регистрирует устройство и создаёт профиль подписи — нужен Team из вашей учётной записи.

  4. Тесты

    Добавьте таргет для unit-тестов (Unit Testing Bundle, система тестов — Swift Testing) и запустите Product → Test (⌘U).

ServicesTests/ServicesTests.swift
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 Testing. Именно этот файл прошёл 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.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.