Если вы уже встречались со сливерами, то наверняка оценили всю «прелесть» работы с ними.
На самом деле они совсем не так трудны и ужасны. Чтобы просто начать их использовать, как чаще всего бывает во Flutter, разбираться особо не нужно. А вот чтобы полноценно использовать их и при этом не страдать, придётся понять, как они работают. Именно этим мы и займемся.
Меня зовут Михаил Зотьев, я организатор официального комьюнити Flutter Voronezh, автор канала Oh, my Flutter и Tech Lead Flutter-команды в компании Surf. Это текстовая версия моего доклада о работе сливеров. Вы можете выбрать наиболее подходящий для вас формат материала: посмотреть видео или прочитать текст.
Что такое Sliver
Чтобы разобраться, что такое сливер, предлагаю вспомнить, как работает построение макета во Flutter.
За построение макета во Flutter отвечает дерево Render Object. Это происходит нехитрым путем, опираясь всего на три правила:
Ограничения спускаются вниз по дереву: от родителей к детям.
Размеры идут вверх по дереву от детей к родителям.
Родители устанавливают положение детей.
В большинстве случаев, когда мы говорим про ограничения, имеем в виду BoxConstraints. Они описываются четырьмя параметрами: минимальными и максимальными шириной и высотой.
BoxConstraints отдаются родителем в метод layout. В результате получаем размеры. И всё это отлично работает, пока не нужно реализовать поведения, связанные с прокруткой.
Пример. Возьмём AppBar: он должен менять внутреннее состояние, исходя из положения прокрутки.
Сначала AppBar уменьшается, потом уходит с экрана. Скроллим дальше, а затем начинаем скроллить в обратном направлении. AppBar при этом должен начать появляться. Довольно стандартная ситуация, однако с помощью BoxConstraints такое не сделать. Здесь не обойтись без механизма работы с прокруткой — Sliver Protocol.
Sliver — некоторая часть контента в скроллящейся области, которая подчиняется Sliver Protocol. Но не стоит думать, что это фундаментальное изменение и что принцип протокола отличается от стандартного механизма работы. Разница поведения достигается расширением сущностей. Ограничения в данном случае — SliverConstraints, а размер возвращается в виде SliverGeometry. Давайте начнём с небольшой теоретической части и разберём, что они из себя представляют.
Как устроен скролл
Прежде чем мы в очередной раз начнем ковыряться в кишочках Flutter, я предлагаю подумать: как можно описать устройство скролла, не касаясь Flutter. Мне кажется, стоит выделить три составляющие с разными уровнями ответственности. Волшебное сочетание для Flutter, не правда ли?
слой взаимодействия с вводом пользователя;
контейнер, который будет содержать всё, что мы скроллим;
контент, который мы собираемся скроллить.
Как мы не раз убеждались, Flutter обычно не изобретает велосипеды. Этот случай — не исключение: реализация совпадает с тем, что мы описали выше.
Слой взаимодействия. Виджет Scrollable
Scrollable — виджет, который, как гласит документация, умеет скроллиться.
А если без шуток, он реализует модель взаимодействия прокрутки, включая распознавание жестов, но не имеет понятия о том, как построено отображение дочерних элементов.
Контейнер Viewport
Контейнер должен содержать всё, что мы собираемся скроллить. Для этого используется Viewport — наследник MultiChildRenderObjectWidget.
Viewport — рабочая лошадка механизма скролла, контейнер, внутренняя часть которого намного больше, чем физический размер. Это своеобразная «черная дыра» в мире скролла Flutter. Он используется во всех виджетах со скроллом: ListView, PageView или CustomScrollView — во всех будет Viewport в комбинации со Scrollable.
Дочерние объекты для Viewport — сливеры.
Поскольку Viewport — контейнер для скролла, для его работы явно требуются направления:
axisDirection — основное направление, в котором увеличивается смещение при прокрутке. Относительно него выделяют передний и задний край ViewPort.
crossAxisDirection — поперечное направление.
Но это не всё: если у нас есть две оси, они должны где-то пересекаться.
Эта точка называется anchor. Находится она с правой или левой стороны, зависит от crossAxisDirection. А вот расположение на основной оси можно задать. Вся область в основном направлении представляет собой отрезок [0; 1] в направлении axisDirection.
Дефолтное значение этой точки — 0: если направление основной оси — вниз, а поперечной — вправо, то anchor находится в левом верхнем углу.
Задав значение anchor, например 0,5, мы сместим anchor на середину по основной оси.
На самом деле область ответственности у anchor намного больше, чем просто начало отсчёта. Это точка выравнивания так называемого центрального сливера — center.
Центральный сливер — один из дочерних. От него в списке дочерних сливеров идёт отсчёт. Все, кто находится после него, появляются в прямом порядке. До — в обратном. Для обозначения этого порядка используется перечисление GrowthDirection.
Дефолтный вариант центрального сливера — первый сливер. Вы можете задать его самостоятельно, указав ключ виджета, который должен стать центральным сливером.
Центральный сливер отображается в точке anchor при нулевом смещении прокрутки, при этом расчёты ведутся относительно него.
Ещё один важный момент: у Viewport имеется область кэширования. Она нужна для плавной прокрутки: чтобы контент, который вот-вот должен появиться, готовился заранее.
Поскольку пользователь может резко поменять направление прокрутки, только что ушедшие объекты опять станут нужны, поэтому областей кэширования две: до переднего края и после заднего. Дефолтный размер каждой области — 250.
Общий размер, который Viewport попытается заполнить дочерними элементами, равен:
размеру области кэша перед передним краем + размеру по основной оси + размеру области кэша после заднего края.
Соединяем Viewport и Scrollable
Давайте соберём Viewport и Scrollable вместе и посмотрим, как работает отображение и интерактивность при скролле. Задумываться над поведением самого содержания пока не будем.
Разберём стандартную ситуацию: когда ось вертикальная, axisDirection направлен вниз. Остальные варианты легко будет моделировать по правилам, которые мы рассмотрим.
Элементы списка пронумерованы от 0 до 7 в порядке возрастания. Отображены два варианта: описываются они параметром growthDirection. Если направление, в котором появляются новые элементы, совпадает с направлением axisDirection, порядок считается прямым — forward. Иначе — обратным, reverse.
Мы также помним, что у нас есть Scrollable, организующий взаимодействие с вводом пользователя. Это взаимодействие описывается параметром userScrollDirection.
В тот момент, когда мы не совершаем скролл, этот параметр имеет значение ScrollDirection.idle. Это верно в ситуациях, когда нет касания или движения.
Когда начинаем скроллить, параметр может принять одно из двух значений:
ScrollDirection.forward — когда элементы появляются в возрастающем порядке.
ScrollDirection.reverse — когда в убывающем.
Контент
Последний пункт в нашем списке — то, что скроллим: контент. Как было сказано выше, контент при скролле — это сливеры: обычные виджеты, только с более детализированным расчётом при построении макета.
Давайте рассмотрим, какие ограничения спускаются сливеру для расчета.
SliverConstraints
Чтобы разобраться было проще, я сделал небольшое приложение.
Для полноты картины коснусь ещё раз параметров, которые мы уже рассмотрели при разборе ViewPort.
axis
Ось, по которой измеряются scrollOffset и RemainExtent.
axisDirection
Направление оси, по нему увеличиваются параметры scrollOffset и RemainExtent.
growthDirection
Направление, в котором упорядочивается содержимое сливера относительно axisDirection.
crossAxisDirection
Поперечное направление расположения потомков.
isNormalized
Параметр, отражающий, выражены ли ограничения в нормализованном виде или нет. Нормализованным видом считается направление оси AxisDirection.down или AxisDirection.right в зависимости от параметра axis.
normalizedGrowthDirection
Показывает, каким было бы направление упорядочивания при нормализованном представлении.
isTight
Показывает, жёсткие ли заданы ограничения. Жёсткими считаются те, при которых возможен лишь один допустимый размер.
viewportMainAxisExtent
Размер Viewport в основном измерении.
Здесь и далее на картинках изображен стандартный случай, если не сказано обратное
crossAxisExtent
Размер Viewport в поперечном измерении.
userScrollDirection
Направление, в котором пользователь пытается совершить прокрутку.
Теперь мы подошли к численным параметрам, которые описывают скролл и отрисовку.
scrollOffset
Важный параметр в протоколе сливеров, который описывает смещение прокрутки. Он декларирует, насколько смещена наиболее ранняя часть сливера относительно видимой области. Пока сливер находится в видимой области, scrollOffset будет нулевым.
Важный момент: речь идёт именно про видимую область, а не про положение точки anchor.
При выходе хотя бы части сливера за пределы видимой части, scrollOffset становится отличным от нуля.
Если направления основной оси нестандартные, расположение переднего края видимой области тоже меняется: scrollOffset рассчитывается с учётом этого.
precedingScrollExtent
Параметр precedingScrollExtent показывает, какое суммарное смещение вмещают в себя остальные сливеры, которые предшествуют текущему.
Выглядит достаточно понятно, но есть подводный камень. Сливеры часто лениво создают свой внутренний контент, например SliverList. Когда контент подобного сливера превышает область просмотра, дочерние создаются по необходимости и сам сливер не имеет достаточно информации для оценки общего размера. В таком случае precedingScrollExtent будет бесконечным для всех RenderSliver, которые появляются после подобного ленивого сливера. Параметр перестанет быть бесконечным, как только наступит момент, при котором создан весь контент ленивого сливера и имеется возможность просчитать размер.
remainingPaintExtent
Размер контента, который должен отобразить сливер. Отображать большее количество контента нерационально.
«Должен» используется в качестве верхнего ограничения, потому что фактический размер будет задан при описании геометрии.
Пока сливер находится в видимой части Viewport, этот параметр фактически описывает расстояние от переднего края сливера до заднего края Viewport.
Когда передний край сливера перейдет границу переднего края Viewport, параметр зафиксируется на размере viewportMainAxisExtent.
Параметр может быть равен 0, если сливер находится ниже заднего края Viewport.
Он может быть бесконечным, если Viewport не имеет ограничений: например, RenderShrinkWrappingViewport.
remainingCacheExtent
Этот параметр идентичен предыдущему: только его границами выступают не видимые части Viewport, а границы областей кэша.
Пока сливер находится в области контента Viewport (видимая часть плюс два кэша), этот параметр фактически описывает расстояние от переднего края сливера до начала области кэша.
Когда передний край сливера перейдет границу окончания кэша, параметр зафиксируется на размере области контента.
Также этот параметр может быть равен 0, если сливер находится ниже начала области кэширования.
cacheOrigin
Показывает, где область кэша начинается относительно смещения сливера.
Параметр cacheOrigin всегда отрицателен или равен нулю и никогда не превышает по модулю значение cacheExtent Viewport.
Пока сливер находится в видимой области, параметр равен 0.
При переходе в область кэша параметр начинает принимать значение отрицательного scrollOffset.
Так происходит до достижения конца области кэша, когда scrollOffset равен cacheExtent. После этого cacheOrigin фиксируется на значении отрицательного cacheExtent.
Не поменяется это даже тогда, когда сливер полностью уйдет из области кэша.
При этом не важно, насколько большим будет сам сливер. Даже если будет существовать чать сливера, которая находится в видимой области, при переходе переднего края за область кэша поведение останется таким, как описано выше.
overlap
Расстояние от первых отрисовываемых пикселей сливера до первых не перекрытых предыдущим сливером пикселей. Используется в эффектах прилипания и плавания, например, SliverAppBar.
Пока перекрытия не происходит, параметр равен нулю.
Прямоугольник синего цвета изображает как раз SliverAppBar
Если предыдущий виджет полностью скрыт, перекрытие тоже не происходит.
Когда появится предыдущий сливер (например, при движении вниз со SliverAppBar), overlap примет значение пересечения этих двух виджетов.
Если предыдущий сливер виден на экране полностью, сливер, не вышедший из-под него, получит overlap в размер части, покрываемой предыдущим сливером.
Если физика скролла резиновая (например, как в iOS), получим отрицательный overlap при оттягивании виджета от предыдущего.
Результатом вычисления макета по этим параметрам во Flutter станут размеры. Роль размеров в протоколе сливеров выполняет SliverGeometry.
SliverGeometry
SliverGeometry — класс, описывающий, какое количество места занимает RenderSliver. В случае со сливером это может происходить множеством способов, поэтому класс содержит большое количество параметров. Именно эти параметры нужно использовать в ответ на ограничения, чтобы определить поведение, которое хотим реализовать.
visible
Должен ли сливер быть отрисован. Если выставить параметр в false, получим поведение, при котором Flutter не будет отрисовывать содержимое сливера, где бы тот ни находился.
scrollExtent
Предполагаемая общая протяженность прокручиваемой области, для которой есть содержимое этого сливера. То есть какой длины прокрутку нужно совершить пользователю, чтобы полностью прокрутить сливер от начала до конца.
Это значение используется для вычисления смещения всех сливеров, поэтому должно выставляться, даже когда сливер находится не в зоне отображения.
Обычно scrollExtent является постоянным для сливера на протяжении всей прокрутки, в то время как paintExtent и layoutExtent будут меняться:
быть равными 0, когда сливер за пределами экрана;
расти от 0 до scrollExtent, когда сливер частично находится на экране;
или быть равными scrollExtent, пока сливер полностью на экране.
paintExtent
Количество занятого сливером визуального пространства для отображения. Охватывает весь SliverConstraints.remainingPaintExtent или часть текущего Viewport. Значение этого параметра должно быть между 0 и remainingPaintExtent.
Параметр не влияет на расположение следующего сливера. Например, если бы paintExtent был 100, а layoutExtent был 0, сливеры со стандартным поведением, следующие за ним, рисовались поверх него.
Параметр используется для расчёта SliverConstraints.overlap следующего сливера.
layoutExtent
Расстояние от первой видимой части этого сливера до первой видимой части следующего сливера при условии, что SliverConstraints.scrollOffset следующего сливера нулевой.
Значение параметра должно быть от 0 до paintExtent. По умолчанию используется paintExtent.
cacheExtent
Какой размер занимает сливер в SliverConstraints.remainingCacheExtent.
Это значение должно быть больше или равно layoutExtent или быть больше него. Сливер всегда занимает как минимум layoutExtent, а поскольку дефолтное значение layoutExtent совпадает с paintExtent, в момент попадания в область кэширования значение layoutExtent уменьшается. Следовательно, cacheExtent, не изменяясь, становится больше него.
maxPaintExtent
Предполагаемый общий размер отрисовки, которую сливер мог бы использовать, если бы SliverConstraints.remainingPaintExtent был бесконечным. Параметр используется для Viewport, реализующих механизм shrink-wrapping.
paintOrigin
Смещение визуального расположения первой видимой части сливера по отношению к её положению в макете.
Значение по умолчанию 0: сливер начинает отрисовку от начала макета. Чтобы сместить отрисовку раньше, нужно задать отрицательное значение. При этом смещённая часть будет отображаться под предыдущим сливером. Если задать положительное значение параметра, отображение ляжет нахлёстом на следующий сливер.
Параметр не влияет на расположение других сливеров, однако аффектит вычисление SliverConstraints.overlap для следующих сливеров.
hitTestExtent
Определяет расстояние от начала рисования сливера, в рамках которого сливер принимает взаимодействие с собой. Он должен быть от нуля до paintExtent. По умолчанию используется paintExtent. Если мы его уменьшим, не вся видимая часть будет принимать взаимодействие.
maxScrollObstructionExtent
Максимальный размер, на который сливер может уменьшить прокручиваемую область, если реализует прилипание к краю. Примером такого виджета может служить SliverAppBar с параметром pinned = true.
Сливеры, не прилипающие к краю, должны возвращать 0.
scrollOffsetCorrection
Значение, которое родитель будет использовать для корректировки смещения прокрутки. Если после завершения RenderSliver.performLayout параметр не равен нулю, смещение прокрутки будет скорректировано родителем, а затем будет повторно запущен процесс построения макета родителя.
Если родитель также является RenderSliver, он должен продолжить пробрасывать эту информацию через своё свойство наверх до тех пор, пока она не доберется до Viewport, который сможет по этой информации осуществить корректировку прокрутки.
hasVisualOverflow
Имеется ли визуальное переполнение у сливера. Стандартное поведение false: означает, что сливер не будет обрезан. Параметр можно выставить в true: если сливер частично находится вне зоны отображения, это позволит не отрисовывать то, что всё равно не будет видно.
Как всё это использовать на практике
На практике эти знания могут пригодиться при реализации любых кастомных эффектов скролла. Когда я думал, что же продемонстрировать в качестве примера, мне вспомнился один персонаж — инвестор по имени Расс Ханнеман из Кремниевой долины. А конкретно — его отношение к дверям автомобиля.
Если вы сейчас не поняли о чем я, то вот краткое содержание :) Аккуратно: обсценная лексика.
Я решил сделать пример, в котором виджеты уходили с экрана так, как это понравилось бы Рассу.
Результат доступен в виде небольшой библиотеке на пабе
Надеюсь, этот материал поможет вам хорошо разобраться с устройством сливеров и они никогда больше не доставят вам проблем при разработке.
Mitai
Спасибо, такое нужно обязательно добавить в закладки