Skip to content
ExponaBLOG
FLUTTER · DAY 018 / 100

Lists and slivers: what every Flutter developer should know

Smooth scrolling for long content.

Expona Admin8 min read

Scrolling through thousands of items without lag is what separates a polished app from one that feels unresponsive. In this post on lists and slivers, I'll walk you through building smooth, performant scrollable content in Flutter—from simple lazy-loaded lists to complex layouts with sticky headers and multiple scroll zones.

ListView.builder — Builds items lazily as they appear

The most common pitfall I see is developers using ListView(children: [...]) with a fixed list of widgets. This renders everything upfront, even items far below the fold. ListView.builder solves this by building items only when they're about to appear on screen.

I use this in every production app where content is dynamic. The constructor takes an itemCount and an itemBuilder callback that receives the index and returns a widget. Flutter recycles the space, so memory usage stays constant regardless of list size.

Here's a practical example—imagine fetching user profiles from an API:

dart
class UserListScreen extends StatefulWidget {
  @override
  _UserListScreenState createState() => _UserListScreenState();
}

class _UserListScreenState extends State {
  List users = [];
  bool isLoading = false;

  @override
  void initState() {
    super.initState();
    _fetchUsers();
  }

  Future _fetchUsers() async {
    setState(() => isLoading = true);
    final response = await http.get(Uri.parse('/api/users'));
    final data = jsonDecode(response.body) as List;
    setState(() {
      users = data.map((json) => User.fromJson(json)).toList();
      isLoading = false;
    });
  }

  @override
  Widget build(BuildContext context) {
    if (isLoading) {
      return Center(child: CircularProgressIndicator());
    }

    return ListView.builder(
      itemCount: users.length,
      itemBuilder: (context, index) {
        final user = users[index];
        return ListTile(
          title: Text(user.name),
          subtitle: Text(user.email),
          leading: CircleAvatar(
            backgroundImage: NetworkImage(user.avatar),
          ),
        );
      },
    );
  }
}

The key insight: only the visible viewport plus a small buffer are kept in memory. When a user scrolls past an item, that widget is destroyed and replaced. This pattern scales to millions of items without crashing.

Separated lists — ListView.separated for dividers

Once your list grows, you'll want visual separation between items. ListView.separated lets you define a separatorBuilder callback alongside the item builder. This is cleaner than manually adding dividers inside itemBuilder.

I reach for this whenever UX designers ask for custom dividers—whether it's a thin line, padding, or a card-style gap. The separator is built at index positions between items, so the count is naturally itemCount - 1.

Here's a real example from a ride-hailing app where rides need visual breathing room:

dart
ListView.separated(
  itemCount: rides.length,
  itemBuilder: (context, index) {
    final ride = rides[index];
    return Padding(
      padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      child: Card(
        elevation: 2,
        child: ListTile(
          title: Text('${ride.pickupLocation} → ${ride.dropLocation}'),
          subtitle: Text(ride.scheduledTime),
          trailing: Text('\$${ride.fare}'),
        ),
      ),
    );
  },
  separatorBuilder: (context, index) {
    return SizedBox(height: 8);
  },
)

You could also make separators context-aware. For instance, style the divider differently on the last item or alternate colors:

dart
separatorBuilder: (context, index) {
  final isLast = index == rides.length - 2;
  return Divider(
    color: isLast ? Colors.grey[300] : Colors.grey[200],
    thickness: isLast ? 2 : 1,
  );
}

This pattern keeps your build logic organized and prevents the common mistake of padding or divider logic leaking into the main item builder.

Slivers — CustomScrollView with SliverAppBar and SliverList for complex scrolling

When a single list isn't enough—when you need a sticky header, multiple scroll zones, or a collapsing app bar—slivers are your answer. Slivers are scrollable components that work together within a CustomScrollView. I use them heavily in e-commerce and SaaS dashboards where the header collapses as you scroll.

SliverAppBar is the most common. It can expand and collapse, pin itself to the top, or snap into place. SliverList is a sliver version of ListView, and SliverGrid handles grids.

Here's a travel app example with a collapsing destination image and pinned filter bar:

dart
CustomScrollView(
  slivers: [
    SliverAppBar(
      expandedHeight: 200,
      pinned: true,
      flexibleSpace: FlexibleSpaceBar(
        title: Text('Destinations'),
        background: Image.network(
          'https://example.com/header.jpg',
          fit: BoxFit.cover,
        ),
      ),
    ),
    SliverPersistentHeader(
      pinned: true,
      delegate: _FilterBarDelegate(),
    ),
    SliverList(
      delegate: SliverChildBuilderDelegate(
        (context, index) {
          final destination = destinations[index];
          return Padding(
            padding: EdgeInsets.all(16),
            child: Card(
              child: Column(
                children: [
                  Image.network(destination.imageUrl),
                  ListTile(
                    title: Text(destination.name),
                    subtitle: Text('\$${destination.price}'),
                  ),
                ],
              ),
            ),
          );
        },
        childCount: destinations.length,
      ),
    ),
  ],
)

The magic is pinned: true on SliverAppBar. As the user scrolls, the header shrinks to a fixed height and sticks to the top. Without slivers, achieving this smoothly requires manual scroll listeners and expensive layout recalculations.

For more control, SliverPersistentHeader lets you pin arbitrary widgets—like filter buttons. The _FilterBarDelegate class manages the height and build logic:

dart
class _FilterBarDelegate extends SliverPersistentHeaderDelegate {
  @override
  double get maxExtent => 60;

  @override
  double get minExtent => 60;

  @override
  Widget build(context, offset, overlapsContent) {
    return Container(
      color: Colors.white,
      child: ListView(
        scrollDirection: Axis.horizontal,
        children: [
          _FilterChip('Budget'),
          _FilterChip('Rating'),
          _FilterChip('Distance'),
        ],
      ),
    );
  }

  @override
  bool shouldRebuild(_FilterBarDelegate old) => false;
}

Slivers compose seamlessly. You can mix SliverAppBar, SliverList, SliverGrid, and custom headers in one scroll view. In my trading app, I've used slivers to layer a sticky trade summary over a scrollable order history without rebuilding the entire tree on each scroll event.

Item extent — itemExtent or prototypeItem speeds up scrolling for fixed-height rows

Here's a performance trick many developers miss. If all your list items have the same height—like a chat app where messages are consistently 80 logical pixels—tell Flutter that upfront with itemExtent. This avoids layout calculations and makes scrolling buttery smooth.

I've seen apps with 10,000+ items drop from 60 fps to 30 fps just by ignoring this. Once I added itemExtent: 80, we hit 60 fps consistently.

Here's a chat screen example:

dart
ListView.builder(
  itemCount: messages.length,
  itemExtent: 80, // Each message is exactly 80 logical pixels tall
  itemBuilder: (context, index) {
    final msg = messages[index];
    return Padding(
      padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          CircleAvatar(
            backgroundImage: NetworkImage(msg.senderAvatar),
          ),
          SizedBox(width: 12),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(msg.senderName,
                    style: TextStyle(fontWeight: FontWeight.bold)),
                SizedBox(height: 4),
                Text(msg.text),
                SizedBox(height: 4),
                Text(msg.timestamp,
                    style: TextStyle(fontSize: 12, color: Colors.grey)),
              ],
            ),
          ),
        ],
      ),
    );
  },
)

If heights are truly dynamic, use prototypeItem instead. Pass a representative item widget, and Flutter measures it once to estimate the scroll extent:

dart
ListView.builder(
  itemCount: posts.length,
  prototypeItem: _PostWidget(posts.first),
  itemBuilder: (context, index) => _PostWidget(posts[index]),
)

This is safer than hardcoding heights but still gives you most of the performance gain. It's perfect for feeds where items vary slightly due to text length but follow a predictable pattern.

Common mistakes

  • Using ListView with a fixed children list for dynamic content. Always use ListView.builder or ListView.separated when data comes from an API or state. Fixed lists kill performance and memory usage at scale.
  • Rebuilding the entire list on state change. If you're calling setState(() => items = newItems) every time you update, you're needlessly rebuilding widgets. Use providers or state management libraries to granularly update only what changed.
  • Ignoring itemExtent when all items have the same height. This is free performance. Don't leave it on the table. Measure once and pass the value.
  • Overcomplicating slivers. Slivers are powerful but can feel magical if you're new. Start with SliverAppBar and SliverList, then add complexity only when needed.
  • Forgetting to handle empty states. An empty list should show a message or illustration, not a blank screen. Wrap your list view in a check for items.isEmpty.
  • Loading all data upfront instead of paginating. If your API returns 10,000 records, don't fetch them all at once. Implement pagination or infinite scroll with ScrollController to load more as the user approaches the end.

Key takeaways

  • ListView.builder is the foundation—use it whenever your list is dynamic or large. Items are built lazily and recycled, keeping memory constant.
  • ListView.separated replaces manual divider logic. The separator builder is cleaner and keeps your item builder focused.
  • Slivers power complex scrolling layouts. CustomScrollView with SliverAppBar, SliverList, and SliverPersistentHeader handles sticky headers, collapsing app bars, and multi-zone scrolling without expensive scroll listeners.
  • itemExtent and prototypeItem unlock massive performance gains. If items have fixed or predictable heights, always specify them. This saves layout calculations on every frame.
  • Combine these tools based on your use case. A simple feed might just need ListView.builder with itemExtent. A complex dashboard might layer slivers with pinned headers and dynamic content below.
  • Profile your scrolling with DevTools. If frame rates drop below 60 fps, check whether you're rebuilding unnecessarily, missing itemExtent, or keeping too much in memory.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles