Не будем мусолить всем известную проблему с навигацией в 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 — публикую интересные статьи, лучшие практики, типсы, анимации и прочие крутые штуки Присоединяйся

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

Комментарии (3)


  1. krakatoor
    23.07.2024 13:36
    +1

    А если хочется, чтобы за навигацию отвечала viewModel, а не View?


    1. sandytwixgg Автор
      23.07.2024 13:36

      Так по сути Router и является ViewModel. Там же пишем всю бизнес логику


    1. sandytwixgg Автор
      23.07.2024 13:36

      RouterView - контейнер для вьюх, он нужен для того, чтобы один раз написать логику с прикидкой дестинейшенов и забыть, ну и Environment прокинуть, чтобы на каждую вручную не пихать самостоятельно