Использование стека навигации SwiftUI для идеального поведения TabView

По стеку навигации (англ. Navigation Stack), представленному в iOS 16, опубликована масса статей. Однако большинство из них просто дублируют сказанное в документации Apple и похожи на пример приложения Colors от Apple. Эти материалы подходят для изучения основ, но их недостаточно для понимания того, как внедрить стек навигации в реальное приложение. 

К концу данного руководства мы разработаем подход на основе перечислений enum и объясним на конкретном примере, как совместить глубокую навигацию с ожидаемым поведением TabView

Приступим к работе, создав TabView:

struct TabScreenView: View {
//enum для Tab, добавление других вкладок по необходимости
enum Tab {
case home, goals, settings
}

@State private var selectedTab: Tab = .home

var body: some View {

TabView(selection: $selectedTab) {

HomeScreen()
.tabItem {
Label("Home", systemImage: "house")
}
.tag(Tab.home)

GoalsScreen()
.tabItem {
Label("Goals", systemImage: "flag")
}
.tag(Tab.goals)

SettingsScreen()
.tabItem {
Label("Settings", systemImage: "gear")
}
.tag(Tab.settings)
}
}
}

У нас есть базовый TabView, позволяющий переключаться между тремя экранами с помощью вкладок. Я добавил фиктивные данные в несколько представлений, связал их с главным экраном (англ. Home Screen) и получил следующий результат: 

Обратите внимание: когда я нахожусь на втором дочернем представлении (Second child view) и нажимаю кнопку вкладки, то она не возвращает на главную страницу. В результате приходится дважды нажимать Back, чтобы там оказаться. 

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

При нажатии значка вкладки большинство панелей вкладок предлагают 2 функциональности: переход к корневому представлению (Pop to root view) и прокрутка вверх (Scroll to top). Если вы с ними знакомы, то переходите к следующему абзацу. 

  1. Pop to root view. Независимо от того, насколько глубоко вы находитесь во вкладке, нажатие значка вкладки перемещает к главному/корневому представлению. Например, у вас есть киноприложение с поисковой панелью в корневом представлении. Оно отображает результаты поиска фильмов во втором представлении. При нажатии на один из результатов происходит переход к третьему представлению с подробным описанием фильма. Нажатие значка вкладки возвращает к поисковой панели. 
  2. Scroll to top. Достаточно простая функциональность. Если вы находитесь в корневом представлении и нажимаете значок вкладки, то перемещаетесь вверх. 

Спрашивается, как совместить оба вида поведения? Для этого нужно знать следующее: 

  1. Когда нажимается значок вкладки, и является ли нажимаемый значок активной вкладкой. 
  2. Следует ли перейти к корневому представлению или прокрутить вверх. 
  3. И наконец, как это сделать. 

Приступим к последовательному решению этих задач. 

Нажатие значка вкладки → Вопреки первой пришедшей в голову мысли, onTapGesture не работает со значками вкладки, что немного странно. Поэтому применим другой подход. Мы можем управлять TabView, просто установив привязку selectedTab. Так и будем действовать. Рассмотрим код: 

{
...code

TabView(selection: tabSelection()) {

}
...code
}
extension TabScreenView {

private func tabSelection() -> Binding<Tab> {
Binding { //это блок get
self.selectedTab
} set: { tappedTab in
if tappedTab == self.selectedTab {
//Пользователь нажал значок активной вкладки => Переход к корневому представлению/Прокрутка вверх
}
//Установка вкладки в нажатую вкладку
self.selectedTab = tappedTab
}
}
}

Мы создаем функцию tabSelection, которая действует как посредник и обеспечивает получение и установку привязки selectedTab

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

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

Вместо этого мы воспользуемся перечислениями и стеком навигации NavigationStack SwiftUI.

Ниже представлен базовый NavigationStack без перечислений: 

//Применение массива целых чисел, где каждое целое число указывает на целевое представление
@State private var path = [Int]()
//или использование @State private var path = NavigationPath()


NavigationStack(path: $path) {//Отслеживание замыкания для корня:

NavigationLink(value: 1) { //На основе этого значения определяется пункт назначения навигации
Text("Click me to navigate") //Метка
}

...другой код

//Переключение происходит здесь
.navigationDestination(for: Int.self) { value in
//В зависимости от потребностей можно добавить другие условия или лучше использовать переключатель
if value == 1 {
ChildView()
}
}

Поясним для тех, кто видит такое впервые. 

NavigationStack требует привязки к NavigationPath, поскольку для него он является источником достоверных данных. 

Путь сообщает стеку, где мы находимся, и каждый экран, добавляемый в навигацию приложения, присоединяется к этому пути. 

Таким образом, если вы очищаете путь, то возвращаетесь к его корню, а именно к корневому представлению. 

Теперь создадим перечисления. В целях экономии времени создадим только homeNavigationStack:

//Перечисление для каждой Tab, которое отслеживает представления Tab
enum HomeNavigation: Hashable {
case child, secondChild
}

//Объявление navigationStack для каждой вкладки
@State private var homeNavigationStack: [HomeNavigation] = []

//Передача этого состояния в HomeScreen
HomeScreen(path: $homeNavigationStack)
.tabItem {
Label("Home", systemImage: "house")
}
.tag(Tab.home)

Этот homeNavigationStack следит за тем, на каком экране вкладки Home мы находимся. Очистим данный массив  —  вернемся к корню. Необходимо создать стеки для каждой из вкладок.

Действуя таким образом, мы четко обозначим и разделим логику для каждой вкладки. 

После внедрения навигации в HomeScreen получаем следующий результат: 

struct HomeScreen: View {
//Этот путь может исходить из объекта среды View Model
@Binding var path: [HomeNavigation]

var body: some View {

NavigationStack(path: $path) {

//Указание нужного перечисления экрана как целевого представления
NavigationLink(value: HomeNavigation.child) {
Text("Click me to navigate")
}

//Всегда объявляется в корне
.navigationDestination(for: HomeNavigation.self) { screen in
switch screen {
case .child: ChildView()
case .secondChild: SecondChildView()
}
}
.navigationTitle("Home")
}
}
}

Примечание. Swift не позволяет передавать путь как есть. Он должен быть хешируемым Hashable! Обратите внимание: выше я добавил совместимость с Hashable для перечисления HomeNavigation.

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

Все, что нужно сделать,  —  очистить этот массив, и мы вернемся к корню. 

Обновляем функцию tabSelection:

private func tabSelection() -> Binding<Tab> {
Binding { //Это блок get
self.selectedTab
} set: { tappedTab in

if tappedTab == self.selectedTab {
//Пользователь нажал на значок активной вкладки => Переход к корневому представлению/Прокрутка вверх

if homeNavigationStack.isEmpty {
//Пользователь уже находится на главном представлении, прокрутка вверх
} else {
//Переход к главному представлению путем очистки стека
homeNavigationStack = []
}
}

//Установка текущей вкладки на вкладку, выбранную пользователем
self.selectedTab = tappedTab
}
}

Прокрутка вверх осуществляется просто. Добавляем State, передаем его в HomeScreen. Затем добавляем событие onChange, которое проверяет, является ли привязка true, и прокручивает до id. По данной теме есть информация на Stack Overflow

И наконец, поговорим о том, как передать что-нибудь между представлениями. По мере роста приложения мы начнем создавать больше отображаемых подэлементов (англ. subviews). 

Но как что-то передать, если пункт назначения навигации находится в корне, где эти аргументы отсутствуют? 

Рассмотрим структуру Person:

struct Person: Hashable {
let name: String
let lastName: String
}

Допустим, ChildView должен передать Person второму дочернему представлению SecondChild. Мы можем получить список людей и отобразить его в дочернем представлении, а во втором дочернем представлении предоставить более подробные сведения о людях. 

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

Обновляем перечисление HomeNavigation следующим образом: 

enum HomeNavigation: Hashable {
case child, secondChild(Person)
}

Мы просто добавили связанный тип, и это практически все, что нужно. Теперь в ChildView мы можем легко отправить данные типа Person:

struct ChildView: View {

let person = Person(name: "Akshay", lastName: "Mahajan")

var body: some View {
VStack {
Text("Child View")

// Ообратите внимание: PERSON добавлен в значение
NavigationLink(value: HomeNavigation.secondChild(person)) {
Text("Click to enter second view")
}

}
.navigationTitle("Child")
}
}

navigationDestination обновляется, как показано ниже:

.navigationDestination(for: HomeNavigation.self) { screen in
switch screen {
case .child: ChildView()
case .secondChild(let person): SecondChildView(person: person)
}
}

Теперь TabView ведет себя так, как и ожидалось, и рабочий процесс приложения выглядит так: 

Пример приложения по ссылке GitHub.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Akshay Mahajan: The Ideal TabView Behaviour With SwiftUI Navigation Stack

Предыдущая статьяСоздание кольцевой диаграммы на JavaScript
Следующая статья4 аспекта, упущенных в большинстве программ по науке о данных.