March 1, 2024

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))
      }
    }
  }

Работающий пример

Имитируем ожидание ответа в течение 1.5 секунд, скролл выполняется корректно

Ломаем скролл

Меняем ожидание ответа сервера на 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))
  }
}
Имитируем быстрый ответ (0.1 сек), скролл выполняется некорректно

Исправляем ситуацию

private var skeletons: some View {
  VStack(spacing: 16) {
    ForEach(0..<5, id: \.self) { _ in // <- добавили id: \.self
      Rectangle()
        .frame(height: 120)
        .skeleton()
    }
  }
}
Имитируем быстрый ответ (0.1 сек), скролл выполняется корректно, потому что настроены идентификаторы в обоих списках (скелетоны и основной список)

Заключение

Здорово, что уже с iOS 14 в SwiftUI можно выполнять скролл к нужному элементу в списке. Будем надеяться, что в ближайшем будущем "ридер" научится делать что-то еще полезное, например, возвращать положение скролла (offset). А пока что не забываем про идентификаторы внутри ForEach/List, потому что SwiftUI может без них работать некорректно.

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