Sliver (слайвер) представляет собой часть прокручиваемой области, которая раскладывает себя от текущего смещения скролла, а не от размера бокса. CustomScrollView складывает несколько slivers в один скролл с одной позицией, а ListView, то есть список из одного sliver, так не умеет.
Sliver получает SliverConstraints (смещение скролла, сколько осталось от viewport) и отвечает SliverGeometry, поэтому каждая часть страницы сама решает, как вести себя при скролле.
Схлопывающиеся или закреплённые заголовки: SliverAppBar, SliverPersistentHeader и PinnedHeaderSliver существуют только как slivers
Разный контент в одном скролле: бокс-шапка, сетка и список рядом (SliverToBoxAdapter, SliverGrid, SliverList), и всё лениво
Короткий контент: SliverFillRemaining заполняет остаток экрана под пустое состояние или футер
**Почему не Column + ListView**: вложенные скроллы или shrinkWrap выбрасывают ленивость; slivers сохраняют один viewport
Простому списку всё это не нужно: ListView уже scroll view вокруг одного sliver-списка
FlutterSenior
Sliver-ы и скроллинг
CustomScrollView, SliverAppBar, ScrollController, physics, NestedScrollView, keep-alive
Закрыто вопросов
0 / 22
- 0Повторить
- 1Часто спрашивают
Вопросы
В debug-режиме падает, когда viewport строит детей: «A RenderViewport expected a child of type RenderSliver but received a child of type RenderPadding». Каждый элемент slivers должен быть sliver, а Padding и ListView относятся к бокс-виджетам.
**Бокс внутри slivers**: оберните его в SliverToBoxAdapter; для отступов вокруг slivers есть SliverPadding
**ListView внутри slivers**: замените на SliverList.builder с теми же itemCount и itemBuilder; ListView является целым scroll view, а не sliver
Зеркальная ошибка: SliverList внутри Column падает с обратным сообщением, потому что бокс-родитель ждёт RenderBox
Правило: виджеты со словом Sliver в имени идут в slivers, всем остальным нужен адаптер
Оберните скролл в RefreshIndicator и верните Future из onRefresh: спиннер крутится, пока он не завершится. Список, который помещается на экран, нельзя потянуть, если он не primary и у него нет AlwaysScrollableScrollPhysics, и до индикатора ничего не доходит.
Где пропадает дефолт: на мобильных вертикальный список без controller считается primary и всегда скроллится; если передали свой контроллер (обычное дело при пагинации) или запускаетесь на вебе и десктопе, physics нужно добавить вручную
Дожидайтесь настоящей работы: если onRefresh завершится раньше, чем придут данные, спиннер исчезнет слишком рано
Пустое состояние и ошибка: кладите их внутрь скролла (ListView с одним ребёнком или SliverFillRemaining), иначе тянуть не за что
Вид как в iOS: RefreshIndicator.adaptive или CupertinoSliverRefreshControl первым sliver в CustomScrollView с bouncing-физикой
expandedHeight задаёт раскрытый размер, тулбар определяет схлопнутый, а флаги решают, что происходит между ними: pinned оставляет тулбар на экране, floating возвращает бар при любом скролле вверх, snap доводит это движение до конца при отпускании.
**pinned**: тулбар и bottom (часто TabBar) остаются, когда заголовок схлопнулся; без него весь бар уезжает
**floating: бар появляется, как только пользователь скроллит вверх, где бы ни был список, а не только наверху
snap**: только вместе с floating: true (иначе сработает assert): при отпускании бар анимируется до полностью открытого или закрытого
**FlexibleSpaceBar**: заполняет раскрытую область: background (картинка с CollapseMode.parallax) и title, который сжимается на тулбар
Подвох с высотой: expandedHeight уже включает bottom, а отступ статус-бара добавляется сверху
Частая ошибка: title и у SliverAppBar, и у FlexibleSpaceBar даёт два заголовка, и анимируется только один
Material 3: SliverAppBar.medium и SliverAppBar.large дают стандартный схлопывающийся заголовок без FlexibleSpaceBar
Делегат объявляет maxExtent и minExtent; sliver резервирует maxExtent скролла и вызывает build с shrinkOffset, то есть смещением скролла, ограниченным maxExtent; полностью схлопнут заголовок при maxExtent - minExtent. С pinned: true заголовок затем останавливается на minExtent.
**build(context, shrinkOffset, overlapsContent)**: (shrinkOffset / (maxExtent - minExtent)).clamp(0.0, 1.0) даёт ваше значение анимации от 0 до 1; clamp нужен, потому что закреплённый заголовок продолжает получать растущий shrinkOffset; overlapsContent равен true, пока строки проезжают под закреплённым заголовком, и именно тогда нужна тень
Заполняйте выделенное место: дочерний виджет получает текущий extent как максимальную высоту, а не как навязанный размер; дочерний виджет ниже выделенной высоты падает с «The layoutExtent exceeds the paintExtent», поэтому возвращайте то, что растягивается (Align, SizedBox.expand), а отступы и размер шрифта считайте от shrinkOffset
**shouldRebuild**: вызывается, когда приходит новый экземпляр делегата; сравнивайте поля, которые читает build. => true перестраивает заголовок при каждой перестройке родителя, => false замораживает его на устаревших данных
Варианты проще, начиная с Flutter 3.24: PinnedHeaderSliver (закреплён, без делегата) и SliverResizingHeader (размер между двумя виджетами-прототипами) закрывают большинство заголовков без класса-делегата
shrinkWrap: true делает список высотой с его контент вместо того, чтобы заполнять viewport, а чтобы узнать эту высоту, строит и раскладывает каждого ребёнка, так что список перестаёт быть ленивым.
Это обычный быстрый фикс для «Vertical viewport was given unbounded height» внутри Column или другого списка. С десятью строками это безвредно; с сотнями каждая перестройка платит за все. Column из строк внутри SliverToBoxAdapter стоит столько же: viewport пропускает целые slivers, но не строки внутри одного бокса.
Список под шапкой: один CustomScrollView: SliverToBoxAdapter для шапки, SliverList.builder для строк
Несколько списков в одном скролле: по SliverList на секцию, сгруппированные через SliverMainAxisGroup, если у секции свой заголовок
**Список, заполняющий остаток Column**: Expanded(child: ListView.builder(...))
Когда это нормально: короткий ограниченный список, который должен подстроиться под контент, например пять вариантов в диалоге; добавьте NeverScrollableScrollPhysics, если скроллит родитель
ScrollController даёт доступ к подключённой ScrollPosition: читайте offset или position.extentAfter в слушателе и двигайте список через jumpTo или animateTo. В initState ни один scroll view ещё не подключил позицию, поэтому offset падает на assert.
Догрузка: в слушателе проверяйте position.extentAfter < 500 (сколько пикселей осталось ниже viewport) и вызывайте загрузчик, который игнорирует вызовы, пока страница уже грузится
**Без setState в слушателе**: он срабатывает на каждом кадре скролла; кладите производное значение в ValueNotifier и перестраивайте только виджет, который его использует
До первой раскладки: используйте initialScrollOffset или addPostFrameCallback с проверкой hasClients
**animateTo возвращает Future: он завершается, когда анимация закончилась или пользователь схватил список, а не обязательно в целевой точке
Владение**: создавайте контроллер один раз в State, вызывайте dispose() в dispose, один контроллер на один scroll view
Слушатель вызывает onLoadMore на каждом кадре скролла, пока пользователь в пределах 500 px от конца, поэтому один жест отправляет много запросов за одной и той же страницей. А контроллер создаётся в build, так что каждая перестройка делает новый, который никто не освобождает.
Защитите загрузку: держите флаг isLoading (и флаг hasMore) у того, кто владеет страницами, и выходите сразу, пока запрос в полёте
Создайте контроллер один раз: поле late final или initState, и dispose() в dispose; ScrollController наследует ChangeNotifier, а при включённом отслеживании утечек (тесты, DevTools) каждый неосвобождённый экземпляр попадает в отчёт
Почему баг прячется: Scrollable переносит существующую ScrollPosition на новый контроллер, поэтому смещение переживает каждую перестройку и список выглядит нормально
Что всё же ломается: всё остальное, что запомнило старый контроллер, например кнопка «наверх», теперь держит контроллер без клиентов
setState только помечает список «грязным»; новая строка строится и раскладывается в следующем кадре. maxScrollExtent, прочитанный сразу после него, всё ещё указывает на старый конец, поэтому список анимируется туда, где уже стоит, а новое сообщение остаётся на одну строку ниже края экрана.
Почему так: build и layout выполняются, когда рисуется следующий кадр, и только layout обновляет ScrollPosition.maxScrollExtent; всё в том же синхронном вызове видит старую геометрию. В ту же ловушку попадает Scrollable.ensureVisible для только что добавленной строки
Исправление: WidgetsBinding.instance.addPostFrameCallback срабатывает после layout этого кадра, и extent уже учитывает новую строку; проверьте mounted и hasClients, потому что к этому моменту экрана может уже не быть
Проще для чатов: ListView.builder(reverse: true), где новое сообщение под индексом 0: низ всегда на смещении 0, так что новое сообщение появляется на месте, и не нужно ни читать extent, ни ждать кадр
Как поймать: виджет-тест: отправить, pumpAndSettle, затем проверить position.pixels == position.maxScrollExtent или что rect нового пузыря внутри viewport
Контроллер сообщает только о позиции, к которой подключён; NotificationListener слышит каждый скролл под собой, включая те, что создавали не вы. Поэтому уведомления горизонтальной карусели доходят до слушателя ленты; фильтруйте по depth == 0.
**depth: 0 означает ближайший скролл; вложенный приходит с большим числом
Возвращаемое значение**: true останавливает всплытие уведомления, false позволяет предкам его увидеть
Полезные типы: ScrollStartNotification и ScrollEndNotification для начала и конца перетаскивания, ScrollUpdateNotification с metrics, UserScrollNotification для смены направления (кнопки, которые прячутся при скролле)
**notificationPredicate**: RefreshIndicator и Scrollbar принимают только depth 0; внутри NestedScrollView внутренний список имеет depth 1, поэтому передайте (n) => n.depth == 1
Когда слушатель удобнее: контроллер не ваш (виджет из пакета, body у NestedScrollView) или нужны начало и конец жеста
Слушатель ScrollController срабатывает только при изменении смещения. На высоком экране первые 10 постов помещаются целиком, maxScrollExtent равен 0 и скроллить нечего, поэтому _onScroll не вызывается и вторая страница не запрашивается.
Исправление: перепроверять после layout кадра: addPostFrameCallback из initState (первый кадр) и из didUpdateWidget (каждая новая страница). Загрузка идёт, пока контент не уйдёт на 400 px за viewport, даже если список так и не стал прокручиваемым
Защитите загрузчик: он должен игнорировать вызовы, пока страница грузится, и останавливаться, когда страниц больше нет (hasMore), иначе короткая последняя страница зациклит загрузку; в колбэке проверяйте mounted
Другие варианты: ScrollMetricsNotification помогает, только пока меняются метрики (первая раскладка, поворот, страница, из-за которой список переполнил экран); если контент всё ещё короче viewport, уведомление не приходит. Другая альтернатива в том, чтобы запрашивать из itemBuilder, когда строятся последние строки, как делает infinite_scroll_pagination: построение идёт за layout, а не за скроллом
Другие варианты: перепроверять в addPostFrameCallback после прихода каждой страницы или запрашивать из itemBuilder, когда строятся последние строки, как делает infinite_scroll_pagination: построение идёт за layout, а не за скроллом
Как поймать: виджет-тест с высоким tester.view.physicalSize, который pump-ит ленту и ожидает второй запрос
ScrollPhysics решает, как позиция скролла реагирует на перетаскивание, на края и на fling. Платформенный дефолт приходит из ScrollBehavior (bouncing на iOS и macOS, clamping на остальных), а переданная вами physics накладывается поверх этого дефолта через applyTo.
**BouncingScrollPhysics: оверскролл с пружинным возвратом; ClampingScrollPhysics даёт остановку на краю (растяжение или свечение даёт behavior, а не physics)
NeverScrollableScrollPhysics: пользователь не может тянуть список; для списка внутри другого скролла или такого, который двигают только из кода
AlwaysScrollableScrollPhysics: принимает перетаскивание, даже когда контент помещается; это нужно pull-to-refresh
Композиция**: physics образуют цепочку через parent, и каждый класс переопределяет только то, что меняет: BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics())
На всё приложение: свой ScrollBehavior в MaterialApp.scrollBehavior или ScrollConfiguration вместо physics на каждом списке; он же задаёт dragDevices, чтобы на вебе и десктопе списки можно было тянуть мышью
**primary**: помечает scroll view, который использует PrimaryScrollController, чтобы тап по статус-бару в iOS прокручивал его наверх
Всё в пределах 250 логических пикселей выше и ниже viewport строится и раскладывается, но не рисуется: это дефолтный cacheExtent. При строках по 96 px это примерно три лишние строки с каждой стороны.
Эти строки полноценные: элементы примонтированы, initState отработал, раскладка сделана; пропускается только отрисовка. Когда строка покидает cache-зону, её размонтируют и уничтожают её State, поэтому при скролле туда-обратно через эту границу она каждый раз строится заново.
**Поэтому initState не подходит для запроса: подгрузить картинки на 250 px вперёд полезно, а слать сетевые запросы за строками, которые пользователь может и не увидеть, не стоит; грузите данные постранично в слое, который владеет списком
Настройка через cacheExtent**: в логических пикселях на каждую сторону; большее значение покупает более плавный скролл ценой лишних build и layout, а 0 строит строки только в момент, когда они нужны, и это обычно даёт рывки
Фиксированная высота помогает сильнее: itemExtent, prototypeItem или SliverFixedExtentList превращают раскладку в арифметику вместо измерения каждой строки
Нужно смещение строки в скролле, а ленивый список знает его только для уже разложенных строк. При фиксированной или вычислимой высоте строки смещение сводится к арифметике; иначе берите список, который умеет прыгать к индексу, например scrollable_positioned_list или super_sliver_list.
Фиксированная высота: itemExtent: 72, затем animateTo(index * 72); с itemExtentBuilder сложите высоты до нужного индекса
**Scrollable.ensureVisible: работает только для строки, которая построена прямо сейчас (на экране или в cache extent), поэтому подходит для «покажи выбранный чип», а не для дальних прыжков
Неизвестные высоты**: ItemScrollController.scrollTo(index: ...) из scrollable_positioned_list или ListController.jumpToItem / animateToItem из super_sliver_list; оба выстраивают список вокруг целевого индекса, поэтому неизвестное смещение строки не нужно
Ограничьте цель: значением position.maxScrollExtent, иначе последние строки проскакивают конец
newIndex считается, пока перетаскиваемая строка ещё в списке, поэтому при движении вниз перед вставкой нужно вычесть единицу. А ValueKey(i) привязывает каждый элемент к позиции, а не к задаче, так что стейт остаётся на месте, пока данные переезжают.
Исправление индекса: if (newIndex > oldIndex) newIndex -= 1; перед tasks.insert(newIndex, tasks.removeAt(oldIndex)); движению вверх правка не нужна
Исправление ключа: key: ValueKey(tasks[i].id): стабильный id из данных, уникальный в списке
В том же кадре: меняйте модель синхронно внутри onReorder; если следующий build ещё показывает старый порядок, строка отлетает назад до того, как придёт новое состояние
Ручки перетаскивания: ReorderableListView добавляет их по умолчанию (долгое нажатие на мобильных, ручка на десктопе); задайте buildDefaultDragHandles: false и оберните свою ручку в ReorderableDragStartListener. В CustomScrollView так же используйте SliverReorderableList
Ленивый sliver размонтирует строки, вышедшие за viewport плюс cache extent, и уничтожает их State. Миксин просит обёртку KeepAlive над строкой оставить этот один элемент примонтированным, поэтому играющее видео или наполовину заполненное поле переживают скролл.
Контракт: переопределите wantKeepAlive, вызовите super.build(context) в build, вызывайте updateKeepAlive(), когда ответ меняется
Тихий отказ первый: забытый super.build(context): ничего не регистрируется и в рантайме ничего не падает; намекает на это только предупреждение анализатора must_call_super
Тихий отказ второй: addAutomaticKeepAlives: false у делегата убирает обёртку, с которой общается миксин
Цена: сохранённая строка не раскладывается и не рисуется, но остаётся примонтированной: её стейт, декодированные картинки, подписки и контроллеры остаются в памяти, а тикеры продолжают работать
Правило: держите живым только то, что нельзя дёшево перестроить, и только пока это важно; всё, что может жить в модели (флаги раскрытия, черновик текста, смещение вложенного списка), должно жить там. Если держать живыми все строки, вы заново построите неленивый список
Он нужен, когда схлопывающийся заголовок стоит над TabBarView или PageView, страницы которых скроллятся сами. NestedScrollView ведёт две позиции скролла, и перекрытие, которое закреплённый заголовок создаёт во внешней, не доходит до внутренних списков, пока вы не передадите его явно.
Внутри одного CustomScrollView это происходит автоматически: закреплённый sliver сообщает своё перекрытие, а следующий sliver читает его как constraints.overlap. У NestedScrollView есть внешний viewport для заголовка и внутренний на каждую страницу, поэтому каждая страница начинается со своего смещения 0, то есть под баром.
Absorb: оберните закреплённый SliverAppBar в SliverOverlapAbsorber с NestedScrollView.sliverOverlapAbsorberHandleFor(context)
Inject: поставьте SliverOverlapInjector с тем же handle первым в каждый внутренний CustomScrollView; Builder даёт поиску контекст ниже NestedScrollView
Без собственного контроллера у внутренних списков: они подключаются через PrimaryScrollController; если дать одному из них свой ScrollController, заголовок перестанет схлопываться
Общая позиция заголовка: у всех вкладок одна внешняя позиция, поэтому при переходе с длинной вкладки на короткую можно увидеть схлопнутый заголовок над списком, который некуда скроллить
Два механизма. PageStorageKey хранит смещение в бакете в памяти, который живёт столько же, сколько маршрут; restorationId записывает его в данные восстановления состояния платформы, поэтому оно переживает убийство процесса.
**Как работает PageStorage**: ScrollPosition записывает смещение в бакет каждый раз, когда скролл заканчивается, и читает его обратно при создании, по ключу из PageStorageKey над ней
Уникальные ключи: один и тот же ключ на двух вкладках восстановит обеим одно и то же число
Бакет умирает вместе с маршрутом: закрытие экрана, hot restart или смерть процесса его теряют
**restorationId ставится на scroll view**: ListView(restorationId: 'inbox'), и без MaterialApp(restorationScopeId: ...) он ничего не делает
Срабатывает, только когда приложение перезапускает система: убийство в фоне на Android, перезапуск на iOS; если пользователь сам смахнул приложение, ничего не восстановится. Проверяйте через «Don't keep activities» в настройках разработчика Android
Ловушка тайминга: если строки грузятся асинхронно, восстановленное смещение попадает на пустой список и обрезается до 0; восстанавливайте и число загруженных страниц или резервируйте высоту плейсхолдерами
SliverList не может знать высоту строки, которую не строил, поэтому maxScrollExtent остаётся оценкой, экстраполированной по уже разложенным строкам, и она меняется всякий раз, когда меняется эта выборка.
Оценка примерно такая: средняя высота построенных строк, умноженная на число оставшихся, плюс то, что уже разложено. Если первые строки необычно высокие или низкие, промах составляет тысячи пикселей. При скролле оценка плывёт, поэтому ползунок меняет размер прямо под пальцем, а jumpTo(maxScrollExtent) приземляется на текущую оценку; затем строятся новые строки, и конец отодвигается.
**itemExtent или SliverFixedExtentList**: одна высота для всех строк: смещения считаются арифметикой, и ни одну строку не нужно измерять; prototypeItem берёт высоту с одной строки-образца
**itemExtentBuilder / SliverVariedExtentList**: высота для каждого индекса, вычисленная без построения виджета, поэтому итог точный, скроллбар честный, а jumpTo попадает
Для этого нужны высоты, которые можно вычислить из данных: число строк текста, соотношение сторон картинки; если нельзя, пакеты вроде super_sliver_list оценивают лучше и поправляются по ходу
**Не решение: shrinkWrap: true**: точный extent ценой построения всех десяти тысяч строк в одном кадре
Положите обе половины в один CustomScrollView с ключом center: slivers, перечисленные до центрального, раскладываются на отрицательных смещениях, поэтому добавление истории сдвигает minScrollExtent, а не контент под пальцем пользователя.
У viewport один центральный sliver, и его передний край совпадает со смещением скролла ноль. Slivers до него растут назад, а slivers после него растут вперёд. Поэтому список истории идёт первым, с индексом 0 ближе всего к якорю и большими индексами дальше в прошлое: новая страница расширяет отрицательную сторону, и всё на экране сохраняет своё смещение.
**ListView(reverse: true) достаточно, когда растёт только история**: center нужен, когда растут оба конца
Смещение 0 больше не верх: minScrollExtent отрицательный, а jumpTo(0) означает границу между историей и новыми сообщениями
**«Внизу» означает pixels >= maxScrollExtent - epsilon: автоскролл на новое сообщение делайте только тогда, иначе выдернете читателя из истории
center должен совпадать с key одного из slivers в списке**: ключ ставится на сам sliver; ключ, который ни с чем не совпал, падает на assert
Не одним общим ScrollController: он может держать несколько позиций, но перетаскивание двигает только свою, а чтение offset падает на assert, как только подключены две. Либо зеркальте два контроллера, либо сделайте один скролл.
Два контроллера, зеркалящие друг друга: слушатель на каждом вызывает jumpTo на другом за флагом защиты от повторного входа; используйте jumpTo, а не animateTo, и ограничивайте значение по maxScrollExtent ведомого
Чего стоит зеркалирование: каждый jumpTo отменяет то, что делал ведомый, и рассылает его собственные уведомления скролла, поэтому его скроллбар и слушатели срабатывают на каждом кадре, а оверскролл одной половины не повторяется, потому что ведомый упирается в край
Одно общее смещение: LinkedScrollControllerGroup из linked_scroll_controller выдаёт контроллеры, чьи позиции двигаются вместе от одного смещения, без эха и без флага повторного входа
Один viewport: TableView из two_dimensional_scrollables скроллит обе оси в одном viewport с pinnedColumnCount, так что выравнивание обеспечивает раскладка, а не синхронизация
Половинкам, которые никогда не должны расходиться, нужна одна позиция или один viewport; списки, которые просто следуют друг за другом, можно спокойно зеркалить.
performLayout обязан присвоить geometry; viewport начинает следующий sliver после вашего layoutExtent, а не после того, что вы нарисовали. Если нужно лишь прочитать SliverConstraints и выбрать готовые slivers, хватит SliverLayoutBuilder.
Три extent
**scrollExtent**: сколько скролла потребляет sliver; сумма по всем slivers и есть maxScrollExtent
**paintExtent**: что нарисовано в этом кадре; не может превышать constraints.remainingPaintExtent или maxPaintExtent, иначе сработает assert
**layoutExtent**: где начинается следующий sliver; по умолчанию равен paintExtent и не может быть больше. Схлопнувшийся закреплённый заголовок сообщает здесь 0, продолжая рисоваться, поэтому контент уезжает под него
Хелперы и ловушки
**calculatePaintOffset и calculateCacheOffset**: сами ограничивают значения по scrollOffset и оставшемуся extent; ручные min/max как раз и служат источником assert-ов
Дочерние элементы: если вы сами позиционируете дочерний элемент, вы также должны реализовать childMainAxisPosition и hitTestChildren, иначе тапы уходят в никуда; RenderSliverSingleBoxAdapter покрывает случай с одним дочерним боксом
**scrollOffsetCorrection: вернув его, вы заставляете viewport сдвинуть смещение и разложить всё заново; делайте это только по условию, иначе раскладка зациклится
Когда писать его не нужно
SliverLayoutBuilder**: когда нужно только прочитать SliverConstraints и выбрать из существующих slivers
Заголовки: PinnedHeaderSliver, SliverResizingHeader или SliverPersistentHeader покрывают закрепление и сжатие; RenderSliver нужен для действительно новой раскладки