Как стать автором
Поиск
Написать публикацию
Обновить
83.56
Surf
Создаём веб- и мобильные приложения

Синхронизируем скроллы в Sliver-списках

Уровень сложностиСредний
Время на прочтение9 мин
Количество просмотров1.1K

Всем привет, на связи Иван, тимлид и ведущий Flutter-разработчик Surf.
Сегодня потрогаем тему синхронизации двух списков при скролле и раскроем важные моменты при её реализации.

В одном из наших проектов дизайн предполагал синхронизацию горизонтального и вертикального Sliver-скроллов для последовательного перехода между разными категориями списков.

Спойлер: мы попробовали разные варианты решения и нашли оптимальный.

Для начала можно почитать полезные штуки:

Slivers
slivers_tools
flutter_sticky_header
scroll_to_index

Постановка задания

Иногда нужно сделать хитрый скролл с разными списками. Сама же секция со скроллом должна быть реализована Sliver-виджетами, а секция горизонтального списка должна быть зафиксирована при скролле.

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

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

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

Иным словами — всё это нужно синхронизировать.  Такой интерфейс часто встречается в фуд-техе в, особенно в списках  подкатегорий продуктов.

Учитываем и другие особенности верстки — прокручиваемая секция должна иметь другой фон и скругления, а при скролле общий фон должен менятся.

Сегодня мы с вами разберём решение с такой логикой и поймём, с каким проблемами можно столкнутся.

Заготовка экрана

Заготовим шаблон экрана на основе StatefulWidget, в состояние которого поместим наш будущий контроллер. 

Мы делегируем ему управление виджетом, State этого виджета нужен нам  для dispose контроллера.
И тут всплывают два момента:

1) Появляется DecoratedSliver — очень редкий виджет. Он используется для декорирования сливеров в иерархии. В нашем случае он нужен, чтобы разделить секции Sliver-виджетов с разными заливками и скруглениями.

2) После CupertinoRefreshControl мы используем SliverStickyHeader.
Расскажем, почему.
Мы используем SliverStickyHeader вместо коробочного SliverPersistentHeader, потому что последующие формулы расчета будут корректно работать только с SliverStickyHeader

Если же мы возьмём  SliverList + SliverPersistentHeader, то можем столкнуться с  проблемой разбиения контекста между Sliver-виджетами и последующим расчетом виджетов из вертикального списка.

Подобные проблемы описывались здесь.

Так мы получаем бесконечный офсет и при попытке скролла через контроллер скроллимся в конец списка — эту проблему и будем решать.

Возможно, вы уже сталкивались с чем-то подобным в функции ensureVisible() у объекта Scrollable.

У SliverStickyHeader обязательно устанавливаем параметр overlaps: true. Он указывает, что этот Header будет встроен как в стеке. У нижнего Sliver-виджета устанавливаем отступ хотя бы на высоту вертикального списка, который находится в Header.

Заготовка экрана

Заготовим шаблон для нашего контроллера окна на основе ChangeNotifier, который будет им управлять.

Для перемещения скролла к нужной позиции используем AutoScrollController — контроллеры из собственного пакета scroll_to_index. Это обертки над ScrollController и они нужны, чтобы не писать дополнительную логику линкования BuildContext в хеш-таблицу по индексу категории.

Из него же мы потом будем рассчитывать по сохраненному BuildContext рендер-объекты и расстояние до них.

final class _Controller extends ChangeNotifier {
  // Смещение отступа для вертикального списка
  static const double _scrollStickyOffset = 112;
  _Controller() {
    // Добавляем слушатель для расчета видимости категории
    // при прокрутке вертикального списка
    verticalScrollController.addListener(
      _calculateCategoryVisible,
    );
    _randomizeCategories();
  }

  // Список категорий с продуктами
  List<Category> categories = [];
  // Текущий выбранный индекс категории
  int categoryIndex = 0;
  // Флаг игнорирования скролла в случае выбора нужной категории
  bool _isIgnoreCatalogScroll = false;
  // AutoScroll Контроллеры горизонтального и вертикального списков
  final AutoScrollController horizontalScrollController = AutoScrollController(
    axis: Axis.horizontal,
  );
  final AutoScrollController verticalScrollController = AutoScrollController(
    axis: Axis.vertical,
  );

  // Расчёт нужной позиции скролла до нужного индекса конкретного AutoScroll контроллера
  // Не привязан к конкретному контроллеру
  double? _getCategoryOffset(
    int index,
    AutoScrollController controller,
  ) {}

  // Расчёт видимости конкретной категории при прокрутке вертикального списка
  void _calculateCategoryVisible() {}
  // Скроллим до нужной горизонтальной категории
  // Необходимо в нескольких местах - при скроллера вертикального списка
  // и при выборе другой категории
  Future<void> _scrollToHorizontalCategory(
    int index,
  ) async {}

  // Рефреш и рандомизация новых категорий с продуктами
  Future<void> onRefresh() async {}

  // Выбор нужной категории
  void onPressedCategory({
    required int index,
  }) async {}

  // Не забываем диспоузить контроллеры
  @override
  void dispose() {
    horizontalScrollController.dispose();
    verticalScrollController.dispose();
    super.dispose();
  }
}

Расчёт позиции скролла

Реализуем функцию, которая будет рассчитывать смещение к нужной позиции скролла AutoScrollController.

// Расчёт нужной позиции скролла до нужного индекса конкретного AutoScroll контроллера
// Не привязан к конкретному контроллеру
double? _getCategoryOffset(
  int index,
  AutoScrollController controller,
) {
  // Получаем контекст виджета по индексу от AutoScroll контроллера
  // Этот контекст был сохранен в момент билда и позволяет найти рендер-область виджета
  final BuildContext? context = controller.tagMap[index]?.context;
  if (context == null) {
    return null;
  }

  // Находим рендер-область виджета
  final RenderObject? renderBox = context.findRenderObject();
  if (renderBox == null) {
    return null;
  }

  // По полученной рендер-области получаем порт и вычисляем смещение от начала скролла
  final RenderAbstractViewport viewport = RenderAbstractViewport.of(renderBox);
  final RevealedOffset revealedOffset = viewport.getOffsetToReveal(
    renderBox,
    0,
  );
  // При таком расчет функция getOffsetToReveal может возвращать объект RevealedOffset
  // со смещением double.infinity.
  // Это означает что не удалось вычислить смещение от начала скролла, допустим, такого виджета
  // в иерархии виджетов внутри скролла нет
  if (revealedOffset.offset == double.infinity) {
    return null;
  }

  return revealedOffset.offset;
}

Расчёт положения вертикального скролла

Реализуем функцию, которая будет вычислять положение вертикального скролла.

// Расчёт видимости конкретной категории при прокрутке вертикального списка
void _calculateCategoryVisible() {
  if (_isIgnoreCatalogScroll) {
    return;
  }

  int newIndex = 0;
  // Проходим по всем индексам вертикального AutoScroll контроллера
  for (final int index in verticalScrollController.tagMap.keys) {
    // Находим вертикальное смещение до индекса у функции, которую заготовили заранее
    final double? offset = _getCategoryOffset(
      index,
      verticalScrollController,
    );
    if (offset == null) {
      continue;
    }

    // Если же вертикальный контроллер еще не достиг большего значения смещения
    // Значит другая категория еще не находится в зоне видимости
    if (offset >= verticalScrollController.offset + _scrollStickyOffset) {
      continue;
    }

    newIndex = index;
  }

  if (categoryIndex == newIndex) {
    return;
  }

  categoryIndex = newIndex;
  notifyListeners();
  _scrollToHorizontalCategory(newIndex);
}

Не забываем добавить эту функцию на прослушивание у нашего вертикального скролл-контроллера:

_Controller() {
  // Добавляем слушатель для расчёта видимости категории
  // при прокрутке вертикального списка
  verticalScrollController.addListener(
    _calculateCategoryVisible,
  );
  _randomizeCategories();
}

Скролл горизонтальной позиции

Выделяем в отдельную функцию скролл к нужной позиции горизонтального списка.

Условие с нулевым индексом необходимо, чтобы попасть в начало списка. Нам не нужно делать видимым часть первого виджета на экране, потому что у большинства элементов в списках есть отступы.

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

// Скроллим до нужной горизонтальной категории
// Необходимо в нескольких местах - при скроллера вертикального списка
// и при выборе другой категории
Future<void> _scrollToHorizontalCategory(
  int index,
) async {
  if (index == 0) {
    return horizontalScrollController.animateTo(
      0,
      duration: scrollAnimationDuration,
      curve: Curves.easeInOut,
    );
  }

  return horizontalScrollController.scrollToIndex(
    index,
  );
}

Выбор категории списка

Реализуем функцию-обработчик нажатия выбора новой категории горизонтального списка.

Функцию рефреша onRefresh() экрана можно реализовать по-своему — в нашем примере логики содержать она не будет.

// Выбор нужной категории
void onPressedCategory({
  required int index,
}) async {
  if (index == categoryIndex) {
    return;
  }

  // Так используем заготовленную функцию для получения смещения скролла вертикального списка
  final double? offset = _getCategoryOffset(
    index,
    verticalScrollController,
  );
  if (offset == null) {
    return;
  }

  // Обновляем новый индекс
  categoryIndex = index;
  notifyListeners();
  // Перемещаемся на новую позицию в горизонтальном списке
  _scrollToHorizontalCategory(index).ignore();
  // И в вертикальном - предварительно защитившись флагом, который
  // используется в функции определяющую видимость категории
  _isIgnoreCatalogScroll = true;
  await verticalScrollController.animateTo(
    offset - _scrollStickyOffset,
    duration: scrollAnimationDuration,
    curve: Curves.easeInOut,
  );
  _isIgnoreCatalogScroll = false;
}

Горизонтальный список

Превращаем заготовку горизонтального списка в имплементацию.

Каждый список оборачиваем в ListenableBuilder, который отслеживает  изменения индекса текущей категории и нового вертикального списка в контроллере.

Обязательно заворачиваем карточки категорий в виджет AutoScrollTag. Он принимает через конструктор AutoScrollController и сохраняет в его хеш-таблицу ссылку на свой BuildContext в момент построения в соответствии с индексом. Не забываем указать ему ключ ValueKey.

child: ListenableBuilder(
  listenable: _controller,
  builder: (
    BuildContext context,
    Widget? child,
  ) =>
      ListView.separated(
    controller:
        _controller.horizontalScrollController,
    scrollDirection: Axis.horizontal,
    padding: const EdgeInsets.symmetric(
      horizontal: 8,
    ),
    separatorBuilder: (
      BuildContext context,
      int index,
    ) =>
        const SizedBox(
      width: 8,
    ),
    itemCount: _controller.categories.length,
    itemBuilder: (
      BuildContext context,
      int index,
    ) {
      final Category category =
          _controller.categories[index];
      return AutoScrollTag(
        key: ValueKey(
          'Category-$index-${category.id}-${category.title}',
        ),
        controller:
            _controller.horizontalScrollController,
        index: index,
        child: Chips(
          title: category.title,
          onPressed: () =>
              _controller.onPressedCategory(
            index: index,
          ),
          isSelected:
              _controller.categoryIndex == index,
        ),
      );
    },
  ),
),

Вертикальный список

Так же поступаем с вертикальным списком — имплементируем нижнюю часть экрана.

sliver: ListenableBuilder(
  listenable: _controller,
  builder: (
    BuildContext context,
    Widget? child,
  ) =>
      MultiSliver(
    children: _controller.categories
        .mapIndexed(
          (
            int index,
            Category category,
          ) =>
              SliverToBoxAdapter(
            child: AutoScrollTag(
              key: ValueKey(
                  'CategoryProduct-$index-${category.id}-${category.title}'),
              controller:
                  _controller.verticalScrollController,
              index: index,
              child: Column(
                mainAxisSize: MainAxisSize.min,
                crossAxisAlignment:
                    CrossAxisAlignment.start,
                children: [
                  Text(
                    category.title,
                    style: const TextStyle(
                      color: Colors.black,
                      fontSize: 32,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                  const SizedBox(
                    height: 20,
                  ),
                  Column(
                    mainAxisSize: MainAxisSize.min,
                    children: List.generate(
                      category.products.length,
                      (
                        int index,
                      ) {
                        final Product product =
                            category.products[index];
                        return Padding(
                          padding: const EdgeInsets.only(
                            bottom: 8,
                          ),
                          child: ProductCard(
                            product: product,
                          ),
                        );
                      },
                    ),
                  ),
                  const SizedBox(
                    height: 32,
                  ),
                ],
              ),
            ),
          ),
        )
        .toList(),
  ),
),

Что в итоге

Готово. Вы великолепны!

Такой расчёт подходит для случаев, когда не нужно рассчитывать высоту всех карточек линейно-алгоритмическими формулами. А также нужно гарантировать скролл вне зависимости от небольших объемов данных и высоты разных отображаемых элементов.

Важный момент

У этого решения есть проблема. Оно отлично подходит для небольшого числа отображаемых элементов — когда мы на уровне продуктовой истории понимаем, с каким количеством сущностей имеем дело. Например, если это страница с подкатегориями большей категории продуктов, как в приложении Самокат.

Решение не подойдет для огромных или бесконечных списков. Создавая список через MultiSliver, мы заставляем фреймворк отрисовывать декларируемые элементы без рендер-оптимизации, потому что нам нужно понимать, к какой позиции виджета скроллить из полученного BuildContext, то есть сами элементы, этих категорий списка должны находится в дереве элементов.

Когда нужна подкапотная оптимизация рендеринга скролла, можно использовать стандартные фабрики виджетов —  ListView.separated, ListView.builder, SliverList.builder, SliverList.list.

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

Полный проект уже в нашем репозитории.

А вот, что получилось:

Больше полезного про Flutter — в Telegram-канале Surf Flutter Team. 

Кейсы, лучшие практики, новости и вакансии в команду Flutter Surf в одном месте. Присоединяйтесь!

Теги:
Хабы:
Всего голосов 5: ↑5 и ↓0+5
Комментарии0

Публикации

Информация

Сайт
surf.ru
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия