The post has been translated automatically. Original language: Russian
In Flutter, it's very easy to assemble a screen that seems to work, but is held on crutches inside: SingleChildScrollView, inside Column, inside ListView with shrinkWrap: true, then another horizontal list, then NeverScrollableScrollPhysics, then a pair of sizedboxes to finish the height.
On a small screen, this often goes unnoticed. On a real product — with long lists, banners, sticky header, pull-to-refresh, collapsible app bar, and loading states — this approach quickly turns into a fragile construct. It is poorly readable, scrolls strangely, is more difficult to optimize, and periodically breaks down with the slightest design change.
Sliver's are just about not building a complex scrolling screen out of embedded crutches. But they have a bad reputation: many perceive slivers as complex magic for a beautiful AppBar. In practice, this is not magic, but a normal Flutter tool for screens where several different parts should behave like one scrollable layout.
The main idea
Slivers are not needed for beautiful effects. They are needed when the screen should have one common scroll, but the content inside consists of different types of blocks.:
- a regular header;
- sticky or floating app bar;
- list;
- grid;
- empty state;
- recommendation block;
- footer;
- the section that should take up the remaining space;
- pull-to-refresh;
- pinned header between sections.
If you try to assemble such a screen through Column + ListView + shrinkWrap, you often struggle not with Flutter, but with the wrong layout model.
What is the problem with the usual approach?
Here is a typical screen:
SingleChildScrollView(
child: Column(
children: [
Header(),
PromoBanner(),
ListView.builder(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
itemCount: items.length,
itemBuilder: (_, index) => ItemCard(items[index]),
),
Footer(),
],
),
)This code often appears out of a very understandable desire: I have several blocks, and everything should scroll together.
But the problem is that we've mixed two different models here.:
- Column wants to arrange all its children like regular box widgets;
- The ListView itself is a scroll view;
- SingleChildScrollView is also a scroll view;
- shrinkWrap: true forces the list to adjust the size to the content.
This may be acceptable for a small number of elements. But as soon as the screen gets longer and more complex, we start paying for the failed composition.
shrinkWrap is especially often used as a Band—aid: Flutter swears at constraints — we set shrinkWrap to true; the list conflicts with scrolling — we disable physics for it; the screen is almost working - so we leave it.
But this is not the architecture of the screen. This is a symptom bypass.
How Sliver's are changing the model
The Sliver model offers a different view: we will not nest the scroll view inside the scroll view, but assemble one scroll view from several sliver blocks.
The basic design looks like this:
CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Header(),
),
SliverToBoxAdapter(
child: PromoBanner(),
),
SliverList.builder(
itemCount: items.length,
itemBuilder: (_, index) => ItemCard(items[index]),
),
SliverToBoxAdapter(
child: Footer(),
),
],
)At first it seems that there is more code. But the meaning has become more honest: we have one scrollable container, and inside there is a sequence of blocks, each of which participates in the overall scrolling.
And this is the main difference. We no longer force multiple scroll views to negotiate with each other through shrinkWrap, disabling physics, and manual height restrictions.
SliverToBoxAdapter is a bridge, not the main building material
The SliverToBoxAdapter is needed in order to insert a regular widget into the slivercontext.
For example:
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('Popular'),
),
)This is normal for a single block: header, banner, separator, empty state, CTA.
But it's a bad idea to wrap each element of a large list in a separate SliverToBoxAdapter.
Badly:
CustomScrollView(
slivers: [
for (final item in items)
SliverToBoxAdapter(
child: ItemCard(item),
),
],
)Better:
CustomScrollView(
slivers: [
SliverList.builder(
itemCount: items.length,
itemBuilder: (_, index) => ItemCard(items[index]),
),
],
)The difference is fundamental: the list must remain a list. If there are a lot of elements, it is better to use sliver, which is designed for lazy creation of elements, rather than manually assembling dozens or hundreds of adapters.
The most useful set of Slivers
In practice, a small set is enough for most screens.
SliverAppBar
It is used when the top panel should be part of the scroll: collapse, fix, float above the content, or have a flexible space.
CustomScrollView(
slivers: [
SliverAppBar(
pinned: true,
expandedHeight: 180,
flexibleSpace: FlexibleSpaceBar(
title: Text('Folder'),
background: Image.network(
imageUrl,
fit: BoxFit.cover,
),
),
),
SliverList.builder(
itemCount: products.length,
itemBuilder: (_, index) => ProductTile(products[index]),
),
],
)This is much cleaner than trying to simulate such a header through Stack, manual offsets, and ScrollController listener.
SliverList
The main tool for the vertical list inside the CustomScrollView.
SliverList.builder(
itemCount: messages.length,
itemBuilder: (_, index) => MessageBubble(messages[index]),
)Use it where your hand used to reach for ListView.builder inside the general scroll.
SliverGrid
If you need a grid inside the same screen, you don't need to nest the GridView in the SingleChildScrollView. It is better to make the grid a sliver block.
SliverGrid.builder(
itemCount: photos.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
),
itemBuilder: (_, index) => PhotoCard(photos[index]),
)Now header, list, grid and footer live in the same scrollable layout.
SliverPadding
Instead of shoving padding inside each element, you can wrap the entire sliver.
SliverPadding(
padding: EdgeInsets.all(16),
sliver: SliverList.builder(
itemCount: items.length,
itemBuilder: (_, index) => ItemCard(items[index]),
),
)SliverFillRemaining
It is very useful for empty states and screens where the block should take up the remaining space.
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: EmptyState(),
),
)This is much better than guessing the height through MediaQuery and manually subtracting toolbar, padding and other elements.
A real example: catalog screen without crutches
Let's say you need a screen:
- large collapsing header;
- the search bar;
- horizontal block of categories;
- List of products;
- footer;
- an empty state if there are no products.
On crutches, this often turns into SingleChildScrollView + Column + ListView.builder(shrinkWrap: true).
This can be expressed directly on sliver.:
class CatalogPage extends StatelessWidget {
const CatalogPage({
super.key,
required this.products,
required this.categories,
});
final List<Product> products;
final List<Category> categories;
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
pinned: true,
expandedHeight: 180,
flexibleSpace: FlexibleSpaceBar(
title: Text('Directory'),
background: CatalogHeaderBackground(),
),
),
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: SearchField(),
),
),
SliverToBoxAdapter(
child: SizedBox(
height: 48,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(horizontal: 16),
itemCount: categories.length,
separatorBuilder: (_, __) => SizedBox(width: 8),
itemBuilder: (_, index) {
return CategoryChip(category: categories[index]);
},
),
),
),
if (products.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: EmptyCatalogState(),
),
)
else
SliverPadding(
padding: EdgeInsets.all(16),
sliver: SliverList.builder(
itemCount: products.length,
itemBuilder: (_, index) {
return ProductCard(product: products[index]);
},
),
),
SliverToBoxAdapter(
child: CatalogFooter(),
),
],
),
);
}
}There is no nested vertical ListView, no shrinkWrap, no manual height control. The screen consists of a single CustomScrollView, and each block honestly describes its behavior in the overall scroll.
When are slivers really needed?
Slivers are worth using if:
- the screen has several different sections that should scroll together.;
- you need a collapsing, pinned, or floating header;
- There is both a list and a grid inside one scroll.;
- you're using shrinkWrap: true, because otherwise the layout won't converge.;
- you are disabling physics from the nested list.;
- are you catching strange bugs with scroll position;
- you manually calculate the height of the remaining space.;
- the screen should be productive on long lists.
If the screen is simple and consists only of a list, the usual ListView.builder is absolutely normal. Slivers should not be used for the sake of architecture.
When slivers can be superfluous
It's not a good idea to turn every screen into a CustomScrollView.
If you have a regular 6-field form, a simple static settings screen, or a short scrollable content, SingleChildScrollView may be a perfectly normal solution.
Slivers are useful not because they are more correct, but because they better describe a certain class of tasks: complex scrollable layouts.
The main mistake when switching to Sliver's
The most common mistake is to use slivers as wrappers around the old approach.
For example:
CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Column(
children: [
Header(),
ListView.builder(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
itemCount: items.length,
itemBuilder: (_, index) => ItemCard(items[index]),
),
],
),
),
],
)Formally, a CustomScrollView appeared here. But nothing has changed architecturally: the old nested scroll lives inside again.
The correct transition is not to wrap the old screen in sliver, but to decompose the screen into sliver sections.
The rule of thumb
If you see such a construction:
SingleChildScrollView(
child: Column(
children: [
SomeHeader(),
ListView.builder(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
...
),
],
),
)It's almost always worth asking:
Shouldn't it be a CustomScrollView with a SliverToBoxAdapter and a SliverList?
The answer is not always yes. But this question should become automatic.
The main thing
Slivers are not an uncommon technique for complex animations. This is a normal Flutter model for screens where several different blocks must live inside a single scroll.
They help to remove typical crutches.:
- nested scroll views;
- shrinkWrap as a universal patch;
- disabling physics;
- manual height calculation;
- simulating a collapsing header via Stack;
- lists hidden inside Column.
And most importantly, slivers make you think of the screen as a sequence of scrollable sections, rather than a bunch of widgets that randomly have to scroll together.
If the screen is simple, don't complicate it. If the screen is complicated, stop assembling it from crutches. Flutter already has a normal tool for this.
Во Flutter очень легко собрать экран, который вроде работает, но внутри держится на костылях: SingleChildScrollView, внутри Column, внутри ListView с shrinkWrap: true, потом ещё один горизонтальный список, потом NeverScrollableScrollPhysics, потом пара SizedBox, чтобы добить высоту.
На маленьком экране это часто проходит незаметно. На реальном продукте — с длинными списками, баннерами, sticky header, pull-to-refresh, collapsible app bar и состояниями загрузки — такой подход быстро превращается в хрупкую конструкцию. Она плохо читается, странно скроллится, сложнее оптимизируется и периодически ломается при малейшем изменении дизайна.
Sliver’ы как раз про то, чтобы не строить сложный скроллящийся экран из вложенных костылей. Но у них плохая репутация: многие воспринимают sliver’ы как сложную магию для красивого AppBar. На практике это не магия, а нормальный инструмент Flutter для экранов, где несколько разных частей должны вести себя как один scrollable layout.
Главная мысль
Sliver’ы нужны не для красивых эффектов. Они нужны, когда у экрана должен быть один общий скролл, но контент внутри состоит из разных типов блоков:
- обычный header;
- sticky или floating app bar;
- список;
- сетка;
- пустое состояние;
- блок рекомендаций;
- футер;
- секция, которая должна занять оставшееся место;
- pull-to-refresh;
- pinned header между секциями.
Если вы пытаетесь собрать такой экран через Column + ListView + shrinkWrap, вы часто боретесь не с Flutter, а с неправильной моделью layout.
В чём проблема обычного подхода
Вот типичный экран:
SingleChildScrollView(
child: Column(
children: [
Header(),
PromoBanner(),
ListView.builder(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
itemCount: items.length,
itemBuilder: (_, index) => ItemCard(items[index]),
),
Footer(),
],
),
)Такой код часто появляется из очень понятного желания: у меня есть несколько блоков, и всё должно скроллиться вместе.
Но проблема в том, что здесь мы смешали две разные модели:
- Column хочет разложить всех своих детей как обычные box widgets;
- ListView сам является scroll view;
- SingleChildScrollView тоже является scroll view;
- shrinkWrap: true заставляет список подстраивать размер под содержимое.
На небольшом количестве элементов это может быть приемлемо. Но как только экран становится длиннее и сложнее, мы начинаем платить за неудачную композицию.
shrinkWrap особенно часто используется как пластырь: Flutter ругается на constraints — мы ставим shrinkWrap: true; список конфликтует со скроллом — мы отключаем ему physics; экран почти работает — значит, оставляем.
Но это не архитектура экрана. Это обход симптомов.
Как Sliver’ы меняют модель
Sliver-модель предлагает другой взгляд: не вложим scroll view внутрь scroll view, а соберём один scroll view из нескольких sliver-блоков.
Базовая конструкция выглядит так:
CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Header(),
),
SliverToBoxAdapter(
child: PromoBanner(),
),
SliverList.builder(
itemCount: items.length,
itemBuilder: (_, index) => ItemCard(items[index]),
),
SliverToBoxAdapter(
child: Footer(),
),
],
)Сначала кажется, что кода стало больше. Но смысл стал честнее: у нас один scrollable container, а внутри — последовательность блоков, каждый из которых участвует в общем скролле.
И это главное отличие. Мы больше не заставляем несколько scroll view договариваться друг с другом через shrinkWrap, отключение physics и ручные ограничения высоты.
SliverToBoxAdapter — мост, а не основной строительный материал
SliverToBoxAdapter нужен для того, чтобы вставить обычный виджет в sliver-контекст.
Например:
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('Популярное'),
),
)Это нормально для одиночного блока: header, banner, разделитель, пустое состояние, CTA.
Но плохая идея — оборачивать каждый элемент большого списка в отдельный SliverToBoxAdapter.
Плохо:
CustomScrollView(
slivers: [
for (final item in items)
SliverToBoxAdapter(
child: ItemCard(item),
),
],
)Лучше:
CustomScrollView(
slivers: [
SliverList.builder(
itemCount: items.length,
itemBuilder: (_, index) => ItemCard(items[index]),
),
],
)Разница принципиальная: список должен оставаться списком. Если элементов много, лучше использовать sliver, который предназначен для ленивого создания элементов, а не вручную собирать десятки или сотни адаптеров.
Самый полезный набор Sliver’ов
На практике для большинства экранов хватает небольшого набора.
SliverAppBar
Используется, когда верхняя панель должна быть частью скролла: схлопываться, закрепляться, плавать над контентом или иметь flexible space.
CustomScrollView(
slivers: [
SliverAppBar(
pinned: true,
expandedHeight: 180,
flexibleSpace: FlexibleSpaceBar(
title: Text('Каталог'),
background: Image.network(
imageUrl,
fit: BoxFit.cover,
),
),
),
SliverList.builder(
itemCount: products.length,
itemBuilder: (_, index) => ProductTile(products[index]),
),
],
)Это намного чище, чем пытаться имитировать такой header через Stack, ручные offset’ы и слушатель ScrollController.
SliverList
Основной инструмент для вертикального списка внутри CustomScrollView.
SliverList.builder(
itemCount: messages.length,
itemBuilder: (_, index) => MessageBubble(messages[index]),
)Используйте его там, где раньше рука тянулась к ListView.builder внутри общего скролла.
SliverGrid
Если внутри того же экрана нужна сетка, не надо вкладывать GridView в SingleChildScrollView. Лучше сделать сетку sliver-блоком.
SliverGrid.builder(
itemCount: photos.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
),
itemBuilder: (_, index) => PhotoCard(photos[index]),
)Теперь header, список, grid и footer живут в одном scrollable layout.
SliverPadding
Вместо того чтобы пихать padding внутрь каждого элемента, можно обернуть sliver целиком.
SliverPadding(
padding: EdgeInsets.all(16),
sliver: SliverList.builder(
itemCount: items.length,
itemBuilder: (_, index) => ItemCard(items[index]),
),
)SliverFillRemaining
Очень полезен для пустых состояний и экранов, где блок должен занять оставшееся место.
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: EmptyState(),
),
)Это намного лучше, чем гадать высоту через MediaQuery и вручную вычитать toolbar, padding и прочие элементы.
Реальный пример: экран каталога без костылей
Допустим, нужен экран:
- большой collapsing header;
- строка поиска;
- горизонтальный блок категорий;
- список товаров;
- футер;
- пустое состояние, если товаров нет.
На костылях это часто превращается в SingleChildScrollView + Column + ListView.builder(shrinkWrap: true).
На sliver’ах это можно выразить напрямую:
class CatalogPage extends StatelessWidget {
const CatalogPage({
super.key,
required this.products,
required this.categories,
});
final List<Product> products;
final List<Category> categories;
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
pinned: true,
expandedHeight: 180,
flexibleSpace: FlexibleSpaceBar(
title: Text('Каталог'),
background: CatalogHeaderBackground(),
),
),
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: SearchField(),
),
),
SliverToBoxAdapter(
child: SizedBox(
height: 48,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(horizontal: 16),
itemCount: categories.length,
separatorBuilder: (_, __) => SizedBox(width: 8),
itemBuilder: (_, index) {
return CategoryChip(category: categories[index]);
},
),
),
),
if (products.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: EmptyCatalogState(),
),
)
else
SliverPadding(
padding: EdgeInsets.all(16),
sliver: SliverList.builder(
itemCount: products.length,
itemBuilder: (_, index) {
return ProductCard(product: products[index]);
},
),
),
SliverToBoxAdapter(
child: CatalogFooter(),
),
],
),
);
}
}Здесь нет вложенного вертикального ListView, нет shrinkWrap, нет ручной борьбы с высотой. Экран состоит из одного CustomScrollView, а каждый блок честно описывает своё поведение в общем скролле.
Когда sliver’ы действительно нужны
Sliver’ы стоит использовать, если:
- у экрана несколько разных секций, которые должны скроллиться вместе;
- нужен collapsing, pinned или floating header;
- внутри одного скролла есть и список, и сетка;
- вы используете shrinkWrap: true, потому что иначе layout не сходится;
- вы отключаете physics у вложенного списка;
- вы ловите странные баги со scroll position;
- вы вручную высчитываете высоту оставшегося пространства;
- экран должен быть производительным на длинных списках.
Если экран простой и состоит только из списка — обычный ListView.builder абсолютно нормален. Sliver’ы не надо использовать ради архитектурности.
Когда sliver’ы могут быть лишними
Не стоит превращать каждый экран в CustomScrollView.
Если у вас обычная форма из 6 полей, простой статичный экран настроек или короткий scrollable content, SingleChildScrollView может быть вполне нормальным решением.
Sliver’ы полезны не потому, что они правильнее, а потому что они лучше описывают определённый класс задач: сложные scrollable layouts.
Главная ошибка при переходе на Sliver’ы
Самая частая ошибка — использовать sliver’ы как обёртки вокруг старого подхода.
Например:
CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Column(
children: [
Header(),
ListView.builder(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
itemCount: items.length,
itemBuilder: (_, index) => ItemCard(items[index]),
),
],
),
),
],
)Формально здесь появился CustomScrollView. Но архитектурно ничего не изменилось: внутри снова живёт старый nested scroll.
Правильный переход — не завернуть старый экран в sliver, а разложить экран на sliver-секции.
Практическое правило
Если вы видите такую конструкцию:
SingleChildScrollView(
child: Column(
children: [
SomeHeader(),
ListView.builder(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
...
),
],
),
)почти всегда стоит спросить:
А не должен ли это быть CustomScrollView со SliverToBoxAdapter и SliverList?
Не всегда ответ будет — да. Но этот вопрос должен стать автоматическим.
Главное
Sliver’ы — это не редкая техника для сложных анимаций. Это нормальная модель Flutter для экранов, где несколько разных блоков должны жить внутри одного скролла.
Они помогают убрать типичные костыли:
- вложенные scroll view;
- shrinkWrap как универсальный пластырь;
- отключение physics;
- ручной расчёт высот;
- имитацию collapsing header через Stack;
- списки, спрятанные внутри Column.
И самое важное: sliver’ы заставляют думать об экране как о последовательности scrollable-секций, а не как о куче виджетов, которые случайно должны прокручиваться вместе.
Если экран простой — не усложняйте. Если экран сложный — перестаньте собирать его из костылей. Во Flutter для этого уже есть нормальный инструмент.