Нативная SwiftUI-навигация с NavigationPath
Изначально возможности стэк-навигации в SwiftUI были очень скромными. Перейти на другой экран в рамках NavigationStack можно было лишь через создание NavigationLink во вьюхе, что жестко связывало с ней роутинг.
С выходом iOS 16 разработчикам предоставили более гибкий способ, с использованием нового объекта NavigationPath. Он представляет собой список любых Hashable-значений, и определяет актуальный стэк навигации. Чтобы показать следующий экран, требуется добавить в него значение, чтобы вернуться назад удалить.
При добавлении нового значения, у корневой вьюхи NavigationStack вызывается модификатор navigationDestination, который должен обработать его, и вернуть необходимое для отображения представление.
struct ContentView: View {
private let columns = [
GridItem(.flexible()),
GridItem(.flexible())
]
private let colors: [Color] = [
.red,.gray,
.green, .yellow,
.blue, .purple,
.indigo, .orange
]
@State private var navigationPath = NavigationPath()
var body: some View {
NavigationStack(path: $navigationPath) {
ScrollView {
LazyVGrid(columns: columns) {
ForEach(colors, id: \.self) { color in
color
.aspectRatio(1, contentMode: .fill)
.cornerRadius(8)
.onTapGesture {
navigationPath.append(color)
}
}
}
.padding()
}
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("Colors")
.navigationDestination(for: Color.self) { color in
color
.edgesIgnoringSafeArea(.bottom)
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("Color")
}
}
}
}
При нажатии на цвет открывается новый экран с ним.
Однако и в таком виде роутинг сильно связан с вьюхами. Поэтому займемся унификацией и выносом логики вовне.
Для начала определим общий протокол роутеров.
protocol Router: AnyObject {
associatedtype Route: Hashable
associatedtype View: SwiftUI.View
var path: NavigationPath { get set }
func makeView(for route: Route) -> Self.View
func route(to route: Route)
}
extension Router {
func route(to route: Route) {
path.append(route)
}
}
И создадим модификатор routing, через который будем задавать вьюхам соответствующий роутер.
struct RoutingViewModifier<ConcreteRouter: Router>: ViewModifier {
private let router: ConcreteRouter
init(with router: ConcreteRouter) {
self.router = router
}
func body(content: Content) -> some View {
content
.navigationDestination(for: ConcreteRouter.Route.self) { route in
router.makeView(for: route)
}
}
}
extension View {
func routing(with router: some Router) -> some View {
modifier(RoutingViewModifier(with: router))
}
}
Наконец, сам роутер.
class ColorsRouter: Router, ObservableObject {
enum Route: Hashable {
case color(Color)
}
@Published var path = NavigationPath()
func makeView(for route: Route) -> some View {
switch route {
case let .color(color):
return color
.edgesIgnoringSafeArea(.bottom)
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("Color")
}
}
}
Связываем все вместе. Теперь роутинг вынесен в свое место, и его расширение не потребует кардинальных изменений во вьхах.
struct ContentView: View {
private let columns = [
GridItem(.flexible()),
GridItem(.flexible())
]
private let colors: [Color] = [
.red,.gray,
.green, .yellow,
.blue, .purple,
.indigo, .orange
]
@StateObject private var colorsRouter = ColorsRouter()
var body: some View {
NavigationStack(path: $colorsRouter.path) {
ScrollView {
LazyVGrid(columns: columns) {
ForEach(colors, id: \.self) { color in
color
.aspectRatio(1, contentMode: .fill)
.cornerRadius(8)
.onTapGesture {
colorsRouter.route(to: .color(color))
}
}
}
.padding()
}
.routing(with: colorsRouter)
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("Colors")
}
}
}