56. Скроллим к нужному элементу
В SwiftUI есть ScrollViewReader (iOS 14+), который позволяет выполнять скролл к элементу с указанным id. Покажу несколько интересных сценариев работы с этим инструментом.
Введение
ScrollViewReader применяется таким же образом как и GeometryReader - в замыкании мы получаем доступ к proxy, который позволяет что-то делать с вьюхой внутри этого контейнера. При помощи ScrollViewReader можно выполнять скролл к нужному элементу, если сложить внутрь ридера ScrollView.
Простой пример
Сделаем такой экран со списком элементов и кнопками для скролла вниз и вверх:
В этом примере скролл выполняется не к элементам списка (с номерами 0...1000), а к кнопкам, поэтому для кнопки проставляется конкретный id - это важный нюанс.
Код для экрана
struct EasyView: View {
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack {
makeScrollButton("Вниз", id: -1) {
proxy.scrollTo(1000)
}
ForEach(Array(0...1000), id: \.self) { i in
Text(i.description)
}
makeScrollButton("Наверх", id: 1001) {
proxy.scrollTo(0)
}
}
}
}
}
private func makeScrollButton(
_ title: String,
id: Int,
action: @escaping () -> Void
) -> some View {
HStack {
Spacer()
Button(title) {
withAnimation { action() }
}
.buttonStyle(.bordered)
.padding(.trailing)
}
.id(id) // <- проставляем конкретный id для скролла
}
}
Ломаем скролл
Достаточно начать делать скролл не к кнопкам, а к первому/последнему элементу в списке, т.е. 0 или 1000, чтобы словить баг: кнопки перестают нажиматься, если не потянуть скролл вниз/вверх (в зависимости от положения кнопки):
Оставляю читателям право починить этот баг при желании 😁
Сложный пример
Сделаем экран с несколькими табами сверху (фильтры или просто разные списки). Наши задачи на этом экране:
- автоматически выполнять скролл наверх при переключении между табами
- смена таба сопровождается запросом к серверу (будем имитировать)
- во время ожидания ответа сервера показываем скелетоны (5 штук)
Код для экрана
struct ComplexView: View {
/// Прогресс загрузки данных
private enum Progress: Equatable {
case loading
case ready([Int])
}
/// Табы для разных вариантов контента
private enum Tab: String, CaseIterable {
case a, b, c
}
@State private var progress = Progress.loading
@State private var selectedTab = Tab.a
@State private var getItemsTask: Task<Void, Never>?
var body: some View {
VStack(spacing: 20) {
tabs
ScrollViewReader { proxy in
ScrollView {
ZStack {
switch progress {
case .loading:
skeletons
case .ready(let array):
makeReadyView(array)
}
}
.onChange(of: progress) { newValue in
if case .loading = newValue {
proxy.scrollTo(0) // <- делаем скролл наверх
}
}
.onChange(of: selectedTab) { _ in
getItems() // <- запрашиваем список элементов
}
}
}
}
.animation(.default, value: progress)
.padding(.horizontal)
.onAppear { getItems() }
}
private var tabs: some View {
HStack(spacing: 32) {
ForEach(Tab.allCases, id: \.self) { option in
Button(option.rawValue) {
selectedTab = option // <- меняем выбранный таб
}
.tint(option == selectedTab ? .blue : .gray)
.buttonStyle(.borderedProminent)
}
}
}
private var skeletons: some View {
VStack(spacing: 16) {
ForEach(0..<5) { _ in
Rectangle()
.frame(height: 120)
.skeleton()
}
}
}
private func makeReadyView(_ items: [Int]) -> some View {
LazyVStack(spacing: 32) {
ForEach(items, id: \.self) { i in
Text("Элемент \(i)")
}
}
}
private func getItems() {
progress = .loading
getItemsTask = Task {
// Имитируем ответ сервера
try? await Task.sleep(for: .seconds(1.5))
progress = .ready(Array(0...30))
}
}
}
Работающий пример
Ломаем скролл
Меняем ожидание ответа сервера на 0.1 сек:
private func getItems() {
progress = .loading
getItemsTask = Task {
// Имитируем очень быстрый ответ сервера
try? await Task.sleep(for: .seconds(0.1)) // <- поменяли 1.5 на 0.1
progress = .ready(Array(0...30))
}
}
Исправляем ситуацию
private var skeletons: some View {
VStack(spacing: 16) {
ForEach(0..<5, id: \.self) { _ in // <- добавили id: \.self
Rectangle()
.frame(height: 120)
.skeleton()
}
}
}
Заключение
Здорово, что уже с iOS 14 в SwiftUI можно выполнять скролл к нужному элементу в списке. Будем надеяться, что в ближайшем будущем "ридер" научится делать что-то еще полезное, например, возвращать положение скролла (offset). А пока что не забываем про идентификаторы внутри ForEach/List, потому что SwiftUI может без них работать некорректно.
Код для этой статьи можно посмотреть тут, а другие статьи - тут.