Scrollable является суперклассом таких виджетов, как ListViewCustomScrollViewSingleChildScrollView и многих других. В этой статье попытаемся понять, что происходит «под капотом».

Начнем с уведомлений об обновлении прокрутки.

1. Что представляет собой уведомление?

Flutter отправляет уведомления по дереву виджетов о таких событиях, как прокрутка, изменение размера и изменение макета.

Другими словами, когда что-то прокручивается или меняет свой размер, предки получают об этом уведомление.

Посмотрим, что находится внутри уведомления, отправленного при прокрутке.

NotificationListener<ScrollUpdateNotification>(
onNotification: (notification) {
return false; // <- установите здесь точку останова отладчика
},
child: ListView(
children: [
const SizedBox(height: 1000),
],
),
)

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

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

Прокручиваемый контент окрашен в красный цвет, а статичные виджеты — в синий.

Значения, не меняющиеся при прокрутке, показаны в левой половине изображения, а динамические — в правой.

Так, extentTotal — общая высота контента Scrollable; maxScrollExtent — высота контента, который не поместился в область просмотра; scrollDelta — необработанное количество пикселей, которые были прокручены с момента предыдущего уведомления; ententBefore и extentAfter соответствуют оставшейся высоте от начала и конца Scrollable.

viewPortDimension — высота виджета, содержащего Scrollable.

2. Что произойдет, если контент будет меньше области просмотра?

Изменим высоту SizedBox с 1000 на 200. Теперь события уведомления не отправляются, потому что прокрутки не происходит — размер прокручиваемого контента меньше области просмотра. Как получить доступ к нему, если потребуются эти значения?

class _ScrollableExampleState extends State<ScrollableExample> {
final _controller = ScrollController(); // <-- добавьте это

@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
_controller.position; // <-- установите здесь точку останова отладчика
});
}

...
ListView(
controller: _controller, // <-- добавьте это
...
)
}

Как видим, высота области просмотра равна extentTotal, а maxScrollExtent равен 0, поскольку в данном случае прокрутка невозможна.

3. Что такое DragDetails?

Если вернемся к объекту уведомления, заметим свойство dragDetails. Этот объект похож на объект, отправляемый в обратных вызовах обновления GestureDetector. Сделаем несколько прокруток на экране и посмотрим, как передаются данные:

print("$scrollDelta\t$dragDetails");

Прокрутим виджет быстрым движением и посмотрим на данные:

На первый взгляд кажется, что значения scrollDelta и dragDetails.y отрицательные, но одинаковые, однако обратим внимание на последнее. Догадываетесь, что здесь произошло? Подсказка: это было сделано на Android.

Проверим это на iPhone и увидим:

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

Конечно, причина в том, что по умолчанию в Scrollable применяются разные ScrollPhysics. По умолчанию в iOS применяется BouncingScrollPhysics, а в Android — ClampingScrollPhysics.

BouncingScrollPhysics и ClampingScrollPhysics

4. Как работает ScrollPhysics?

  • Получает данные о перетаскивании (drag details) и дельту прокрутки (scroll delta).
  • Проходит через слои моделирования (simulation).
  • Применяет при необходимости границы (boundaries).
  • Выводит положение (position) и скорость (velocity) прокрутки.
  • Scrollable отправляет вычисленное значение в качестве уведомления слушателям.

Во Flutter можно выбрать один из различных физических параметров (physics) или создать собственный. Кроме того, можно применить сразу несколько физических параметров прокрутки, например, так:

BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics())

В этом примере сначала будут применены симуляции BouncingScrollPhysics, а затем AlwaysScrollableScrollPhysics.

5. Всегда ли известна общая протяженность?

Нет, бывают случаи вычисления размера каждого элемента в Scrollable, например, с помощью ListView.builder, когда размер элемента зависит от его контента, скажем, есть виджет Text, который может иметь высоту 1 или 2 строки.

В этом случае нельзя вычислить totalExtent, потому что тогда придется выполнять вычисления для всего списка, что противоречит назначению ленивой загрузки. В данном случае Flutter будет инстанцировать только элементы, видимые в области просмотра + cacheExtent, который является параметром ListView. Обратите внимание: если элемент поместится в cacheExtent лишь частично, он все равно будет инстанцирован.

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

Если установить значение shrinkWrap в true, рендерер будет рассчитывать высоту viewPort на основе его дочерних элементов. Таким образом, ленивая загрузка будет отключена, что может привести к проблемам с производительностью. Используйте этот параметр, только если уверены, что в списке не будет много объектов.

6. Почему невозможно поместить Spacer или Flexible в Scrollable?

SingleChildScrollView(
  child: Column(
    children: [
      Text("Content"),
      const Spacer(), // <- не делайте так
      ElevatedButton(onPressed: () {}, child: Text("Button"))
    ],
  ),
)

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

LayoutBuilder(builder: (context, constraints) {
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
maxHeight: double.infinity,
),
child: IntrinsicHeight(
child: Column(
children: [
Text("Content"),
const Spacer(),
ElevatedButton(onPressed: () {}, child: Text("Button"))
],
),
),
),
);
})

LayoutBuilder:

  • получает родительские ограничения;
  • предоставляет контекст размера.

SingleChildScrollView:

  • включает прокрутку, когда контент переполняется.

ConstrainedBox:

  • устанавливает минимальную высоту, равную высоте родителя;
  • позволяет устанавливать бесконечную максимальную высоту;
  • предотвращает сворачивание колонки.

IntrinsicHeight:

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

Column:

  • упорядочивает дочерние элементы по вертикали;
  • расширяется до максимального пространства;

В результате можно использовать Spacer или Flexible в Scrollable.

Вы также можете использовать виджет ScrollableColumn из этого пакета.

7. Как использовать Scrollable и Transform?

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

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

class _ScrollableZoomerState extends State<ScrollableZoomer> {
  ValueNotifier<double> scrollPosition = ValueNotifier(0.0);
  
  ...
}

Здесь сохраняется нормальное положение прокрутки, где 0.0 — это начало, а 1.0 — полная прокрутка.


@override
Widget build(BuildContext context) {
return Scaffold(
appBar: ...,
body: NotificationListener<ScrollUpdateNotification>(
onNotification: (notification) {
scrollPosition.value = min(1, notification.metrics.pixels /
notification.metrics.maxScrollExtent);

return true;
},
child: widget.child,
),
);
}

Все элементарно: разделим пиксели прокрутки на максимальный размер прокрутки и убедимся, что он не превышает 100%. Затем применим несколько простых преобразований к кнопке Next.

appBar: AppBar(
actions: [
ValueListenableBuilder(
valueListenable: scrollPosition,
builder: (context, value, _) => Opacity(
opacity: value > 0.1 ? value : 0,
child: Transform.translate(
offset: Offset(0, 40 * (1 - value)),
child: TextButton(
onPressed: ...,
child: Text("Next"),
),
),
),
)
],
),

Можно попробовать различные значения и математические операции, единственное ограничение — ваше воображение.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Roman Ismagilov: Mastering Scrollable in Flutter

Предыдущая статьяC++: полное руководство по memset
Следующая статьяПочему трудно писать полезные библиотеки