Использование стека навигации 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
). Если вы с ними знакомы, то переходите к следующему абзацу.
Pop to root view
. Независимо от того, насколько глубоко вы находитесь во вкладке, нажатие значка вкладки перемещает к главному/корневому представлению. Например, у вас есть киноприложение с поисковой панелью в корневом представлении. Оно отображает результаты поиска фильмов во втором представлении. При нажатии на один из результатов происходит переход к третьему представлению с подробным описанием фильма. Нажатие значка вкладки возвращает к поисковой панели.Scroll to top
. Достаточно простая функциональность. Если вы находитесь в корневом представлении и нажимаете значок вкладки, то перемещаетесь вверх.
Спрашивается, как совместить оба вида поведения? Для этого нужно знать следующее:
- Когда нажимается значок вкладки, и является ли нажимаемый значок активной вкладкой.
- Следует ли перейти к корневому представлению или прокрутить вверх.
- И наконец, как это сделать.
Приступим к последовательному решению этих задач.
Нажатие значка вкладки → Вопреки первой пришедшей в голову мысли, 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.
Читайте также: