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

от автора

Всем привет, на связи Иван, тимлид и ведущий 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 в одном месте. Присоединяйтесь!


ссылка на оригинал статьи https://habr.com/ru/articles/887758/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *