Существует множество статей, описывающих стек навигации, который был представлен в iOS 16, но большинство из них в значительной степени повторяют то, что говорится в документации Apple, и похожи на образец приложения Colors, которым поделилась Apple. Хотя это хорошо для понимания основ, этого далеко недостаточно, чтобы понять, как включить навигационный стек в реальное приложение.

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

Итак, давайте углубимся в это, создав представление Tab:

struct TabScreenView: View {
//enum for Tabs, add other tabs if needed
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, который позволяет переключаться между тремя экранами с помощью вкладок. Я добавил несколько фиктивных данных в несколько представлений и связал их с главным экраном, так что на данный момент у нас есть вот что:

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

Это может быть несколько нажатий, если приложение большое и ваши пользователи не скажут вам за это спасибо.

Большинство панелей вкладок имеют две функции при нажатии на значок вкладки: всплывающее окно в корневой каталог и прокрутку вверх. Вы можете перейти к следующему абзацу, если знаете, что он означает.

  1. Перейти к корневому представлению. Независимо от того, насколько глубоко вы находитесь на вкладке, нажатие на значок вкладки приведет вас к домашнему/корневому представлению. Например, у вас есть приложение для просмотра фильмов с панелью поиска на главном экране, которая показывает результаты фильма на втором представлении, и вы можете нажать на один из результатов, и оно перейдет к третьему представлению, в котором показаны подробности фильма. Нажатие на значок вкладки должно вернуть вас на панель поиска.
  2. Прокрутить вверх достаточно легко. Если вы находитесь на главном экране и нажимаете значок вкладки, он должен прокрутиться вверх.

Теперь, как объединить оба эти поведения? Нам нужно знать несколько вещей:

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

Давайте рассмотрим эти шаги по одному.

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

{
  ...code
  
  TabView(selection: tabSelection()) {

  }
  ...code
}
extension TabScreenView {
 
 private func tabSelection() -> Binding<Tab> {
    Binding { //this is the get block
     self.selectedTab
    } set: { tappedTab in
     if tappedTab == self.selectedTab {
      //User tapped on the currently active tab icon => Pop to root/Scroll to top
     }
     //Set the tab to the tabbed tab
     self.selectedTab = tappedTab
    }
 }
}

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

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

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

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

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

//Use an array of Integers with each integer pointing to a destination view
@State private var path = [Int]()
//or use @State private var path = NavigationPath()


NavigationStack(path: $path) {//Trailing closure for root:

  NavigationLink(value: 1) { //Based on what this value is the navigation Destination shall be decided
    Text("Click me to navigate") //Label
  }

  ...other code

  //Our switching happens here
  .navigationDestination(for: Int.self) { value in
    //Based on your need, you may add other conditions or better use a switch
    if value == 1 {
      ChildView()
    }
  }
}

Позвольте мне объяснить больше для тех, кто никогда не видел этого раньше.

Стек навигации нуждается в привязке к NavigationPath, поскольку это похоже на его источник истины.

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

Итак, если вы очистите путь, вы вернетесь в корень пути, в корневое представление.

Теперь давайте создадим несколько перечислений. Ради экономии времени я создам только стек домашней навигации.

 //an enum for each Tab that tracks the views of the Tab
 enum HomeNavigation: Hashable {
  case child, secondChild
 }

//Declare navigationStacks for each of the tabs
 @State private var homeNavigationStack: [HomeNavigation] = []

//Pass this state to HomeScreen
  HomeScreen(path: $homeNavigationStack)
      .tabItem {
       Label("Home", systemImage: "house")
      }
      .tag(Tab.home)

Теперь этот homeNavigationStack будет отслеживать, на каком экране мы находимся на вкладке «Главная». Мы очищаем этот массив и возвращаемся к корню. Нам нужно будет создать стеки для каждой из вкладок.

Это позволяет четко обозначить и разделить логику каждой из вкладок.

После включения навигации в наш HomeScreen мы получаем следующее:

struct HomeScreen: View {
//This path can come from environmene object View Model
 @Binding var path: [HomeNavigation]
 
 var body: some View {
  
  NavigationStack(path: $path) {

//Specify the enum of the screen you want as the destination view
   NavigationLink(value: HomeNavigation.child) {
    Text("Click me to navigate")
   }

   //This is always declared at the root
   .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 { //this is the get block
   self.selectedTab
  } set: { tappedTab in

   if tappedTab == self.selectedTab {
    //User tapped on the currently active tab icon => Pop to root/Scroll to top
    
      if homeNavigationStack.isEmpty {
       //User already on home view, scroll to top
      } else {
      //Pop to root view by clearing the stack
       homeNavigationStack = []
      }
   }
   
   //Set the current tab to the user selected tab
   self.selectedTab = tappedTab
  }
 }

Прокрутить вверх легко. Добавьте State, передайте его HomeScreen и добавьте onChange, который проверяет, верна ли эта привязка, и прокручивает до идентификатора. Вот ветка Переполнение стека на эту тему.

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

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

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

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

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

Сейчас, в 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")

     //NOTICE THE PERSON ADDED TO THE VALUE
     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)
    }
   }

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

Это меня очень огорчило. Посмотрите мой пример приложения на GitHub.

Спасибо за прочтение.