Предварительный просмотр Stateful Вью в SwiftUI
Интерактивные Предварительные просмотры для ваших вью в SwiftUI
ОПУБЛИКОВАНО: 21 ДЕКАБРЯ 2022 ГОДА
Stateful - с сохранением состояния во времени; состояние, которое можно отследить.
При создании пользовательского интерфейса в SwiftUI мы, как правило, создаем два типа компонентов пользовательского интерфейса: экраны и (переиспользуемые) вью. Обычно мы начинаем с создания прототипа экрана, что неизбежно приводит к Massive ContentView, далее который мы начинаем рефакторить в более мелкие, используемые повторно компоненты.
Предположим, что мы создаем приложение для списка дел. Вот как это может выглядеть:
struct TodoListView: View {
@State var todos = [Todo]()
var body: some View {
NavigationStack {
List($todos) { $todo in
Toggle(isOn: $todo.completed) {
Text(todo.title)
.strikethrough(todo.completed)
}
}
.listStyle(.plain)
.navigationTitle("My tasks")
}
}
}
Я упростил этот процесс, но вы можете представить, что код для приложения со списком дел, такого как Reminders от Apple, выглядит немного сложнее (на самом деле, если вам интересно, посмотрите MakeItSo, пример проекта, который я создал для воспроизведения приложения Reminders с помощью SwiftUI и Firebase).
Один из способов упростить этот код — рефакторинг вью Списка (List) и извлечение строки в отдельный повторно используемый компонент:
struct TodoRowView: View {
@Binding var todo: Todo
var body: some View {
Toggle(isOn: $todo.completed) {
Text(todo.title)
.strikethrough(todo.completed)
}
}
}
Поскольку TodoRowView теперь является дочерним элементом вью List, мы хотим, чтобы TodoListView был владельцем данных. Чтобы убедиться, что любые изменения, которые делает пользователь (нажав на переключатель внутри TodoRowView, отражаются в List (и наоборот), нам нужно установить двунаправленную привязку данных. Правильная обертка свойства для этого задания — @Binding — она позволяет нам подключаться к данным, принадлежащим другому вью.
Проблема
Однако при попытке настроить PreviewProvider для этого вью мы быстро столкнулись с некоторыми ограничениями: как настроить предварительный просмотр с некоторыми демонстрационными данными, чтобы его можно было редактировать внутри TodoRowView?
Мы могли бы начать с создания статической переменной в PreviewProvider, а затем передать ее вью, которое мы хотим просматривать предварительно. Однако это неизбежно приведет к ошибке компилятора, поскольку TodoRowView ожидает, что todo будет Binding<Todo> :
struct TodoRowView_Previews: PreviewProvider {
static var todo = Todo(title: "Draft article", completed: false)
static var previews: some View {
TodoRowView(todo: todo)
// ^ error: "Cannot convert value of type 'Todo' to expected argument type 'Binding<Todo>'"
}
}
Пометка статической переменной todo как @State устраняет ошибку компилятора, но не приводит к интерактивному предварительному просмотру:
struct TodoRowView_Previews: PreviewProvider {
@State static var todo = Todo(title: "Draft article", completed: false)
static var previews: some View {
TodoRowView(todo: $todo)
}
}
https://peterfriese.dev/posts/swiftui-previews-interactive/StatefulPreviews-1.mp4
Подходящее решение
Обычный способ решить эту проблему — использовать постоянную(константную) привязку. Apple предоставляет нам статическую функцию Binding, которая делает это непосредственным образом:
struct TodoRowView_Previews_withConstantBinding: PreviewProvider {
static var previews: some View {
TodoRowView(todo: .constant(Todo.sampple))
}
}
Как указано в документации, это создает привязку с неизменяемым значением, что предотвращает обновление базового свойства, поэтому наша реализация может показаться нерабочей, когда мы запускаем ее на панели предварительного просмотра.
https://peterfriese.dev/posts/swiftui-previews-interactive/StatefulPreviews-2.mp4
Использование пользовательской привязки
Другая стратегия выхода из этой ситуации — использование пользовательской привязки. Вот статическая функция mock, которая сохраняет значение в локальной переменной и предоставляет к нему доступ для чтения/записи.
extension Binding {
static func mock(_ value: Value) -> Self {
var value = value
return Binding(get: { value },
set: { value = $0 })
}
}
Прелесть этой техники в том, что она позволяет нам заменить любые вызовы .constant вызовом .mock:
struct TodoRowView_Previews_withMockBinding: PreviewProvider {
static var previews: some View {
TodoRowView(todo: .mock(Todo.sampple))
}
}
Однако это решение работает лишь частично. Хотя теперь можно переключать тумблер, остальная часть пользовательского интерфейса не обновляется: когда todo помечена как завершенная, ее заголовок должен быть перечеркнут, но, как вы можете видеть из анимации, это не работает.
https://peterfriese.dev/posts/swiftui-previews-interactive/StatefulPreviews-3.mp4
Настоящее решение
Это помогает напомнить себе, что у нас есть полный контроль над превью — например, добавление .preferredColorScheme(.dark) к вью внутри PreviewProvider включит темный режим.
Учитывая это, решение становится очевидным: оберните вью во контейнер и сохраните состояние уже в нем:
struct TodoRowView_Previews_Container: PreviewProvider {
struct Container: View {
@State var todo = Todo.sampple
var body: some View {
TodoRowView(todo: $todo)
}
}
static var previews: some View {
Container()
}
}
Это решение, рекомендованное Apple на сессии WWDC 2020 “Структурируйте ваше приложение для предварительного просмотра SwiftUI” (см. здесь). Поскольку контейнер вью владеет данными через обертку свойства @State, этот подход дает нам предварительный просмотр SwiftUI, который является полностью интерактивным и реагирует на любые изменения состояния вью.
https://peterfriese.dev/posts/swiftui-previews-interactive/StatefulPreviews-4.mp4
Еще кое-что
Мы можем сделать это еще проще, создав общий вью-контейнер, который передает привязку к значению содержащемуся в нем вью:
struct StatefulPreviewContainer<Value, Content: View>: View {
@State var value: Value
var content: (Binding<Value>) -> Content
var body: some View {
content($value)
}
init(_ value: Value, content: @escaping (Binding<Value>) -> Content) {
self._value = State(wrappedValue: value)
self.content = content
}
}
Что позволяет нам реализовать предварительный просмотр с отслеживанием состояния всего несколькими строками кода:
struct TodoRowView_Previews_withGenericWrapper: PreviewProvider {
static var previews: some View {
StatefulPreviewContainer(Todo.sampple) { binding in
TodoRowView(todo: binding)
}
}
}
Вот gist с кодом для StatefulPreviewContainer, включая пример того, как его использовать.
Заключение
Предварительные просмотры SwiftUI — отличный инструмент для разработки вью SwiftUI, и когда они работают, это дает потрясающий опыт разработки: нет необходимости повторно запускать приложение для каждого небольшого изменения, которое вы вносите. Это значительно сокращает время обработки и приводит к гораздо более эффективному рабочему процессу.
Такое чувство, что Apple по оплошности не предоставила простой способ предварительного просмотра вью, которые связаны с представлением их основных вью @Binding, но, к счастью, есть несколько способов обойти этот недостаток.
В этой статье я показал вам несколько подходов, которые вы можете использовать, когда ваши вью SwiftUI используют @Binding для связи с внешним миром. Лично мне больше всего нравится превью вью-контейнера, и вы даже можете сделать его более эффективным, определив фрагмент кода Xcode.