Не будем мусолить всем известную проблему с навигацией в 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 — публикую интересные статьи, лучшие практики, типсы, анимации и прочие крутые штуки → Присоединяйся
Еще веду канал с вакансиями для мобильных разработчиков → Присоединяйся
krakatoor
А если хочется, чтобы за навигацию отвечала viewModel, а не View?
sandytwixgg Автор
Так по сути
Router
и является ViewModel. Там же пишем всю бизнес логикуsandytwixgg Автор
RouterView - контейнер для вьюх, он нужен для того, чтобы один раз написать логику с прикидкой дестинейшенов и забыть, ну и Environment прокинуть, чтобы на каждую вручную не пихать самостоятельно