Реализация нового NavigationStack программно и без NavigationLink

Только что пересмотрев эту навигацию для SwiftUI 3 здесь (которая обновила исходный подход для SwiftUI 1 здесь), Apple с тех пор переосмыслила навигацию с новым NavigationStack как часть последней версии SwiftUI 4. Это отличная новость… и она охватывает большинство моих предыдущих предложений!

Ранее NavigationView требовало явного определения «граней» навигации и использования нескольких флагов, что могло привести к путанице. В новом подходе используется стек, создающий представление существующей навигации без пользовательского интерфейса, и он прекрасно работает с нашим предыдущим программным подходом без особых изменений.

Этот подход изначально начался с обзора многоэкранного процесса онбординга с помощью SwiftUI. Как и все многоэкранные потоки ввода данных, они часто представляют собой интересную проблему разделения данных, представления и логики навигации.

Итак, что делает процесс многоэкранного ввода данных отличным? Вот что я придумал. За неимением более громкого термина я назову это своим «манифестом потока экрана». Я использую здесь «экран», а не вид, потому что мы явно имеем в виду полноэкранную навигацию.

  1. Экраны не должны иметь «родительских» знаний и не должны нести ответственность за навигацию внутрь или наружу.
  2. Индивидуальные модели просмотра для каждого экрана.
  3. Общая логика управления потоком отделена от реализации пользовательского интерфейса и может быть протестирована без пользовательского интерфейса.
  4. Гибкость и возможность перехода к различным экранам в потоке.
  5. Максимально простой, но компонуемый и масштабируемый.

Требования к навигации

Таким образом, онбординг может быть простым, возможно, два или три экрана запрашивают у пользователя простую личную информацию. Кнопка «Далее» будет перемещать пользователя вперед в потоке.

Однако обычно более типичным является более сложный поток с разветвлениями. Возможно, пользователь еще не готов поделиться всеми этими подробностями или, возможно, в зависимости от предыдущих ответов необходимы дополнительные сведения. Итак, может быть, это более репрезентативно:

Первоначальная реализация

Как упоминалось ранее, мы будем использовать NavigationStack. Это может быть привязано (двусторонняя привязка) к пути навигации. В нашей первой реализации с потоком всего из 3 экранов мы собираемся использоватьNavigationPath(), который представляет собой последовательность со стиранием типов. Мы добавим путь навигации в ориентированную на навигацию модель представления и передадим ее (подробнее позже).

Внутри NavigationStack мы определяем корневой вид (в нашем случае VStack с текстом и кнопкой). Это также содержит модификаторы назначения навигации, которые запускают реальную навигацию. Любое добавление к пути навигации укажет SwiftUI на соответствующий новый вид для нового экрана в зависимости от типа и выполнит анимацию push.

В этой реализации мы используем модель представления под названием FlowVM, которая управляет потоком навигации (отличается от моделей экранного представления). Эта модель представления содержит путь навигации, позволяющий нам инициировать реальную навигацию вне представлений (пункты 1 и 3 манифеста).

В нашем примере добавление целого числа к пути навигации поместит aContentView2 в стек, а добавление строки к пути поместит ContentView3. Теперь простая манипуляция только с путем навигации (который является последовательностью) будет напрямую влиять на навигацию, предоставляя нам полный программный контроль. В этом заключается красота (и охватывает пункт 4 манифеста)!

  1. Push: добавление определенного типа.
  2. Вернуться к корню: повторная инициализация пути навигации.
  3. Назад 1 экран: Удалить последнее значение.

Мы также можем вернуться к нескольким экранам с помощьюremoveLast(x).

Вы также заметите, что нам больше не нужно использовать useNavigationLink. NavigationLink по-прежнему доступен в SwiftUI 4, но в основном он используется для навигации между представлениями — чего мы хотим избежать здесь!

Посмотреть модели и привязку

К пункту 2 манифеста — отдельные модели просмотра для каждого экрана. Использование и реализация моделей представления здесь могут различаться, и я слышал опасения по поводу чрезмерного использования шаблона проектирования MVVM в SwiftUI. Это, конечно, не термин, используемый в официальных документах Apple.

Что мне нужно, так это представление представления без пользовательского интерфейса, чтобы я мог четко инкапсулировать логику без пользовательского интерфейса, проводить модульное тестирование без представления и, конечно же, легко привязываться к представлению (в обоих направлениях). Он также должен быть специфичен для представления, чтобы представление можно было перемещать и оно не зависело ни от чего внешнего (т. е. компонуемое — пункт 5 манифеста). Это интерфейс представления для остальной части приложения. Я называю это моделью представления.

Внутри SwiftUIObservableObject (который на самом деле является частью Combine) создается хорошая модель представления, которая обеспечивает двустороннюю привязку представлений. Более новый подход с @StateObject создает стабильную модель представления, которая лениво загружается только при необходимости.

Также обратите внимание, что в этой версии модели представления события пользовательского интерфейса также передаются в модель представления из представления, и оттуда может запускаться любая логика представления (например, сетевые вызовы) (обычно обращение к уровню API для пример).

У нас также есть модель потокового представления (FlowVM) для управления навигацией между экранами. Он не знает представлений и предназначен для тестирования. Самому ему могут потребоваться вызовы API для определения пути следования. Обратите внимание, что это похоже на «координатор», но для меня это модель навигации, поэтому я использовал термин «модель просмотра».

Каждый экран также имеет отдельные модели просмотра. Эти модели просмотра экрана обрабатывают события пользовательского интерфейса и логику экрана. В конечном счете (после завершения всей экранной логики, например, после «следующего» нажатия) мы передаем управление обратно из моделей экранного представления в модель потокового представления, чтобы в конечном итоге решить, куда двигаться.

Для завершения, возвращаясь от моделей просмотра экрана обратно «вверх» к модели представления потока, мы можем использовать различные методы. Делегаты и обратные вызовы являются допустимыми реализациями, но мне нравится использовать PassthroughSubject Combine, возвращающий ссылку на саму модель представления экрана.

Таким образом, модель просмотра экрана и view хотели бы что-то вроде этого:

И подключен в модели представления потока для прослушивания событий завершения следующим образом, используя sink и сохраняя это в subscription. Вы заметите, что здесь обрабатывается фабричная функция для создания модели просмотра экрана, которая также добавляет прослушивание событий. Эта фабричная функция вызывается представлением потока при инициализации представления экрана.

sink вызывает метод напрямую для обработки любой логики (и навигации) и сохраняет подписку в наборе, прикрепленном к модели представления (который можно использовать для всех подписок).

Объединяем

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

Мы могли бы добавить модели представления непосредственно в NavigationPath и создать несколько модификаторов назначения навигации для каждого типа модели представления. Однако этот тип стертой последовательности предлагает только ограниченные возможности запроса (например, не может легко узнать, какой экран отображается в данный момент).

Вместо этого мы можем привязать стек навигации к простому массиву перечислений, который содержит связанное значение модели представления. Теперь путь представляет собой массив, у нас есть максимальный контроль и самоанализ его текущего состояния. Единственным требованием здесь является массив Hashable, что, в свою очередь, требует, чтобы модели представления были Hashable. Немного дополнительной работы здесь, но просто.

Проверьте репозиторий для полного кода. Это также включает примеры обратной навигации (включая возврат к корню или экрану два и т. д.) и соответствие Hashable.

«Максимальный контроль» также позволяет нам справляться с некоторыми интересными ситуациями, которые я не могу припомнить в UIKit. Вы можете изменить предыдущий экран на что-то совершенно другое (navigationPath[0] = ...), и теперь кнопка «Назад» переходит на другой экран. Или странным образом удаляя экран предыдущего экрана глубже в стеке (например, navigationPath.removeFirst() ). Это будет программно перемещаться назад с удалением первого экрана из стека. Возможно, последнее не имеет смысла, но мне нравится, как SwiftUI работает, как я и ожидал, даже в этих странных ситуациях. Молодец Эппл!

Тестирование

Большая часть нашего дизайна состоит в том, чтобы улучшить тестируемость и позволить проводить модульные тесты потока навигации независимо от пользовательского интерфейса (пункт 3 манифеста). Теперь с моделями просмотра это легко сделать. Вот пример:

Мы можем инициировать нажатие кнопки «Далее», а затем проверить, сработала ли логика навигации — и все это без фактического пользовательского интерфейса.

Обратите внимание, что это, очевидно, простая реализация. Если бы у моделей представления были вызовы API, нам пришлось бы подумать о какой-то инъекции, чтобы их имитировать. Кроме того, это явно не тест пользовательского интерфейса.

Мы также можем захотеть добавить некоторые тесты пользовательского интерфейса (возможно, с помощью тестирования моментальных снимков), но это выходит за рамки этой статьи.

И наконец…

Я надеюсь, что это имеет смысл! После 4 версий эта итерация push-навигации SwiftUI — это подход, который мы искали. Теперь это должно было ответить на большинство опасений сообщества в целом (а также на мои предыдущие предложения здесь).

Есть улучшения? Прямо сейчас нет очевидного способа создания пользовательской push-навигации. Еще одна большая идея заключается в создании единого конвергентного API навигации как для push-, так и для модальной навигации. Apple — ваши часы идут прямо сейчас! 😁

Полный код можно найти на https://github.com/nickm01/NavigationFlow. Наслаждаться!