Skip to content
FlutterLearn

Beginner · Lesson 7 · 12 min read

Lists, Grids and Scrolling

Render long lists efficiently with ListView.builder, lay out grids, add pull-to-refresh and swipe-to-delete, and meet slivers.

Updated August 1, 2026

What you will learn

  • Choose the right ListView constructor for the job
  • Build grids that adapt to screen width
  • Add pull-to-refresh, swipe-to-delete and reordering
  • Understand what slivers are and when you need them

Most app screens are a list of something. Flutter's scrolling widgets are excellent, but choosing the wrong one is the single most common cause of a beginner app feeling slow — so this lesson is as much about which widget as it is about how to use it.

Four ways to build a list

ConstructorBuildsUse when
ListView(children: [...])Everything, immediatelyA short, fixed list — a settings screen
ListView.builderOnly visible items, lazilyLong or unknown-length lists — the default choice
ListView.separatedLazily, with dividers betweenLists that need separators
ListView.customWhatever your delegate saysRare — custom child management
Dart
// Short and fixed: fine to build everything up front
ListView(
  padding: const EdgeInsets.all(16),
  children: const [
    ListTile(leading: Icon(Icons.person), title: Text('Account')),
    ListTile(leading: Icon(Icons.lock), title: Text('Privacy')),
    ListTile(leading: Icon(Icons.info), title: Text('About')),
  ],
)

// Long: only builds what is on screen (plus a small buffer)
ListView.builder(
  itemCount: articles.length,
  itemBuilder: (context, index) {
    final article = articles[index];
    return ListTile(
      title: Text(article.title),
      subtitle: Text('${article.minutes} min read'),
      onTap: () => _open(article),
    );
  },
)

// With dividers — note separatorBuilder gets its own index
ListView.separated(
  itemCount: articles.length,
  separatorBuilder: (context, index) => const Divider(height: 1),
  itemBuilder: (context, index) => ArticleTile(article: articles[index]),
)

Grids

Dart
// Fixed number of columns — simple, but wrong on tablets
GridView.builder(
  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 2,
    mainAxisSpacing: 12,
    crossAxisSpacing: 12,
    childAspectRatio: 3 / 4,
  ),
  itemCount: products.length,
  itemBuilder: (context, i) => ProductCard(product: products[i]),
)

// Better: fix the maximum tile width and let the column count follow
GridView.builder(
  padding: const EdgeInsets.all(16),
  gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
    maxCrossAxisExtent: 220,
    mainAxisSpacing: 12,
    crossAxisSpacing: 12,
  ),
  itemCount: products.length,
  itemBuilder: (context, i) => ProductCard(product: products[i]),
)

childAspectRatio is width divided by height. If your grid tiles overflow vertically, that ratio is usually the culprit — not the tile's contents.

Pull to refresh

Dart
RefreshIndicator(
  onRefresh: () async {
    final fresh = await api.fetchArticles();
    if (!mounted) return;
    setState(() => _articles = fresh);
  },
  child: ListView.builder(
    // Keeps the gesture working even when the list is too short to scroll
    physics: const AlwaysScrollableScrollPhysics(),
    itemCount: _articles.length,
    itemBuilder: (context, i) => ArticleTile(article: _articles[i]),
  ),
)

Swipe to delete

Dart
Dismissible(
  // The key must be stable and unique, or the wrong row disappears
  key: ValueKey(task.id),
  direction: DismissDirection.endToStart,
  background: Container(
    alignment: Alignment.centerRight,
    padding: const EdgeInsets.only(right: 20),
    color: Theme.of(context).colorScheme.errorContainer,
    child: const Icon(Icons.delete_outline),
  ),
  confirmDismiss: (direction) async {
    return await showDialog<bool>(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('Delete task?'),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context, false),
            child: const Text('Cancel'),
          ),
          FilledButton(
            onPressed: () => Navigator.pop(context, true),
            child: const Text('Delete'),
          ),
        ],
      ),
    );
  },
  onDismissed: (direction) {
    setState(() => _tasks.remove(task));
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: const Text('Task deleted'),
        action: SnackBarAction(
          label: 'Undo',
          onPressed: () => setState(() => _tasks.insert(index, task)),
        ),
      ),
    );
  },
  child: TaskTile(task: task),
)

Reorderable lists

Dart
ReorderableListView.builder(
  itemCount: _tasks.length,
  onReorder: (oldIndex, newIndex) {
    setState(() {
      // The framework reports newIndex before the removal
      if (newIndex > oldIndex) newIndex -= 1;
      final task = _tasks.removeAt(oldIndex);
      _tasks.insert(newIndex, task);
    });
  },
  itemBuilder: (context, index) {
    final task = _tasks[index];
    return ListTile(
      key: ValueKey(task.id),
      title: Text(task.title),
      trailing: const Icon(Icons.drag_handle),
    );
  },
)

Slivers: scrolling beyond a plain list

A sliver is a piece of a scrollable area that knows how to behave as it scrolls in and out of view. You need them when one scroll view has to contain several different sections — a collapsing header, then a grid, then a list.

Dart
CustomScrollView(
  slivers: [
    // Collapses into a compact bar as you scroll
    SliverAppBar.large(
      title: const Text('Discover'),
      floating: true,
      flexibleSpace: FlexibleSpaceBar(
        background: Image.network(headerUrl, fit: BoxFit.cover),
      ),
    ),

    // A normal widget inside a sliver world
    const SliverToBoxAdapter(
      child: Padding(
        padding: EdgeInsets.all(16),
        child: Text('Popular this week'),
      ),
    ),

    SliverGrid.builder(
      gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
        maxCrossAxisExtent: 200,
      ),
      itemCount: featured.length,
      itemBuilder: (context, i) => ProductCard(product: featured[i]),
    ),

    SliverList.builder(
      itemCount: articles.length,
      itemBuilder: (context, i) => ArticleTile(article: articles[i]),
    ),
  ],
)

Scroll controllers and infinite scroll

Dart
final _controller = ScrollController();
bool _loadingMore = false;

@override
void initState() {
  super.initState();
  _controller.addListener(_onScroll);
}

@override
void dispose() {
  _controller
    ..removeListener(_onScroll)
    ..dispose();
  super.dispose();
}

void _onScroll() {
  final position = _controller.position;
  // Start loading before the user actually hits the bottom
  if (position.pixels >= position.maxScrollExtent - 400 && !_loadingMore) {
    _loadNextPage();
  }
}

Loading the next page a few hundred pixels early is what makes infinite scroll feel seamless rather than stuttering at every page boundary.

Key takeaways

  • ListView.builder is the default for any list whose length comes from data.
  • Grids should size by maximum tile extent, not a fixed column count.
  • Anything that can be removed or reordered needs a stable key from the item's id.
  • Use CustomScrollView with slivers when one scroll view holds several kinds of section.

Practice

A feed with everything

Build a screen with a collapsing SliverAppBar, a horizontal 'featured' row, and a long paginated list below it. Add pull-to-refresh, swipe-to-delete with an undo snackbar, and load the next page 400 pixels before the bottom.

Show hints
  • A horizontal list inside a vertical scroll needs a fixed height — wrap it in a SizedBox.
  • Keep the page number and loading flag in State, and guard against firing two loads at once.