Паттерны NavigationStack в SwiftUI для предсказуемых многошаговых потоков
Паттерны NavigationStack в SwiftUI для многошаговых потоков: понятная маршрутизация, безопасное поведение Назад и практические примеры для онбординга и мастеров одобрения.

Что идёт не так в многошаговых потоках
Многошаговый поток — это любая последовательность, где шаг 1 должен произойти прежде, чем шаг 2 обретает смысл. Типичные примеры: онбординг, запрос на одобрение (просмотр, подтверждение, отправка) и пошаговый ввод данных, где пользователь собирает черновик через несколько экранов.
Эти потоки кажутся простыми только тогда, когда кнопка Назад ведёт так, как люди ожидают. Если Назад забрасывает пользователя в неожиданное место, они перестают доверять приложению. Это проявляется в неправильных отправках, брошенных онбордингах и обращениях в поддержку вроде «Я не могу вернуться на тот экран».
Запутанная навигация обычно проявляется как одно из следующих:
- Приложение прыгает на неправильный экран или преждевременно выходит из потока.
- Один и тот же экран появляется дважды, потому что его запушили дважды.
- Шаг сбрасывается при возврате и пользователь теряет черновик.
- Пользователь может попасть на шаг 3, не завершив шаг 1, что создаёт некорректное состояние.
- После глубокой ссылки или перезапуска приложения показывается нужный экран, но с неправильными данными.
Полезная ментальная модель: многошаговый поток — это две вещи, движущиеся вместе.
Сначала — стек экранов (по которому пользователь может возвращаться). Второе — общее состояние потока (черновые данные и прогресс), которое не должно исчезать только потому, что экран удалился.
Многие настройки NavigationStack ломаются, когда стек экранов и состояние потока расходятся. Например, в онбординге экран «Создать профиль» может запушиться дважды (дублированный маршрут), а черновик профиля живёт внутри view и пересоздаётся при рендере. Пользователь нажимает Назад, видит другую версию формы и думает, что приложение ненадёжно.
Предсказуемое поведение начинается с того, что вы даёте потоку имя, определяете, что должен делать Назад на каждом шаге, и задаёте для состояния потока одно понятное место хранения.
Основы NavigationStack, которые действительно нужны
Для многошаговых потоков используйте NavigationStack, а не старый NavigationView. NavigationView может вести себя по-разному на разных версиях iOS и его сложнее прогнозировать при push/pop или восстановлении экранов. NavigationStack — современное API, которое трактует навигацию как настоящий стек.
NavigationStack хранит историю того, где был пользователь. Каждый push добавляет destination в стек. Каждый Back удаляет один destination. Это простое правило — то, что делает поток стабильным: UI должен отражать чёткую последовательность шагов.
Что на самом деле хранится в стеке
SwiftUI не хранит ваши экземпляры view. Оно хранит данные, которые вы использовали для навигации (значение маршрута) и использует их, чтобы восстанавливать destination view при необходимости. Из этого вытекают несколько практических последствий:
- Не полагайтесь на то, что view останется живым, чтобы хранить важные данные.
- Если экрану нужно состояние — держите его в модели (например,
ObservableObject), которая живёт вне запушенного view. - Если вы запушите один и тот же destination дважды с разными данными, SwiftUI трактует их как разные записи в стеке.
NavigationPath — то, к чему стоит обращаться, когда ваш поток — не просто один или два фиксированных пуша. Думайте о нём как об редактируемом списке «куда мы идём» значений. Вы можете append’ить маршруты чтобы двигаться вперёд, удалять последний маршрут чтобы вернуться назад, или заменить весь путь, чтобы перепрыгнуть к более позднему шагу.
Это хорошо работает для пошаговых мастеров, когда нужно сбрасывать поток после завершения или восстанавливать частично заполненный поток из сохранённого состояния.
Предсказуемость важнее хитростей. Меньше скрытых правил (автопереходов, неявных pop’ов, сайд‑эффектов во view) — меньше странных багов со стеком Назад позже.
Смоделируйте поток небольшим enum маршрутов
Предсказуемая навигация начинается с одного решения: держать маршрутизацию в одном месте и описывать каждый экран потока маленьким, понятным значением.
Создайте единый источник правды, например FlowRouter (ObservableObject), который владеет NavigationPath. Это делает каждый push и pop последовательным, вместо того чтобы разбрасывать навигацию по разным view.
Простая структура роутера
Используйте enum для представления шагов. Добавляйте associated values только для лёгких идентификаторов (например, ID), но не для целых моделей.
enum Step: Hashable {
case welcome
case profile
case verifyCode(phoneID: UUID)
case review(applicationID: UUID)
case done
}
final class FlowRouter: ObservableObject {
@Published var path = NavigationPath()
func go(_ step: Step) { path.append(step) }
func back() { if !path.isEmpty { path.removeLast() } }
func reset() { path = NavigationPath() }
}
Держите состояние потока отдельно от состояния навигации
Рассматривайте навигацию как «где пользователь находится», а состояние потока как «что он уже ввёл». Поместите данные потока в своё хранилище (например, OnboardingState с именем, email, загруженными документами) и держите их стабильными, пока экраны появляются и исчезают.
Правило простое:
FlowRouter.pathсодержит только значенияStep.OnboardingStateсодержит вводы пользователя и черновые данные.- Шаги несут в себе ID для поиска данных, а не сами данные.
Это помогает избежать хрупких хэширований, громоздких путей и неожиданных сбросов при перестроении view SwiftUI.
Шаг за шагом: постройте мастер с NavigationPath
Для экранов в стиле мастера самый простой подход — контролировать стек вручную. Стремитесь к одному источнику правды «где я в потоке?» и одному способу двигаться вперёд и назад.
Начните с NavigationStack(path:), связанного с NavigationPath. Каждый запушенный экран представлен значением (часто case enum), и вы регистрируете destinations один раз.
import SwiftUI
enum WizardRoute: Hashable {
case profile
case verifyEmail
case permissions
case review
}
struct OnboardingWizard: View {
@State private var path = NavigationPath()
@State private var currentIndex = 0
private let steps: [WizardRoute] = [.profile, .verifyEmail, .permissions, .review]
var body: some View {
NavigationStack(path: $path) {
StartScreen {
goToStep(0) // push first step
}
.navigationDestination(for: WizardRoute.self) { route in
switch route {
case .profile:
ProfileStep(onNext: { goToStep(1) })
case .verifyEmail:
VerifyEmailStep(onNext: { goToStep(2) })
case .permissions:
PermissionsStep(onNext: { goToStep(3) })
case .review:
ReviewStep(onEditProfile: { popToStep(0) })
}
}
}
}
private func goToStep(_ index: Int) {
currentIndex = index
path.append(steps[index])
}
private func popToStep(_ index: Int) {
let toRemove = max(0, currentIndex - index)
if toRemove > 0 { path.removeLast(toRemove) }
currentIndex = index
}
}
Чтобы сделать поведение Назад предсказуемым, придерживайтесь нескольких правил. Добавляйте ровно один маршрут для продвижения, держите «Далее» линейным (только push следующего шага), а когда нужно перепрыгнуть назад (например «Редактировать профиль» из Review), урежьте стек до известного индекса.
Это предотвращает случайные дубли экранов и заставляет Назад соответствовать ожиданию пользователя: один тап = один шаг.
Держите данные стабильными, пока экраны появляются и уходят
Поток кажется ненадёжным, когда каждое view владеет своим состоянием. Вы вводите имя, идёте дальше и, вернувшись, видите пустое поле, потому что view было пересоздано.
Решение простое: рассматривайте поток как один объект черновика, и каждый шаг его редактирует.
В SwiftUI это обычно означает общий ObservableObject, созданный один раз в начале потока и передаваемый всем шагам. Не храните черновые значения в @State каждого view, если они не принадлежат только этому экрану.
final class OnboardingDraft: ObservableObject {
@Published var fullName = ""
@Published var email = ""
@Published var wantsNotifications = false
var canGoNextFromProfile: Bool {
!fullName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& email.contains("@")
}
}
Создайте его в точке входа и затем поделитесь через @StateObject и @EnvironmentObject (или передавайте явно). Тогда стек может меняться, не теряя данных.
Решите, что должно пережить навигацию назад
Не всё должно сохраняться навсегда. Определите правила заранее, чтобы поток оставался предсказуемым.
Сохраняйте ввод пользователя (поля ввода, переключатели, выборы), если пользователь явно не сбросил их. Сбрасывайте step‑специфичное UI‑состояние (загрузки, временные алерты, короткие анимации). Очищайте чувствительные поля (одноразовые коды) при выходе из соответствующего шага. Если выбор влияет на последующие шаги, очищайте только зависимые поля.
Валидация естественно вписывается сюда. Вместо того чтобы позволять идти дальше и затем показывать ошибку на следующем экране, удерживайте пользователя на текущем шаге, пока он не станет валидным. Отключение кнопки на основе вычисляемого свойства вроде canGoNextFromProfile чаще всего достаточно.
Сохраняйте контрольные точки, но не переусердствуйте
Часть черновиков может жить только в памяти. Другие стоит хранить, чтобы пережить перезапуск или падение приложения. Практическое правило по умолчанию:
- Держите данные в памяти, пока пользователь активно проходит шаги.
- Пишите в локальное хранилище на чётких контрольных точках (учётная запись создана, заявка отправлена, начат платёж).
- Сохраняйте раньше, если поток длинный или ввод занимает больше минуты.
Так экраны могут свободно появляться и исчезать, а прогресс пользователя всё ещё будет ощущаться стабильным и уважительным к его времени.
Глубокие ссылки и восстановление частично завершённого потока
Deep links важны, потому что реальные потоки редко начинаются с шага 1. Кто‑то нажимает письмо, пуш или общую ссылку и ожидает приземлиться на нужном экране, например на шаге 3 онбординга или на финальном экране одобрения.
С NavigationStack рассматривайте глубокую ссылку как инструкцию построить валидный путь, а не как команду телепортироваться на один экран. Начинайте с начала потока и добавляйте только те шаги, которые действительно верны для этого пользователя и этой сессии.
Превратите внешнюю ссылку в безопасную последовательность маршрутов
Хороший паттерн: распарсить внешний ID, подгрузить минимальные данные, которые нужны, и затем конвертировать это в последовательность маршрутов.
enum Route: Hashable {
case start
case profile
case verifyEmail
case approve(requestID: String)
}
func pathForDeepLink(requestID: String, hasProfile: Bool, emailVerified: Bool) -> [Route] {
var routes: [Route] = [.start]
if !hasProfile { routes.append(.profile) }
if !emailVerified { routes.append(.verifyEmail) }
routes.append(.approve(requestID: requestID))
return routes
}
Эти проверки — ваши перила. Если предварительные условия отсутствуют, не бросайте пользователя на шаг 3 с ошибкой и без пути вперёд. Направьте его на первый отсутствующий шаг и убедитесь, что стек Назад всё ещё рассказывает связную историю.
Восстановление частичного потока
Чтобы восстановить после релонча, сохраняйте две вещи: последнее известное состояние маршрута и черновые данные пользователя. Затем решите, как возобновить без сюрпризов.
Если черновик «свежий» (несколько минут или часов), предложите явный выбор «Возобновить». Если он старый — начните с начала, но используйте черновик для предзаполнения полей. Если требования изменились, перестройте путь по тем же правилам предусловий.
Push vs modal: делайте выход из потока простым
Поток кажется предсказуемым, когда есть один главный способ двигаться вперёд: пушить экраны в один стек. Используйте sheets и fullScreenCover для побочных задач, а не для основного пути.
Push (NavigationStack) подходит, когда пользователь ожидает, что Назад вернёт к предыдущим шагам. Модалы (sheet или fullScreenCover) подходят для побочных задач, быстрого выбора или подтверждения рискованного действия.
Простые правила предотвращают большинство странностей навигации:
- Push для основного пути (Шаг 1, Шаг 2, Шаг 3).
- Sheet для небольших опциональных задач (выбор даты, страны, скан документа).
- fullScreenCover для «отдельных миров» (логин, захват фото/видео, длинный юридический документ).
- Модал для подтверждений (отмена потока, удаление черновика, отправка на одобрение).
Частая ошибка — помещать ключевые шаги потока в sheet. Если Шаг 2 — это sheet, пользователь может свайпом его закрыть, потерять контекст и оказаться в стеке, где он всё ещё на Шаге 1, а данные говорят, что Шаг 2 завершён.
Подтверждения — обратная ошибка: пушить экран «Вы уверены?» внутрь мастера захламляет стек и может создать циклы (Шаг 3 -> Подтвердить -> Назад -> Шаг 3 -> Назад -> Подтвердить).
Как аккуратно закрыть всё после «Готово»
Сначала решите, что означает «Готово»: вернуться на главный экран, вернуться в список или показать экран успеха.
Если поток был запушен, сбросьте NavigationPath в пустой, чтобы выпать к корню. Если поток был открыт модально — вызовите dismiss() из окружения. Если есть и то, и другое (модал содержит NavigationStack) — закройте модал, а не по одному каждому экрану. После успешной отправки также очищайте черновик, чтобы при повторном открытии поток стартовал чистым.
Поведение кнопки Назад и моменты «Вы уверены?»
Для большинства многошаговых потоков лучшая стратегия — ничего не менять: дайте системной кнопке Назад (и жесту свайпа) работать как есть. Это совпадает с ожиданиями пользователей и снижает баги, где UI говорит одно, а состояние навигации — другое.
Перехватывать стоит только когда возврат приводит к реальному вреду: потеря длинной несохранённой формы или отмена необратимого действия. Если пользователь может вернуться и продолжить безопасно, не добавляйте трение.
Практический подход: оставьте системную навигацию, но показывайте подтверждение только когда экран «грязный» (есть редактирования). Это означает обеспечить собственное действие Назад и спросить один раз, с ясным выходом.
@Environment(\.dismiss) private var dismiss
@State private var showLeaveConfirm = false
let hasUnsavedChanges: Bool
var body: some View {
Form { /* поля */ }
.navigationBarBackButtonHidden(hasUnsavedChanges)
.toolbar {
if hasUnsavedChanges {
ToolbarItem(placement: .navigationBarLeading) {
Button("Back") { showLeaveConfirm = true }
}
}
}
.confirmationDialog("Discard changes?", isPresented: $showLeaveConfirm) {
Button("Discard", role: .destructive) { dismiss() }
Button("Keep Editing", role: .cancel) {}
}
}
Не превращайте это в западню:
- Спрашивайте только когда можете объяснить последствие в одном коротком предложении.
- Предлагайте безопасный вариант (Отмена, Продолжить редактирование) и ясный выход (Удалить, Выйти).
- Не скрывайте кнопки Назад, если вы не заменяете их очевидной кнопкой Назад или Закрыть.
- Предпочитайте подтверждение необратимого действия (например «Утвердить»), а не блокировку навигации повсеместно.
Если вы часто боретесь с жестом Назад, это обычно означает, что потоку нужны автосохранение, сохранённый черновик или более мелкие шаги.
Распространённые ошибки, создающие странный стек Назад
Большинство «почему он вернулся туда?» багов — не проявление произвольности SwiftUI. Обычно это паттерны, которые делают состояние навигации нестабильным. Для предсказуемого поведения относитесь к стеку Назад как к данным приложения: стабильно, тестируемо и управляемо из одного места.
Случайные дополнительные стеки
Частая ловушка — оказаться с несколькими NavigationStack без осознания этого. Например, каждая вкладка имеет свой корневой стек, а дочерний view добавляет ещё один стек внутри потока. В результате получается смазанное поведение Назад, отсутствующие navigation bar или экраны, которые не попадают при попе.
Другой часто встречающийся баг — частое пересоздание NavigationPath. Если путь создаётся внутри view, которое ререндерится, он может сброситься при изменениях состояния и вернуть пользователя к шагу 1 после ввода в поле.
Ошибки, стоящие за странными стеками, обычно просты:
- Вложение
NavigationStackвнутри другого стека (часто в табах или контенте модала). - Реинициализация
NavigationPath()при обновлениях view вместо хранения его в долгоживущем состоянии. - Помещение нестабильных значений в маршрут (например, модель, которая меняется), что ломает
Hashableи вызывает несоответствие destinations. - Разбрасывание решений навигации по обработчикам кнопок так, что никто толком не может объяснить, что значит «следующий».
- Управление потоком из нескольких источников одновременно (например, view model и view оба мутируют путь).
Если нужно передавать данные между шагами, предпочитайте стабильные идентификаторы в маршруте (ID, enum шага) и храните реальные данные в общем состоянии.
Конкретный пример: если ваш маршрут .profile(User) и User меняется по мере ввода, SwiftUI может воспринять это как другой маршрут и перешить стек. Сделайте маршрут .profile и храните черновой профиль в общем состоянии.
Быстрый чеклист для предсказуемой навигации
Когда поток ощущается странно — обычно потому, что стек Назад не рассказывает ту же историю, что и пользователь. Пройдитесь по правилам навигации перед финальной шлифовкой UI.
Тестируйте на реальном устройстве, а не только в превью, и пробуйте как медленные, так и быстрые нажатия. Быстрые нажатия часто выявляют дублирующие пуши и потерю состояния.
- Идите назад шаг за шагом от последнего экрана к первому. Убедитесь, что каждый экран показывает те же данные, что ввёл пользователь.
- Триггерьте Отмена с каждого шага (включая первый и последний). Убедитесь, что это всегда возвращает в разумное место, а не на случайный предыдущий экран.
- Принудительно закройте приложение в середине потока и запустите заново. Убедитесь, что можно безопасно возобновить — либо восстановив путь, либо стартовав с известного шага с предзаполнением из сохранённых данных.
- Откройте поток через глубокую ссылку или ярлык приложения. Проверьте, что целевой шаг валиден; если требуемых данных нет — перенаправьте на самый ранний шаг, который может их собрать.
- Закончите с «Готово» и убедитесь, что поток убран чисто. Пользователь не должен иметь возможность нажать Назад и снова попасть в завершённый мастер.
Простой способ протестировать: представьте онбординг с тремя экранами (Профиль, Разрешения, Подтвердить). Введите имя, идите дальше, вернитесь, отредактируйте, затем прыгните в Подтвердить через глубокую ссылку. Если Подтвердить показывает старое имя, или Назад ведёт к дублированному экрану Профиля — обновления пути неконсистентны.
Если вы проходите чеклист без сюрпризов, поток будет казаться спокойным и предсказуемым, даже если пользователи уйдут и вернутся позже.
Реалистичный пример и следующие шаги
Представьте поток менеджера для одобрения заявки на расходы. Есть четыре шага: Просмотр, Редактирование, Подтверждение и Квитанция. Пользователь ожидает одно: Назад всегда возвращает на предыдущий шаг, а не на какой‑то случайный экран, который он посещал раньше.
Небольшой enum маршрутов сохраняет предсказуемость. NavigationPath должен хранить только маршрут и лёгкие идентификаторы для подгрузки состояния, например expenseID и mode (review vs edit). Избегайте пушить большие изменяемые модели в путь — это делает восстановление и глубокие ссылки хрупкими.
Держите рабочий черновик в единственном источнике правды вне view, например в @StateObject модели потока (или в store). Каждый шаг читает и пишет эту модель, чтобы экраны могли появляться и исчезать, не теряя вводов.
Минимум, что вам нужно отслеживать:
- Маршруты (например:
review(expenseID),edit(expenseID),confirm(expenseID),receipt(expenseID)). - Данные (черновой объект со строками расходов и заметками, плюс статус:
pending,approved,rejected). - Расположение (черновик в модели потока, каноническая запись на сервере и маленький токен для восстановления локально: expenseID + последний шаг).
Крайние случаи — те, где потоки либо завоёвывают доверие, либо его теряют. Если менеджер отклоняет на этапе Подтверждения, решите: возвращает ли Назад к Редактированию (чтобы исправить) или выходит из потока. Если он вернётся позже, восстановите последний шаг по сохранённому токену и подгрузите черновик. Если он переключится на другое устройство — считайте сервер эталоном: реконструируйте путь из статуса на сервере и отправьте на нужный шаг.
Следующие шаги: документируйте enum маршрутов (что означает каждый case и когда он используется), добавьте пару базовых тестов для сборки пути и поведения восстановления, и придерживайтесь одного правила: view-ы не владеют решениями навигации.
Если вы строите такие же многошаговые потоки, но не хотите писать всё с нуля, платформы вроде AppMaster (appmaster.io) применяют ту же идею разделения: держите навигацию шагов и бизнес‑данные отдельно, чтобы экраны можно было менять, не ломая прогресс пользователя.
Вопросы и ответы
Используйте NavigationStack с единственным NavigationPath, которым вы управляете. Запушьте ровно один маршрут для действия «Далее» и удаляйте ровно один маршрут при действии Назад. Когда нужно перепрыгнуть (например «Редактировать профиль» из обзора), обрежьте путь до известного шага вместо того, чтобы запушивать дополнительные экраны.
Потому что SwiftUI восстанавливает экраны из значения маршрута, а не из живого экземпляра view. Если данные формы хранятся в @State внутри view, они могут сброситься при пересоздании view. Перенесите черновые данные в общий модельный объект (например, ObservableObject), который живёт вне запушенных view.
Обычно это происходит, когда вы добавляете один и тот же маршрут несколько раз (часто из‑за быстрых нажатий или нескольких кодовых путей, вызывающих навигацию). Отключайте кнопку Далее пока идёт навигация или в процессе валидации/загрузки, и централизуйте мутации пути, чтобы на каждый шаг происходило ровно одно append.
Держите в маршруте компактные и стабильные значения — enum‑слой и лёгкие идентификаторы. Мутируемые данные (черновик) храните в отдельном общем объекте и при необходимости ищите их по ID. Запихивание больших изменяющихся моделей в путь ломает Hashable-ожидания и приводит к несоответствию назначений.
Навигация — это «где пользователь сейчас», состояние потока — это «что он ввёл». Держите путь навигации в роутере (или в одном верхнеуровневом состоянии), а черновик — в отдельном ObservableObject. Каждый экран редактирует черновик; роутер только изменяет шаги.
Обрабатывайте внешнюю ссылку как инструкцию по сборке корректной последовательности шагов, а не как телепорт на один экран. Постройте путь, добавляя сначала необходимые предварительные шаги (на основе того, что у пользователя уже есть), а затем добавьте целевой шаг. Так стек Назад останется целостным и не создаст некорректного состояния.
Сохраните две вещи: последний значимый маршрут (или идентификатор шага) и черновые данные. При перезапуске восстановите путь, используя те же проверки предусловий, что и для deep links, затем подгрузите черновик. Если черновик старый, обычно менее удивительно начать поток заново, но автоматически предзаполнить поля из сохранённого черновика.
Пушьте экраны для основного пошагового пути, чтобы кнопка Назад естественно возвращала по шагам. Используйте sheets для опциональных задач и fullScreenCover для отдельного опыта (вход, камера). Не кладите ключевые шаги в модальные представления: жесты отключения модала могут рассинхронизировать UI и состояние потока.
Не перехватывайте Назад по умолчанию: дайте системе обрабатывать это. Делайте подтверждение ухода только когда возврат реально приводит к потере значительной несохранённой работы, и только если экран действительно «грязный». Предпочитайте автосохранение или сохранение черновика, если вам часто приходится блокировать навигацию.
Чаще всего причины — вложенные NavigationStack, пересоздание NavigationPath при перерисовках и несколько владельцев, мутирующих путь. Держите один стек на поток, храните путь в долгоживущем состоянии (@StateObject или отдельный роутер) и централизуйте логику push/pop в одном месте.


