June 27, 2024

73. Отслеживаем окончание скролла

Недавно делал экран, для которого нужно распознавать момент окончания скролла. В UIKit есть scrollViewDidEndDecelerating, а в SwiftUI такого пока нет, но есть обходное решение с использованием Combine и PreferenceKey.

Демо готового экрана

Так выглядит готовый экран

На гифке по центру экрана видно 2 текста, первый из которых показывает актуальный оффсет, а второй - оффсет в момент "остановки" (позже будет понятно, для чего нужны кавычки).

Решение

За решение призываю поставить лайк этому комментарию на SO.

Хитрость заключается в том, что мы при помощи Combine отслеживаем все изменения оффсета скролла, дожидаемся пока изменения перестанут поступать, ждем еще 0.2 секунды и считаем, что скролл остановился.

Преимущество такого решения - работает на iOS 13+

Краткая версия кода

struct ScrollViewDidEndScrollingExample: View {
  /// Распознаватель изменения в оффсете
  private let detector: CurrentValueSubject<CGFloat, Never>
  /// Публикатор сообщений от распознавателя (с дебаунсом в 0.2 сек)
  private let publisher: AnyPublisher<CGFloat, Never>
  private let scrollId = "ScrollID"
  @State private var stopOffset = CGFloat.zero
   
  init() {
    // Настраиваем отслеживание оффсета
    let detector = CurrentValueSubject<CGFloat, Never>(0)
    self.publisher = detector
      .debounce(for: .seconds(0.2), scheduler: DispatchQueue.main)
      .dropFirst() // Пропускаем первое событие при появлении экрана
      .eraseToAnyPublisher()
    self.detector = detector
  }
   
  var body: some View {
    ScrollView {
      VStack(spacing: 20) {
        // демо-список
      }
      .background(
        // Настраиваем отслеживание оффсета
        GeometryReader {
          Color.clear.preference(
            key: ViewOffsetKey.self,
            value: -$0.frame(in: .named(scrollId)).origin.y
          )
        }
      )
      // Публикуем изменения оффсета
      .onPreferenceChange(ViewOffsetKey.self, perform: detector.send)
    }
    // Задаем идентификатор для координатной плоскости
    .coordinateSpace(name: scrollId)
    .onReceive(publisher) { finalOffset in
      // обрабатываем "окончание" скролла
    }
  }
}

Подводный камень

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

Круг вокруг курсора означает, что пользователь держит палец на экране. Когда круга нет - палец не касается экрана.

Заключение

Несмотря на отсутствие всех необходимых инструментов для работы со скроллом в iOS < 18 (сравниваю с UIKit), мы все-равно достаточно успешно справляемся с рабочими задачами на SwiftUI 😁

Кстати, в iOS 18 эта задача будет решаться при помощи onScrollPhaseChange.

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