42. Пример работы с NavigationView
В этой статье разберем один из вариантов построения навигации на SwiftUI с использованием NavigationView, несмотря на то что он уже deprecated.
Сделаем приложение с несколькими экранами, навигация будет выглядеть так:
Корневой экран отображает вьюшку, соответствующую состоянию вьюмодели, один из вариантов:
/// Корневой экран
///
/// 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.
Код для этой статьи можно посмотреть тут, а другие статьи - тут.