November 25, 2023

42. Пример работы с NavigationView

В этой статье разберем один из вариантов построения навигации на SwiftUI с использованием NavigationView, несмотря на то что он уже deprecated.

Сделаем приложение с несколькими экранами, навигация будет выглядеть так:

Демо NavigationView

Корневой экран отображает вьюшку, соответствующую состоянию вьюмодели, один из вариантов:

  1. Экран авторизации
  2. Главный экран (после авторизации)
  3. Экран с текстом какой-то ошибки
/// Корневой экран
///
/// NV - NavigationView
struct NVRootScreen: View {
  @StateObject private var viewModel = NVAppViewModel()
   
  var body: some View {
    NavigationView {
      contentView
    }
    .navigationViewStyle(.stack)
    .environmentObject(viewModel)
  }
}

private extension NVRootScreen {
  var contentView: some View {
    ZStack {
      switch viewModel.appState {
      case .notAuth: // 1
        NVAuthorizeScreen()
          .transition(.slide)
      case .auth: // 2
        NVMainScreen()
          .transition(.scale)
      case let .error(message): // 3
        NVErrorScreen(message: message)
          .transition(.move(edge: .trailing))
      case .none:
        EmptyView()
      }
    }
    .animation(.default, value: viewModel.appState)
  }
}

Используя ZStack в связке с animation и transition, мы можем легко настроить способ появления/исчезновения вьюшек для всех состояний, в нашем примере используются slide, scale и move - переходы.

Все экраны в нашем примере имеют доступ к единственной вьюмодели, которая передается через модификатор environmentObject, применяемый к NavigationView.

Важно помнить, что Xcode не может проверить на этапе сборки приложения, не забыл ли разработчик передать нужную зависимость через модификатор на экран, где он обращается к этому классу через @EnvironmentObject.

Для более удобной навигации с использованием enum'ов в нашем примере используются некоторые расширения:

extension Binding<Bool> {
  init<Wrapped>(bindingOptional: Binding<Wrapped?>) {
    self.init(
      get: { bindingOptional.wrappedValue != nil },
      set: { newValue in
        guard newValue == false else { return }
        /// Обрабатываем только значение `false`, чтобы обнулить опционал,
        /// потому что не можем восстановить предыдущее состояние опционала для значения `true`
        bindingOptional.wrappedValue = nil
      }
    )
  }
}

extension Binding {
  func mappedToBool<Wrapped>() -> Binding<Bool> where Value == Wrapped? {
    Binding<Bool>(bindingOptional: self)
  }
}

И для сокращения количества кода при работе с NavigationLink:

extension NavigationLink where Label == EmptyView {
  /// Инициализатор с пустым лейблом для удобства
  init(destination: Destination, isActive: Binding<Bool>) {
    self.init(
      destination: destination,
      isActive: isActive,
      label: EmptyView.init
    )
  }
}

Посмотрим на примере главного экрана как применяются эти вещи.

Сделаем перечисление с вариантами навигации:

/// Варианты навигации для главного экрана
  enum NavigationDestination {
    case profile
    case bookmarks
    case settings
  }

Добавим в бэкграунд NavigationLink:

struct NVMainScreen: View {
  // Тут храним выбранный путь навигации
  @State private var navigationDestination: NavigationDestination?
   
  var body: some View {
    VStack(spacing: 20) {
      HStack(spacing: 16) {
        profileButton
        bookmarksButton
      }
      settingsButton
    }
    .background(
      NavigationLink(
        destination: destinationView,
        isActive: $navigationDestination.mappedToBool()
      )
    )
    .navigationTitle("Главный экран")
  }
}

А вот вьюшка, определяющая экраны для навигации:

@ViewBuilder
  var destinationView: some View {
    switch navigationDestination {
    case .profile: NVProfileScreen()
    case .bookmarks: NVBookmarksScreen()
    case .settings: NVSettingsScreen()
    case .none: EmptyView()
    }
  }

В кнопках на экране мы выбираем, куда нужно перейти:

var profileButton: some View {
    Button {
      navigationDestination = .profile // <- тут
    } label: {
      Label("Профиль", systemImage: "person")
    }
  }
   
  var bookmarksButton: some View {
    Button {
      navigationDestination = .bookmarks // <- тут
    } label: {
      Label("Закладки", systemImage: "bookmark")
    }
  }
   
  var settingsButton: some View {
    Button {
      navigationDestination = .settings // <- тут
    } label: {
      Label("Настройки", systemImage: "gear")
    }
  }

В будущих статьях посмотрим на навигацию с использованием TabView и NavigationStack.

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