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