Нативная 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")
            }
        }
    }
}

При нажатии на цвет открывается новый экран с ним.

Colors screen Color screen

Однако и в таком виде роутинг сильно связан с вьюхами. Поэтому займемся унификацией и выносом логики вовне.

Для начала определим общий протокол роутеров.

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")
        }
    }
}