December 2, 2023

43. Пример работы с NavigationStack

В этой статье посмотрим на вариант построения навигации с использованием NavigationStack (iOS 16+). Для наглядности адаптируем под NavigationStack код из предыдущей статьи, там же можно посмотреть демо-видео с результатом.

Наименования всех файлов в новом примере поменял с NV* на NS*, а верстка и почти вся логика остались без изменений.

Посмотрим на основные отличия двух инструментов для навигации.

1) Возвращаемся к корневому экрану (pop to root)

NavigationStack позволяет очень легко сделать pop to root, просто заменив путь пустым значением:

@MainActor
final class NSAppViewModel: ObservableObject {
  /// Хранит актуальный путь навигации для конкретного `NavigationStack`.
  /// В нашем примере стек всего один - внутри `NSRootScreen`
  @Published var path = NavigationPath()
  
  // Остальной код ...
   
  func popToRoot() {
    path = .init() // <- вот и весь popToRoot`
  }
}

2) Выполняем программную навигацию

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

Для управления навигацией достаточно обратиться к нужному стеку навигации через path, который в нашем примере находится внутри NSAppViewModel. и сделать append или другую нужную операцию из общего списка (документация), например:

private extension NSMainScreen {
  var profileButton: some View {
    Button {
      // Переходим в "Профиль"
      viewModel.path.append(NavigationDestination.profile)
    } label: {
      Label("Профиль", systemImage: "person")
    }
  }
   
  var bookmarksButton: some View {
    Button {
      // Переходим в "Закладки"
      viewModel.path.append(NavigationDestination.bookmarks)
    } label: {
      Label("Закладки", systemImage: "bookmark")
    }
  }
   
  var settingsButton: some View {
    Button {
      // Переходим в "Настройки"
      viewModel.path.append(NavigationDestination.settings)
    } label: {
      Label("Настройки", systemImage: "gear")
    }
  }
}

3) Удаляем ненужный код

Больше не нужно держать в бэкграунде экрана NavigationLink, поэтому такие строчки можно целиком удалить:

.background(
   NavigationLink(
      destination: destinationView,
      isActive: $navigationDestination.mappedToBool()
   )
)

4) Обновляем метод для обновления appState

Чтобы наша первая реализация со сменой состояния корневого экрана работала корректно, нужно внутри вьюмодели (NSAppViewModel) вовремя возвращаться к корневому экрану при помощи popToRoot(), пример:

   /// Обрабатывает действие
  func process(action: AppAction) {
    switch action {
    case .performAuth:
      appState = .auth
      isAuthorized = true
    case .performLogout:
      popToRoot() // <- тут сбросили стек
      appState = .notAuth
      isAuthorized = false
    case let .showError(string):
      popToRoot() // <- тут сбросили стек
      appState = .error(string)
    case let .closeError(stayAuthorized):
      appState = stayAuthorized ? .auth : .notAuth
      isAuthorized = stayAuthorized
      if !stayAuthorized { popToRoot() } // <- тут сбросили стек
    }
  }
   
  func popToRoot() {
    path = .init()
  }

5) Обрабатываем навигацию в корневом экране

NavigationStack появился вместе с navigationDestination, вот как его нужно применить в нашем случае:

struct NSRootScreen: View {
  @StateObject private var viewModel = NSAppViewModel()
   
  var body: some View {
    NavigationStack(path: $viewModel.path) {
      contentView
        .navigationDestination(
          for: NSMainScreen.NavigationDestination.self
        ) { destination in
          switch destination {
          case .profile: NSProfileScreen()
          case .settings: NSSettingsScreen()
          case .bookmarks: NSBookmarksScreen()
          }
        }
    }
    .environmentObject(viewModel)
  }
}

Вот и все! Переход на новую навигацию успешно завершен.

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

Важный нюанс

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

Во избежание таких проблем можно:

  • Тщательно тестировать и писать обходные решения (то еще занятие)
  • Не использовать NavigationStack при поддержке минимальной iOS < 16, то есть откатиться на NavigationView
  • Использовать навигацию на UIKit, например, на координаторах, которых на гитхабе много (недавно именно с координаторов я переносил всю навигацию в рабочем проекте на SwiftUI и NavigationView)

Код для этой статьи можно посмотреть тут, а другие статьи - тут.