Давайте пропустим известные проблемы с навигацией в SwiftUI до iOS 16, ведь об этом уже написано множество отличных статей. С релизом iOS 18 минимальные цели скоро сместятся ближе к iOS 16.
В этой статье я хочу представить, на мой взгляд, наиболее удобную реализацию навигации с помощью NavigationStack, причем в рамках привычной архитектуры MVVM. Извиняюсь перед поклонниками UDF.
Реализация
Наш подход будет основана на модели экрана, где мы можем разместить все необходимое для его представлений — давайте создадим немного рок-н-ролла!
/// Example of routes
enum Route {
case goToServices(PasswordViewModel, PasswordTagViewModel)
case addPassword(Service, PasswordViewModel, PasswordTagViewModel)
case editPassword(PasswordViewModel, PasswordTagViewModel)
}
После определения экранов мы переходим к логике маршрутизации. Для этого нам нужен класс, соответствующий ObservableObject. С самого начала мы пометим его атрибутом @MainActor, чтобы избежать использования средств синхронизации, таких как NSLock(). Атрибут @MainActor гарантирует, что все методы и свойства класса Router будут выполняться в главном потоке, предотвращая проблемы гонки данных при обращении к пути или другим свойствам.
final class Router: ObservableObject {
// MARK: - Public properties
@Published var path = NavigationPath()
// Any path you want to navigate to is already added here.
}
После того как все настроено, мы напишем функцию, которая возвращает нужный экран на основе заданного перечисления. Конечно, вы можете создать столько функций, сколько вам нужно, вместо того чтобы впихивать все в одну. Все это создано для того, чтобы быть максимально масштабируемым.
@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 {
// We put the necessary screens here,
// write the display logic, and success!
ZStack {
TabBarView()
.toolbar(.hidden, for: .navigationBar)
}
}
.task {
isNeedUpdate = await appUpdateManager.isUpdateRequired()
}
.sheet(isPresented: $isNeedUpdate) {
VStack {
Text("Update app")
}
.interactiveDismissDisabled()
}
}
// This is the code section with my variables.
// You don't need to implement it.
@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("Services")
.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("Add Password")
.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("Edit Password")
.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: "Medium", url: "medium.com", icon: Data())
}
В нужных местах используйте кнопки для перехода к предыдущему экрану и для возврата к корневому представлению.
router.pop() router.popToRoot()
Опыт использования в продакшене
Со временем я перепробовал множество методов навигации, но этот оказался самым удачным и приятным для меня в реализации. Этот пример хорошо масштабируется как по горизонтали, так и по вертикали и отлично подходит для покрытия UI и Unit-тестами.
Больше интересных вещей можно найти на моем LinkedIn.

