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

В одном из наших проектов дизайн предполагал синхронизацию горизонтального и вертикального Sliver-скроллов для последовательного перехода между разными категориями списков.
Спойлер: мы попробовали разные варианты решения и нашли оптимальный.
Для начала можно почитать полезные штуки:
Постановка задания
Иногда нужно сделать хитрый скролл с разными списками. Сама же секция со скроллом должна быть реализована 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/
Добавить комментарий