Одно из самых проблемных мест SwiftUI — работа с навигацией. Отсутствие доступа к стеку навигации, невозможность разделить UI-слой и слой навигации, сложность создания диплинков — всё это затрудняло работу многим разработчикам, использующим SwiftUI в своих проектах. В iOS 16 появился совершенно новый API для работы с навигацией. Что же изменилось с его появлением и стало ли лучше?

Я — Светлана Гладышева, iOS-разработчик компании Surf. В статье расскажу про навигацию в Swift UI и её основные проблемы. Сравним работу с навигацией до появления iOS 16 и после.

Как было реализовано раньше

Чтобы лучше понимать изменения, давайте посмотрим, как навигация осуществлялась в старых версиях Swift UI.

Основным компонентом навигации был NavigationView — контейнер для управления переходами. Для перехода на другой View использовалась NavigationLink. Например, создать кнопку с текстом «Details», ведущую на экран DetailsView, можно было так:

NavigationView {
    NavigationLink(destination: DetailsView()) {
        Text(“Details")
    }
}

Тут в параметре destination передаём то View, куда хотим перейти.

Также существовали способы управлять переходами программно. Можно было использовать параметр isActive у NavigationView, в котором передавался Binding на Bool-свойство:

struct ContentView: View {
    @State private var isShowingDetailsView = false

    var body: some View {
        NavigationView {
            VStack {
                NavigationLink(destination: DetailsView(), isActive: $isShowingDetailsView) { 
                    EmptyView()
                }
                Button("Details") {
                    self.isShowingDetailsView = true
                }
            }
        }
    }
}

В этом примере при нажатии на кнопку переменная isShowingDetailsView устанавливается в true, и происходит переход в DetailsView. В NavigationLink мы при этом передаем EmptyView, поскольку нам не нужно, чтобы NavigationLink тоже была видна на экране.

Была возможность управлять и обратным переходом. Если в этом примере переменную isShowingDetailView установить в false, вернёмся на первый View.

Переменных типа Bool могло быть недостаточно, поэтому можно было управлять навигацией через теги:

struct ContentView: View {
    @State private var selection: String? = nil

    var body: some View {
        NavigationView {
            VStack {
                NavigationLink(destination: Text("View 1"), tag: "View1", selection: $selection) { EmptyView() }
                NavigationLink(destination: Text("View 2"), tag: "View2", selection: $selection) { EmptyView() }
                Button("Show View 1") {
                    self.selection = "View1"
                }
                Button("Show View 2") {
                    self.selection = "View2"
                }
            }
        }
    }
}

Для каждой NavigationLink добавляем свой тег, а также передаем Binding на свойство selection. Если значение в selection совпадает с тегом у NavigationLink, происходит переход. Если мы после этого установим selection в nil, то вернёмся назад на исходный экран. Selection не обязательно должен был быть типа String, это мог быть любой Hashable-тип.

Проблемы, которые были раньше

Возможности навигации были ограничены. Навигация была сильно связана с UI-слоем: отделить их друг от друга было практически невозможно. На всех экранах, на которых требовалось сделать переходы, нужно было добавить нужные NavigationLink. Во всех NavigationLink —  обязательно прописать destination, при этом поменять их программно не могли. Из-за этого невозможно было полностью вынести навигацию, например, в роутер.

Не получалось легко переходить на несколько уровней вперёд или назад: не было прямого доступа к стеку навигации. Да, мы могли пользоваться параметрами isActive и selection у NavigationLink и управлять их изменениями программно. Например, для простого перехода на root-экран нужно было создать Binding-свойство isActive, привязать его к NavigationLink в первом экране, передать её в нужный View и в нужный момент выставить isActive в false

Но если надо было сделать много экранов и много переходов с экрана на экран, количество таких Binding-переменных бы сильно разрослось: управлять ими стало очень сложно.

Сложность с созданием диплинков. Возьмем для примера список продуктов: в нём по клику на продукт нужно перейти на экран продукта.

struct ContentView: View {
    @State var products: [Product] = []
    @State var productSelection: Int?

    var body: some View {
        NavigationView {
            List {
                ForEach(products, id: .id) { product in
                    NavigationLink(
                        destination: ProductView(product: product),
                        tag: product.id,
                        selection: $productSelection
                    ) {
                        ProductRow(product: product)
                    }
                }
            }
        }
    }
}

Предположим, что после обработки диплинка нужно перейти в детали последнего элемента списка. Можно сделать это так:

func openLastProduct() {
    productSelection = products.last
}

Но если мы запустим этот код для большого количества продуктов, то увидим, что ничего не происходит. Нужный переход произойдет только тогда, когда проскроллим список до конца. Но это не то поведение, которое было нужно.

Это несколько наиболее значимых проблем, но список далеко не полный. Для всех этих проблем существовало и другое решение: SwiftUI можно связать со старым добрым UIKit. Поэтому для навигации часто использовали UINavigationController из UIKit. Но всё-таки хотелось бы иметь решение, не зависящее от UIKit.

Что изменилось с появлением iOS 16

NavigationView стал deprecated: вместо него теперь нужно использовать NavigationStack, который позволяет помещать View в стек навигации и убирать из него.

В NavigationLink добавились несколько новых конструкторов: теперь можно передавать параметр value вместо параметра destination. Value — это некий идентификатор того, куда будем переходить. На Value есть ограничение — его тип должен быть Hashable.

Чтобы определить, куда же переходить, используется модификатор navigationDestination. Его нужно указать для всех value, которые хотим использовать. Пример:

NavigationStack {
    List(products) { product in
        NavigationLink(product.name, value: product)
    }
    .navigationDestination(for: Product.self) { product in
        ProductView(product: product)
    }
}

Старые init-методы NavigationLink, в которых есть параметр destination, стали deprecated.

Ещё больше возможностей даёт использование NavigationPath: в конструкторе у NavigationStack можно указать параметр path. Можно передать туда непосредственно NavigationPath, а можно коллекцию, хранящую значения типа Hashable. Например, это может быть массив продуктов:

struct ContentView: View {
    @State var products: [Product]
    @State var path = [Product]()
    
    var body: some View {
        NavigationStack(path: $path) {
            List(products) { product in
                ProductRow(product: product)
                    .onTapGesture {
                        path.append(product)
                    }
            }
            .navigationDestination(for: Product.self) { product in
                ProductView(product: product)
            }
        }
    }
}

При добавлении в path нового элемента будет происходить переход на тот View, который мы прописали в navigationDestination. При удалении N элементов из path будет происходить переход назад на N шагов.

В итоге можно полностью управлять навигацией программно, без использования NavigationLink.

Какие проблемы теперь решились

Появилась возможность легкого управления стеком навигации. Стало очень легко перейти на несколько экранов вперёд или назад. Можно легко вернуться в корневой view. Создание диплинков теперь тоже не представляет особой сложности.

Навигация теперь легко выносится из UI-слоя в роутеры или координаторы. Да, нам всё равно придётся во View прописать navigationDestination для всех возможных элементов из NavigationPath, а также указать все View для переходов, но дальше всей навигацией можно управлять извне.

Пример использования новой навигации

Предположим, нужно написать iOS-приложение для интернет-магазина. В таком приложении обычно есть список товаров, корзина, в которую можно добавить товары, выбор адреса и подтверждения заказа. Для простоты оставим только корзину с товарами, выбор адреса и подтверждение заказа.

Из корзины можем перейти на экран товара и на экран выбора адреса. С экрана выбора адреса мы должны переходить назад либо на экран подтверждения заказа. С экрана подтверждения заказа мы должны вернуться обратно в корзину.

Давайте посмотрим, как можно реализовать переходы между экранами для такого приложения. Полный код приложения можно посмотреть в моём репозитории.

Создадим enum Route, в котором будут перечислены все необходимые переходы:

enum Route: Hashable {
    case product(Product)
    case address
    case orderConfirmation
}

Этот enum должен быть Hashable, поскольку он будет использоваться в NavigationPath.

Далее создадим класс Router, в котором пропишем все возможные изменения path:

final class Router: ObservableObject {
    static let shared = Router()

    @Published var path = [Route]()
    
    func showProduct(product: Product) {
        path.append(.product(product))
    }

    func showAddress() {
        path.append(.address)
    }
    
    func showOrderConfirmation() {
        path.append(.orderConfirmation)
    }
    
    func backToRoot() {
        path.removeAll()
    }
    
    func back() {
        path.removeLast()
    }
}

Далее прямо в App можем добавить NavigationStack и прописать navigationDestination:

@main
struct NavigationTestApp: App {
    @ObservedObject var router = Router.shared
    
    var body: some Scene {
        WindowGroup {
            NavigationStack(path: $router.path) {
                buildCartView()
                .navigationDestination(for: Route.self) { route in
                    switch route {
                        case .product(let product):
                            buildProductView(for: product)
                        case .address:
                            buildAddressView()
                        case .orderConfirmation:
                            buildOrderConfirmationView()
                    }
                }
            }
        }
    }
}

Тут мы связываем View и Router, используя path из Router в качестве параметра для NavigationStack. Также в этом View прописан navigationDestination для Route и указано, на какие именно View будут происходить переходы для всех возможных Route.

Теперь из ViewModel можем вызывать методы роутера:

class CartViewModel: ObservableObject {
    private let router: Router
    
    init(router: Router) {
        self.router = router
    }
    
    func showProduct(product: Product) {
        self.router.showProduct(product: product)
    }
    
    func buy() {
        self.router.showAddress()
    }
}

А View для экрана корзины теперь может выглядеть так:

struct CartView: View {
    @ObservedObject var viewModel: CartViewModel
    
    var body: some View {
        VStack {
            List {
                ForEach(viewModel.products, id: \.self) { product in
                    ProductRow(product: product)
                        .onTapGesture {
                            viewModel.showProduct(product: product)
                        }
                }
            }
       	 
            Button("Buy") {
                viewModel.buy()
            }
        }
        .navigationTitle("Cart")
    }
}

Как видите, здесь нет ни NavigationLink, ни navigationDestination. Мы просто вызываем методы ViewModel.

Таким образом, навигацией в этом приложении полностью управляет Router. 

Если понадобится добавлять диплинки, сделать это будет очень просто — с помощью методов роутера. Например, если нужно добавить диплинк на продукт, подойдёт метод router.showProduct().

Если хотим использовать координаторы, всё становится немного сложнее. В navigationDestination мы должны указывать View. Поэтому, если наш координатор будет сам создавать модули экранов, нужно будет в navigationDestination получать View из координатора. Кроме того, NavigationStack не предоставляет возможность изменить корневой View, поэтому подменить его из координатора не получится.


К сожалению, использовать новый API в iOS 15 и в более ранних версиях не получится. В остальном — работать с навигацией в SwiftUI теперь действительно стало легче и удобней. Хочется, чтобы SwiftUI и дальше развивался и становился все лучше.

Чтобы глубже погрузиться в тему, рекомендую видео The SwiftUI cookbook for navigation с WWDC 2022. В нём помимо работы с NavigationStack и NavigationPath наглядно показывается работа с NavigationSplitView, а также сохранение и восстановление состояния навигации.

Комментарии (1)


  1. dendude
    12.08.2022 15:34
    +1

    Огромнейшая проблема (или фича) Apple в том, что нет обратной совместимости, таким образом пару лет нужно юзать старый код, облизываясь новым. Иос 15 например поддерживает те же устройства, что иос 13. А вот иос 16 выбросил некоторые устройства. Для больших приложений это существенно.