Давайте пропустим известные проблемы с навигацией в 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.