Улучшенные API навигации SwiftUI

30 ноября 2022

21 ноября, 2022. Без рубрики

Сегодня мы выпускаем самое большое обновление для нашей библиотеки SwiftUINavigation с момента её первого выпуска год назад. В нём обеспечена поддержка новых API-интерфейсов iOS 16, исправлены ошибки некоторых навигационных инструментов Apple, улучшена поддержка оповещений и диалоговых окон подтверждения, а также улучшена документация.

Присоединяйтесь к нам для быстрого обзора новых функций и обязательно обновитесь до версии 0.4.0, чтобы получить доступ ко всему этому и многому другому:

  • Навигационные стеки
  • Исправления ошибок навигации
  • Оповещения и диалоговые окна подтверждения
  • Начните сегодня

 

Навигационные стеки


iOS 16 в значительной степени изменила способ работы со структурной навигацией, реализовав новое вью верхнего уровня, вью NavigationStack, новый модификатор вью, navigationDestination и новые инициализаторы в NavigationLink. Эти новые инструменты обеспечивают лучшее разделение между источником и конечной точкой навигации, а также позволяют лучше управлять глубокими стеками функции.

В нашей библиотеке появился новый инструмент, построенный на основе модификатора вью navigationDestination(isPresented:), который позволяет управлять навигацией из логической привязки( булевой цепочки). Этот инструмент устранил один из самых больших недостатков NavigationLink, связанный с трудностью использования во вью списка, так как детализация происходила только в том случае, если строка была видна в списке. Это означает, что было невозможно программно создать глубокую ссылку на экран, если строка в данный момент не видна.

Модификатор вью navigationDestination исправил этот недостаток, позволяя иметь единственное место для выражения навигации, не встраивая ее в каждую строку списка:

func navigationDestination<V>(
  isPresented: Binding<Bool>,
  destination: () -> V
) -> some View where V : View

 

Однако логическая цепочка слишком упрощена для использования в инструменте доменного моделирования. Что, если вы хотите управлять навигацией с помощью части опционального состояния и далее передавать это состояние конечному вью? 

Вот почему наша библиотека поставляется с дополнительной перегрузкой, называемой navigationDestination(unwrapping:), которая может управлять навигацией от цепочки к опционалу:

public func navigationDestination<Value, Destination: View>(
  unwrapping value: Binding<Value?>,
  @ViewBuilder destination: (Binding<Value>) -> Destination
) -> some View {

 

Это упрощает получение списка данных, которые вы хотите развернуть при нажатии на строку:

struct UsersListView: View {
  @State var users: [User]
  @State var editingUser: User?

  var body: some View {
    List {
      ForEach(self.users) { user in
        Button("\(user.name)") { self.editingUser = user }
      }
    }
    .navigationDestination(unwrapping: self.$editingUser) { $user in
      EditUserView(user: $user)
    }
  }
}


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

Вот почему наша библиотека поставляется с другой перегрузкой, называемой navigationDestination(unwrapping:case:), которая позволяет управлять несколькими пунктами назначения из одного положения:

public func navigationDestination<Enum, Case, Destination: View>(
  unwrapping enum: Binding<Enum?>,
  case casePath: CasePath<Enum, Case>,
  @ViewBuilder destination: (Binding<Case>) -> Destination
) -> some View {

 

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

struct UsersListView: View {
  @State var categories: [Category]
  @State var users: [User]
  @State var destination: Destination?
  enum Destination {
    case edit(user: User)
    case edit(category: Category)
  }

  var body: some View {
    List {
      Section(header: Text("Users")) {
        ForEach(self.users) { user in
          Button("\(user.name)") { self.destination = .edit(user: user) }
        }
      }
      Section(header: Text("Categories")) {
        ForEach(self.categories) { category in
          Button("\(category.name)") { self.destination = .edit(category: user) }
        }
      }
    }
    .navigationDestination(
      unwrapping: self.$destination,
      case: /Destination.edit(user:)
    ) { $user in
      EditUserView(user: $user)
    }
    .navigationDestination(
      unwrapping: self.$destination,
      case: /Destination.edit(category:)
    ) { $category in
      EditCategoryView(user: $category)
    }
  }
}


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


Исправления ошибок навигации


Модификатор вью navigationDestination(isPresented:), выпущенный в iOS 16 - мощный, и вышесказанное показывает, что мы можем создавать отличные API поверх него, однако в нем есть некоторые ошибки.

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

Мы подали отзыв (и рекомендуем вам его продублировать!), и этот простой пример демонстрирует проблему:

struct UserView: View {
  @State var isPresented = true

  var body: some View {
    Button("Go to destination") {
      self.isPresented = true
    }
    .navigationDestination(isPresented: self.$isPresented) {
      Text("Hello!")
    }
  }
}

 

Это довольно катастрофично. Если вы используете navigationDestination(isPresented:) в своем коде, вы просто не сможете поддерживать такие вещи, как глубокая ссылка URL или глубокая ссылка push-уведомлений.

Тем не менее, мы смогли исправить эту ошибку в наших API. Если вы используете их, то можете быть уверены, что диплинкинг будет работать правильно и не потеряет работоспособность на любом количестве уровней в глубину. Это также исправляет давнюю ошибку в iOS <16, которая печально известна тем, что не может делать глубокие ссылки более чем на 2 уровня в глубину.

 

Оповещения и диалоговые окна подтверждения

 

Наша SwiftUINavigation с самого начала поддерживала улучшенные API-интерфейсы оповещений и диалогов подтверждений с использованием опционалов и перечислений, но с выпуском 0.4.0 мы сделали их еще более мощными.

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

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

@Published var alert: AlertState<AlertAction>
enum AlertAction {
  case confirmDeletion
}

 

Затем вы можете изменить это состояние в любое время, чтобы указать, что должно отображать предупреждение:

func deleteButtonTapped() {
  if item.isLocked {
    self.alert = AlertState {
      TextState("Cannot be deleted")
    } message: {
      TextState("This item is locked, and so cannot be deleted.")
    }
  } else {
    self.alert = AlertState {
      TextState("Delete?")
    } actions: {
      ButtonState(role: .destructive, action: .confirmDeletion) {
        TextState("Yes, delete")
      }
      ButtonState(role: cancel) {
        TextState("Nevermind")
      }
    } message: {
      TextState(#"Are you sure you want to delete "\(item.name)"?"#)
    }
  }
}

 

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

func alertButtonTapped(_ action: AlertAction) {
  switch action {
  case .confirmDeletion:
    self.inventory.remove(id: item.id)
  }
}

 

Затем, чтобы вью отображало оповещение, когда состояние оповещения становится не nil, нам просто нужно использовать alert(unwrapping:) API, который поставляется с нашей библиотекой.

struct ItemView: View {
  @ObservedObject var model: ItemModel

  var body: some View {
    Form {
      …
    }
    .alert(unwrapping: self.$model.alert) { action in
      self.model.alertButtonTapped(action)
    }
  }
}

 

Обратите внимание, что во вью нет логики для того, какое оповещение показывать. Вся логика, когда отображать оповещение и какая информация отображается (заголовок, сообщение, кнопки) перенесена в модель, а потому её очень легко тестировать.
Для тестирования вы можете просто настроить любые части состояния оповещения, которые вы хотите. Например, если вы хотите убедиться, что сообщение оповещения соответствует вашим ожиданиям, можете просто использовать XCTAssertEqual:

let headphones = Item(…)
let model = ItemModel(item: headphones)
model.deleteButtonTapped()

XCTAssertEqual(
  model.alert.message,
  TextState(#"Are you sure you want to delete "Headphones"?"#)
)

 

Начните сегодня


Начните пользоваться всеми мощными инструментами моделирования предметной области, которые поставляются со Swift (перечисления и опции!), добавив SwiftUINavigation в свой проект уже сегодня!
 

Оригинал статьи

Содержание