# Лектор Стенфорда показал продвинутые техники создания UI в SwiftUI

Источник: https://www.youtube.com/watch?v=gNok5P7HLCw
Канал: Stanford Online
Опубликовано: 03.12.2025

---

Лекция 12 курса Стенфордского университета CS193p (весна 2025 года) целиком посвящена практическому программированию и созданию сложных пользовательских интерфейсов в SwiftUI. Лектор демонстрирует продвинутые техники разработки на примере игрового приложения CodeBreaker, детально разбирая работу с модальными окнами, жизненным циклом компонентов и системными окружениями. В центре внимания — оптимизация архитектуры интерфейса и решение неочевидных проблем производительности и отображения на различных устройствах Apple, включая iPhone и iPad.

## 🧩 Выделение компонентов и магия реактивного связывания: `@Binding` против `@Bindable`
[[JUMP:0:05]]

Лектор начинает занятие с рефакторинга кода, созданного на предыдущем занятии. Цель текущего этапа — выделить элемент выбора фишек (`pegChoices`) из общего экрана редактора игры (`GameEditor`) в самостоятельный визуальный компонент `PegChoicesChooser`. Это необходимо для реализации функций добавления и удаления фишек из списка, не перегружая основной интерфейс лишними деталями.

Процесс рефакторинга демонстрирует строгое правило проектирования интерфейсов: компонентам не следует передавать избыточные данные. Компоненту `PegChoicesChooser` не нужен весь объект игры, достаточно лишь массива фишек `[Peg]`. Для настройки интерактивного превью в Xcode используется новая директива `@Previewable` вместе со `@State`, что позволяет тестировать динамические изменения прямо в панели предварительного просмотра.

Важным теоретическим моментом лекции становится разграничение двух похожих по названию, но принципиально разных по сути механизмов SwiftUI: `@Binding` и `@Bindable`. Лектор напоминает студентам базовые различия:

* `@Binding` предназначен в основном для связывания структур (структурных типов данных, Value Types) между различными представлениями (Views).
* `@Bindable` используется для работы с классами и объектами, помеченными макросом `@Observable` (ссылочными типами, Reference Types). Он позволяет использовать синтаксис знака доллара (`$`) для создания связей с их свойствами.

Благодаря этому разграничению разработчики могут прозрачно передавать данные по цепочке вложенных компонентов интерфейса.

## 🎨 Создание кастомных элементов интерфейса и управление стилями с помощью `.tint()`
[[JUMP:4:03]]

Добавление новой фишки в список реализуется просто — через метод `append` к массиву `pegChoices` прямо внутри замыкания кнопки интерфейса. Однако стандартный дизайн кнопок добавления и удаления быстро перестает устраивать лектора из-за визуальной перегруженности и монотонного синего цвета по умолчанию. Лектор ставит задачу сделать кнопки более интуитивными: зеленый плюс для добавления и красный минус для удаления, сохранив стандартный цвет текста для остальной части строки.

Для этого создается кастомная функция генерации кнопок `button()`, принимающая заголовок, системное имя иконки и опциональный цвет. В коде применяется модификатор `.tint()`, который, по мнению лектора, является современным и гибким способом управления цветовыми акцентами UI. Если в этот модификатор передать значение `nil`, система автоматически применит глобальный цветовой акцент приложения.

Чтобы сделать интерфейс более плавным, все операции добавления и удаления элементов оборачиваются в блок `withAnimation`. Лектор также детально объясняет назначение символа подчеркивания (`_`) перед аргументом функции: он отменяет необходимость писать внешнее имя параметра при вызове метода, что делает конечный синтаксис чище.

## 📱 Модальные окна в SwiftUI: Анатомия модификатора `.sheet()`
[[JUMP:10:45]]

Следующим шагом становится интеграция созданного редактора в основной список игр `GameList`. Нажатие на кнопку «плюс» должно не просто создавать абстрактную пустую игру, а мгновенно открывать интерфейс редактирования. Лектор объясняет, что стандартный переход `NavigationLink` здесь неуместен, поскольку создание новой записи требует от пользователя немедленного сфокусированного действия. Такие интерфейсы называются «модальными».

Для отображения модального контента применяется модификатор `.sheet()`, поведение которого кардинально различается на смартфонах и планшетах:

* На iPhone модальное окно плавно выезжает снизу вверх, перекрывая практически весь экран.
* На iPad оно отображается в виде аккуратного центрированного прямоугольника, который можно легко закрыть, тапнув в любую свободную область экрана.

Управление состоянием окна привязывается к логическому флагу `@State private var showGameEditor`, инициализированному значением `false`. Важная деталь, на которую лектор обращает внимание аудитории: при открытии листов через `.sheet()` нет необходимости использовать блок `withAnimation`, поскольку анимация появления модальных окон жестко встроена в саму операцию на системном уровне. Чтобы избежать появления пустых окон в случае сбоев с опциональными данными, применяется реактивный подход с использованием замыкания `onChange(of: gameToEdit)`, которое автоматически синхронизирует видимость экрана с наличием редактируемого объекта.

## 🛠 Семантические панели инструментов и архитектурный рефакторинг
[[JUMP:20:09]]

Модальный интерфейс обязательно должен быть отменяемым, чтобы не загонять пользователя в тупик. Традиционным местом размещения кнопок «Отмена» (Cancel) и «Готово» (Done) является верхняя панель инструментов — Toolbar. Однако простое добавление модификатора `.toolbar` внутрь формы не дает результата, если визуальный компонент не обернут в контейнер `NavigationStack`. Лектор подчеркивает, что стек навигации допустимо использовать даже тогда, когда фактических переходов по экранам не планируется — исключительно ради корректной работы панелей инструментов.

Чтобы избежать случайных нажатий из-за близкого расположения кнопок, SwiftUI предлагает семантическое размещение элементов через `ToolbarItem(placement:)`:

* Значение `.cancellationAction` автоматически переносит кнопку «Отмена» в левый верхний угол, привычный для пользователей iOS.
* Значение `.confirmationAction` помещает кнопку подтверждения в правый угол и автоматически выделяет её жирным шрифтом, подчеркивая важность действия.

Разрастание кода внутри главного экрана заставляет лектора провести глубокий рефакторинг, вынеся кнопки и сам редактор в отдельные вычисляемые свойства `addButton` и `gameEditor` с макросом `@ViewBuilder`. Более того, продвигая концепцию чистого кода, преподаватель предлагает перенести всю логику управления кнопками «Отмена» и «Готово» непосредственно внутрь самого компонента `GameEditor`, передавая наружу лишь замыкание `onChoose` по завершении редактирования. Закрытие экрана изнутри компонента реализуется с помощью переменной окружения `@Environment(\.dismiss)`, которая элегантно закрывает модальный контекст любой сложности.

## ⌨️ Тонкая настройка текстовых полей и валидация данных через расширения моделей
[[JUMP:31:01]]

Работа с текстовым вводом (`TextField`) в SwiftUI скрывает множество полезных модификаторов. Лектор демонстрирует использование `.onSubmit`, который перехватывает нажатие клавиши «Ввод» (Return) на виртуальной или физической клавиатуре и мгновенно активирует логику сохранения данных. Модификатор `.autocapitalization(.words)` настраивает автоматическое возведение в верхний регистр первой буквы каждого вводимого слова, а отключение встроенного словаря через `.autocorrectDisabled(true)` предотвращает навязчивые автоисправления системы при вводе специфических названий.

Важнейшей задачей становится предотвращение создания некорректных игровых наборов — например, игр без названия или с недостаточным количеством уникальных цветов фишек. Лектор показывает, как расширить функционал модели данных с помощью механизма `extension CodeBreaker` прямо в слое интерфейса, добавив вычисляемое свойство `isValid`. Для проверки уникальности цветов применяется эффективный трюк: массив фишек преобразуется в коллекцию `Set` (множество), которая автоматически исключает любые дубликаты, после чего проверяется, что размер множества равен или больше двух элементов.

Вместо банальной блокировки кнопки через модификатор `.disabled()`, которая, по мнению лектора, может запутать пользователя, рекомендуется использовать систему предупреждений `.alert()`. Диалоговое окно уведомляет игрока о конкретных ошибках валидации и подсказывает, как их исправить, при этом само окно настраивается и вызывается аналогично механизму модальных листов.

## 🔄 Копирование объектов и безопасное редактирование существующих записей
[[JUMP:41:40]]

После отладки механизма создания новых игр возникает логичное желание использовать этот же редактор для изменения уже существующих записей в списке. Однако здесь разработчик сталкивается с архитектурной ловушкой: компонент `GameEditor` вносит изменения в модель в режиме реального времени. Если пользователь начнет редактировать игру, удалит из неё фишки, а затем передумает и нажмет «Отмена», изменения все равно останутся примененными к исходному объекту.

Решением становится концепция работы с временными копиями данных. Лектор напоминает о фундаментальном отличии типов данных в Swift: если бы модель была структурой (`struct`, тип значения), копирование происходило бы автоматически при простом присваивании. Но поскольку `CodeBreaker` спроектирован как класс (`class`, ссылочный тип), программисту приходится вручную создавать новый экземпляр класса, копируя в него изменяемые свойства — имя и набор фишек.

Только после успешного нажатия кнопки «Готово» приложение производит поиск индекса оригинальной игры в массиве через метод `firstIndex(of:)` и заменяет её отредактированной копией. Побочным, но полезным эффектом такого подхода становится автоматический сброс счетчика попыток пройденной игры при изменении структуры её фишек.

## ⚡ Двунаправленное связывание данных и продвинутая инициализация `Binding(get:set:)`
[[JUMP:48:04]]

Лектор объявляет блок «продвинутого Swift» (Advanced Swift) и иронично предлагает неподготовленным студентам закрыть уши на пять минут, если эта тема покажется им слишком сложной. Речь идет об оптимизации состояний экрана. Хранение двух отдельных переменных (`gameToEdit` и `showGameEditor`) для контроля одного и того же процесса модального окна — это потенциальный источник багов и рассинхронизации данных. Преподаватель ставит задачу полностью избавиться от флага видимости `@State` и вычислять его динамически на основе состояния объекта игры.

Для этого используются глубокие возможности кастомной инициализации связей через конструктор `Binding(get:set:)`:

* Блок `get` возвращает логическое значение `true`, если объект `gameToEdit` не равен `nil`, что мгновенно сигнализирует системе о необходимости поднять модальное окно.
* Блок `set` перехватывает момент закрытия окна пользователем (когда система пытается присвоить параметру значение `false`) и автоматически обнуляет указатель `gameToEdit = nil`.

Такой элегантный подход позволяет избавиться от лишних системных триггеров `onDismiss` и `onChange(of:)`, делая код максимально лаконичным, декларативным и легким для долгосрочной поддержки.

## 👆 Жесты смахивания списка и устранение платформенных различий на iPad
[[JUMP:54:52]]

Дополнительным элементом кастомизации списка становится внедрение быстрых действий по свайпу с помощью модификатора `.swipeActions(edge: .leading)`. Смахивание строки слева направо открывает доступ к кнопке редактирования, окрашенной в системный цвет с помощью `.accentColor`. При этом лектор напоминает о кроссплатформенных ограничениях: жесты свайпа не будут работать в среде macOS, где вместо них стандартным поведением остаются контекстные меню.

Затем лекция переходит к критически важному разбору багов с подсчетом прошедшего времени (`elapsedTime`). Изначально таймер запускался в момент создания игры и тикал непрерывно для всех элементов списка одновременно. Была добавлена логика остановки и запуска таймера через события жизненного цикла `onAppear` и `onDisappear`. Однако тестирование на iPad выявило серьезную проблему: на планшетах правая панель детального просмотра никогда не исчезает с экрана физически, из-за чего события `onAppear`/`onDisappear` просто прекращают вызываться при переключении между играми.

Для исправления этого платформенного нюанса лектор задействует продвинутую форму модификатора `onChange(of: game)`, которая позволяет принимать два параметра — старое и новое состояние объекта (`oldGame, newGame in ...`). Это позволяет точечно останавливать таймер предыдущей выбранной игры и мгновенно запускать его для вновь выбранной, гарантируя идеальную точность расчетов на любых форм-факторах устройств.

## 🔄 Контроль жизненного цикла приложения через `scenePhase` и создание кастомных `ViewModifier`
[[JUMP:1:08:27]]

Фональным аккордом отладки таймера становится обработка поведения приложения при сворачивании. Если пользователь переключается на другое приложение (например, Apple Maps), игра замораживается, но системный таймер может продолжить некорректный учет времени. Для отслеживания глобального состояния программы используется переменная окружения `@Environment(\.scenePhase)`.

Данное перечисление (enum) содержит три фундаментальных состояния:

* `.active` — приложение находится на переднем плане и активно взаимодействует с пользователем.
* `.background` — приложение полностью свернуто и переведено в фоновый режим.
* `.inactive` — переходная фаза, возникающая, например, при вызове панели многозадачности iOS.

Связывание фаз через очередное замыкание `.onChange(of: scenePhase)` позволяет автоматически ставить таймер на паузу при уходе в фоновый режим и возобновлять его при возвращении.

Весь этот громоздкий массив системного кода лектор предлагает упаковать в красивую и многоразовую обертку, создав полноценную кастомную структуру `struct ElapsedTimeTracker: ViewModifier`. Внутри неё собираются все переменные окружения и триггеры, а для удобства использования пишется легковесное расширение для протокола `View` — метод `.trackElapsedTime(in: game)`. Это превращает сложнейшую внутреннюю логику отслеживания времени и фаз операционной системы в лаконичную однострочную команду декларативного интерфейса.