Удобная навигация в SwiftUI для iOS 16 и выше

от автора

Не будем мусолить всем известную проблему с навигацией в SwiftUI до 16 iOS, так как уже много крутых статей на эту тему есть в открытом доступе. Близится релиз 18 iOS, а это значит, что минимальные таргеты поднимутся на единичку ближе к 16 ?

В данной статье хочу представить на `мой взгляд` наиболее удобную реализацию навигации на NavigationStack. Разумеется в концепции старого доброго MVVM, поэтому фанаты UDF извините ?

Итак перейдем к реализации

В основе всего у нас будет лежать моделька с экранами. Пихаем внутрь все, что требуется в этих вьюхах и погнали творить настоящий рок-н-ролл.

/// Например такие роуты enum Route {   case goToServices(PasswordViewModel, PasswordTagViewModel)   case addPassword(Service, PasswordViewModel, PasswordTagViewModel)   case editPassword(PasswordViewModel, PasswordTagViewModel) }

После того, как мы определились с экранами мы переходим к логике роутинга. Для этого нам понадобится какой-нибудь класс, подписанный под ObservableObject. Помечаем его как @MainActor сразу, чтобы не тыкать всякие инструменты синхронизации по типу NSLock().

@MainActor будет гарантировать то, что все методы и свойства в классе Router будут выполняться на главном потоке. Это исключает возможность гонок данных при доступе к path или другим свойствам класса.

@MainActor final class Router: ObservableObject {   // MARK: - Public properties      @Published var path = NavigationPath()   // Сюда уже добавляете любой путь по которому хотите навигироваться   // Все зависит от сложности вашего проекта }

После всех этим махинаций напишем функцию, которая будет возвращать нужный нам экран исходи из заданного enum . Разумеется вы можете написать нужное вам количество таких функций, а не пихать все в одну. Все максимально scalable ?

    @ViewBuilder     func view(for route: Route) -> some View {         switch route {         case let .goToServices(passwordViewModel, passwordTagViewModel):             ServicesView(                 passwordViewModel: passwordViewModel,                 passwordTagViewModel: passwordTagViewModel             )         case let .addPassword(service, passwordViewModel, passwordTagViewModel):             AddPasswordView(                 service: service,                 passwordViewModel: passwordViewModel,                 passwordTagViewModel: passwordTagViewModel             )         case let .editPassword(passwordViewModel, passwordTagViewModel):             EditPasswordView(                 passwordViewModel: passwordViewModel,                 passwordTagViewModel: passwordTagViewModel             )         }     }

Осталось дело за малым, теперь опишем основную логику роутинга.

    @inlinable     @inline(__always)     func push(_ appRoute: Route) {         path.append(appRoute)     }          @inlinable     @inline(__always)     func pop() {         guard !path.isEmpty else { return }         path.removeLast()     }          @inlinable     @inline(__always)     func popToRoot() {         path.removeLast(path.count)     }

Самое страшное позади ?‍? Теперь, чтобы эта вундервайля начала функционировать, нам потребуется написать корневую роутинг вьюху, которую напишем один раз в жизни и забудем

struct RouterView<Content: View>: View {     @inlinable     init(@ViewBuilder content: @escaping () -> Content) {         self.content = content()     }      var body: some View {         NavigationStack(path: $router.path) {             content                 .navigationDestination(for: Router.Route.self) {                     router.view(for: $0)                         .navigationBarBackButtonHidden()                 }         }         .environmentObject(router)     }      @StateObject private var router = Router()     private let content: Content }

Если кто не знал, то модификатор .navigationBarBackButtonHidden() ломает немножко нативеый выход с экрана по свайпу. Благо я знаю способ, как это исправить. Так как большая часть SwiftUI под капотом все еще является UIKit`от, то решение будет достаточно простым:

 extension UINavigationController {     override open func viewDidLoad() {         super.viewDidLoad()         interactivePopGestureRecognizer?.delegate = nil     } }

Отлично, а теперь давайте потыкаем это палкой.

Как теперь внедрить это в код и использовать

Я надеюсь у вас есть какая-то рутовая вьюшка, если ее нет, то придется создать.

import SwiftUI  struct RootView: View {     var body: some View {         RouterView {             // Пихаем сюда нужные экраны, пишем логику показа и успех             ZStack {                 TabBarView()                     .toolbar(.hidden, for: .navigationBar)             }         }         .task {             isNeedUpdate = await appUpdateManager.isUpdateRequired()         }         .sheet(isPresented: $isNeedUpdate) {             VStack {                 Text("Надо обновиться")             }             .interactiveDismissDisabled()         }     }      // На это не обращаем внимание, тут какая-то      @State private var isNeedUpdate = false     private let appUpdateManager = AppUpdateManagerImpl() }

Настало время уже наконец потыкать наш роутер. Так как мы умные ребята и не хотим пропихивать в каждый экран ObservedObject , то прибегнем к использованию прекрасного EnvironmentObject. В дальнейшем никакие модификаторы .environmentObject(_) не понадобятся, ибо мы сделали это в роут вьюхе ?

import SwiftUI  struct TestView: View {     var body: some View {         VStack {             Button {                 router.push(                     .service(                         passwordViewModel,                         passwordTagViewModel                     )                 )             } label: {                 ZStack {                     RoundedRectangle(cornerRadius: 20)                         .fill(.blue)                         .frame(height: 60)                                          Text("Перейти на экран с сервисами")                         .font(                             .system(                                 size: 16,                                 weight: .bold,                                 design: .rounded                             )                         )                         .foregroundStyle(.white)                 }             }              Button {                 router.push(                     .addPassword(                         service,                         passwordViewModel,                         passwordTagViewModel                     )                 )             } label: {                 ZStack {                     RoundedRectangle(cornerRadius: 20)                         .fill(.blue)                         .frame(height: 60)                                          Text("Перейти на экран добавления пароля")                         .font(                             .system(                                 size: 16,                                 weight: .bold,                                 design: .rounded                             )                         )                         .foregroundStyle(.white)                 }             }              Button {                 router.push(                     .editPassword(                         passwordViewModel,                         passwordTagViewModel                     )                 )             } label: {                 ZStack {                     RoundedRectangle(cornerRadius: 20)                         .fill(.blue)                         .frame(height: 60)                                          Text("Перейти на экран редактирования пароля")                         .font(                             .system(                                 size: 16,                                 weight: .bold,                                 design: .rounded                             )                         )                         .foregroundStyle(.white)                 }             }         }         .padding(.horizontal)     }          @StateObject private var passwordViewModel = PasswordViewModel(manager: .shared)     @StateObject private var passwordTagViewModel = PasswordTagViewModel(manager: .shared)     @EnvironmentObject private var router: Router     private var service = PasswordService(id: .zero, title: "Habr", url: "habr.com", icon: Data()) } 

В нужных местах юзаем кнопки для возврата к предыдущему экрану и сброс до корневой вьюхи

router.pop() router.popToRoot()

Пример использования в продакшене

За все время перепробовал массу способов навигации, но в итоге эта оказалась самая удачная и приятная в имплементации лично для меня. Данный пример хорошо расширяется в любую горизонталь и вертикаль и отлично покрывается UI и Unit тестами.

Пример использовании на одном из проектов:

Здесь можешь почитать много всякого интересного про iOS и SwiftUI — публикую интересные статьи, лучшие практики, типсы, анимации и прочие крутые штуки. Присоединяйся

Еще веду канал с вакансиями для мобильных разработчиковПрисоединяйся


ссылка на оригинал статьи https://habr.com/ru/articles/830392/