Skip to content

Flutter Scrolling — ListView, GridView, and Scrollable Widgets

DodaTech Updated 2026-06-28 8 min read

In this tutorial, you will learn about Flutter Scrolling. We cover key concepts, practical examples, and best practices to help you master this topic.

Flutter scrolling widgets enable displaying content that exceeds the available viewport, with ListView for linear lists, GridView for grids, and CustomScrollView for complex scrollable layouts.

What Will You Learn

  • ListView for vertical and horizontal scrolling lists
  • GridView for grid layouts with cross-axis count
  • CustomScrollView with Sliver widgets
  • ScrollController for programmatic scrolling
  • Lazy loading with Infinite Scroll
  • Pull-to-refresh patterns
  • Scroll physics and behavior customization

Why It Matters

Most mobile apps display more content than fits on one screen. Flutter's scrolling widgets are lazily built: they create only the visible items, making them efficient for thousands of entries. Understanding the difference between ListView, ListView.<a href="/design-patterns/builder/">Builder</a>, and CustomScrollView helps you choose the right widget for performance and complexity. The ScrollController enables features like scroll-to-top buttons, infinite scroll, and animated scrolling.

Real-World Use

The DodaTech Flutter app uses ListView.builder for the course catalog with thousands of courses. Each course item is a Card widget. The builder pattern ensures only visible courses are built. A ScrollController with a listener detects when the user scrolls near the bottom and triggers loading more courses. Pull-to-refresh with RefreshIndicator allows users to reload the catalog.

Learning Path

flowchart LR
  A[Flutter Layout] --> B[Flutter Scrolling\nYou are here]
  B --> C[State Management]
  style B fill:#f90,color:#fff

ListView Basics

ListView displays a scrolling linear list of children:

import 'package:flutter/material.dart';

class ListViewExample extends StatelessWidget {
  const ListViewExample({super.key});

  final List<String> items = const [
    'Apple', 'Banana', 'Cherry', 'Date', 'Elderberry',
    'Fig', 'Grape', 'Honeydew', 'Kiwi', 'Lemon',
    'Mango', 'Nectarine', 'Orange', 'Papaya', 'Quince',
  ];

  @override
  Widget build(BuildContext context) {
    return ListView(
      padding: EdgeInsets.all(16),
      children: items.map((item) => ListTile(
        leading: Icon(Icons.circle, color: Colors.blue, size: 12),
        title: Text(item),
        trailing: Icon(Icons.chevron_right),
        onTap: () => print('Tapped $item'),
      )).toList(),
    );
  }
}

This creates all children upfront. Use this for small lists (under 50 items). For large lists, use ListView.builder which lazily creates only visible items.

ListView.builder

ListView.builder creates items on demand as the user scrolls:

class BuilderExample extends StatelessWidget {
  const BuilderExample({super.key});

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      // Number of items
      itemCount: 1000,
      // Padding around the list
      padding: EdgeInsets.all(8),
      // Called for each visible item
      itemBuilder: (context, index) {
        return ListTile(
          leading: CircleAvatar(
            backgroundColor: Colors.primaries[index % Colors.primaries.length],
            child: Text('${index + 1}'),
          ),
          title: Text('Item ${index + 1}'),
          subtitle: Text('Description for item ${index + 1}'),
          trailing: Icon(Icons.arrow_forward_ios, size: 16),
        );
      },
    );
  }
}

ListView.builder is efficient for large or infinite lists. The itemBuilder is called only when the item scrolls into view. Flutter recycles item widgets as they scroll off screen.

ListView.separated

Add separators between items:

ListView.separated(
  itemCount: 20,
  separatorBuilder: (context, index) => Divider(height: 1, color: Colors.grey.shade300),
  itemBuilder: (context, index) {
    return ListTile(
      title: Text('Message ${index + 1}'),
      subtitle: Text('Preview content of message ${index + 1}'),
    );
  },
);

ListView.separated builds items and separators lazily. The separator builder receives the index of the item before the separator.

GridView

GridView arranges items in a grid:

class GridViewExample extends StatelessWidget {
  const GridViewExample({super.key});

  @override
  Widget build(BuildContext context) {
    return GridView.count(
      // Number of columns
      crossAxisCount: 2,
      // Spacing
      mainAxisSpacing: 8,
      crossAxisSpacing: 8,
      // Padding
      padding: EdgeInsets.all(16),
      // Child aspect ratio (width / height)
      childAspectRatio: 0.8,
      // Build items
      children: List.generate(20, (index) {
        return Card(
          color: Colors.primaries[index % Colors.primaries.length].shade100,
          child: Center(
            child: Text(
              'Item ${index + 1}',
              style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
            ),
          ),
        );
      }),
    );
  }
}

Use GridView.count for a fixed number of columns. Use GridView.extent for a fixed maximum tile width. For large grids, use GridView.builder for lazy construction.

CustomScrollView with Slivers

CustomScrollView combines multiple scrollable areas with slivers:

class SilverExample extends StatelessWidget {
  const SilverExample({super.key});

  @override
  Widget build(BuildContext context) {
    return CustomScrollView(
      slivers: [
        // App bar that expands and collapses
        SliverAppBar(
          title: Text('Custom Scroll'),
          expandedHeight: 200,
          flexibleSpace: FlexibleSpaceBar(
            background: Image.network(
              'https://picsum.photos/400/200',
              fit: BoxFit.cover,
            ),
          ),
          pinned: true,
        ),
        // Grid inside scroll view
        SliverGrid(
          gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
            crossAxisCount: 3,
            mainAxisSpacing: 4,
            crossAxisSpacing: 4,
          ),
          delegate: SliverChildBuilderDelegate(
            (context, index) => Container(
              color: Colors.primaries[index % Colors.primaries.length].shade200,
              child: Center(child: Text('$index')),
            ),
            childCount: 30,
          ),
        ),
        // List items
        SliverList(
          delegate: SliverChildBuilderDelegate(
            (context, index) => ListTile(
              leading: Icon(Icons.star),
              title: Text('Sliver Item ${index + 1}'),
            ),
            childCount: 20,
          ),
        ),
        // Bottom padding
        SliverPadding(
          padding: EdgeInsets.all(16),
          sliver: SliverToBoxAdapter(
            child: Text('End of content', textAlign: TextAlign.center),
          ),
        ),
      ],
    );
  }
}

Slivers are scrollable areas that can interact with each other. SliverAppBar expands on overscroll. SliverGrid and SliverList provide lazy building. SliverToBoxAdapter wraps a regular widget as a sliver.

ScrollController

ScrollController manages scroll position and listens to scroll events:

class ScrollControllerExample extends StatefulWidget {
  const ScrollControllerExample({super.key});

  @override
  State<ScrollControllerExample> createState() => _ScrollControllerExampleState();
}

class _ScrollControllerExampleState extends State<ScrollControllerExample> {
  final _scrollController = ScrollController();
  bool _showTopButton = false;

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

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

  void _onScroll() {
    setState(() {
      _showTopButton = _scrollController.offset > 200;
    });
  }

  void _scrollToTop() {
    _scrollController.animateTo(
      0,
      duration: Duration(milliseconds: 500),
      curve: Curves.easeInOut,
    );
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        ListView.builder(
          controller: _scrollController,
          itemCount: 100,
          itemBuilder: (context, index) => ListTile(
            title: Text('Item ${index + 1}'),
          ),
        ),
        if (_showTopButton)
          Positioned(
            right: 16,
            bottom: 16,
            child: FloatingActionButton(
              onPressed: _scrollToTop,
              child: Icon(Icons.arrow_upward),
            ),
          ),
      ],
    );
  }
}

Always remove listeners and dispose the controller. Use _scrollController.offset to read the current scroll position. animateTo provides smooth animated scrolling.

Infinite Scroll with ScrollController

Detect when the user scrolls near the bottom to load more data:

class InfiniteScrollExample extends StatefulWidget {
  const InfiniteScrollExample({super.key});

  @override
  State<InfiniteScrollExample> createState() => _InfiniteScrollExampleState();
}

class _InfiniteScrollExampleState extends State<InfiniteScrollExample> {
  final _scrollController = ScrollController();
  final _items = List.generate(20, (i) => 'Item $i');
  bool _isLoading = false;

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

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  void _onScroll() {
    if (_scrollController.position.pixels >=
        _scrollController.position.maxScrollExtent - 100) {
      _loadMore();
    }
  }

  Future<void> _loadMore() async {
    if (_isLoading) return;
    setState(() => _isLoading = true);

    await Future.delayed(Duration(seconds: 1));

    setState(() {
      _items.addAll(List.generate(20, (i) => 'Item ${_items.length + i}'));
      _isLoading = false;
    });
  }

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      controller: _scrollController,
      itemCount: _items.length + (_isLoading ? 1 : 0),
      itemBuilder: (context, index) {
        if (index >= _items.length) {
          return Center(
            child: Padding(
              padding: EdgeInsets.all(16),
              child: CircularProgressIndicator(),
            ),
          );
        }
        return ListTile(title: Text(_items[index]));
      },
    );
  }
}

The listener checks if the scroll position is within 100 pixels of the bottom. The loading indicator appears as the last item while fetching more data.

Pull to Refresh

RefreshIndicator enables pull-to-refresh:

class RefreshExample extends StatefulWidget {
  const RefreshExample({super.key});

  @override
  State<RefreshExample> createState() => _RefreshExampleState();
}

class _RefreshExampleState extends State<RefreshExample> {
  List<String> _items = List.generate(10, (i) => 'Item $i');

  Future<void> _onRefresh() async {
    await Future.delayed(Duration(seconds: 1));
    setState(() {
      _items = List.generate(10, (i) => 'Refreshed Item $i');
    });
  }

  @override
  Widget build(BuildContext context) {
    return RefreshIndicator(
      onRefresh: _onRefresh,
      child: ListView.builder(
        itemCount: _items.length,
        itemBuilder: (context, index) => ListTile(title: Text(_items[index])),
      ),
    );
  }
}

RefreshIndicator wraps a scrollable widget. When the user overscrolls downward, the refresh indicator appears. Return a Future from onRefresh that completes when the data is loaded.

Scroll Physics

Customize scroll behavior with ScrollPhysics:

import 'package:flutter/physics.dart';

// Bouncy scroll physics (default on iOS)
ListView(
  physics: BouncingScrollPhysics(),
  // ...
);

// Clamped scroll physics (default on Android)
ListView(
  physics: ClampingScrollPhysics(),
  // ...
);

// Always scrollable (even when content fits)
ListView(
  physics: AlwaysScrollableScrollPhysics(),
  // ...
);

// Disable scrolling
ListView(
  physics: NeverScrollableScrollPhysics(),
  // ...
);

// Custom physics (no overscroll)
ListView(
  physics: RangeMaintainingScrollPhysics(),
  // ...
);

The default physics adapts to the platform: BouncingScrollPhysics on iOS, ClampingScrollPhysics on Android. Override with AlwaysScrollableScrollPhysics to enable pull-to-refresh even when content fits the screen.

Common Mistakes

  1. Using ListView for small, static content: For fewer than 10 items that rarely change, use Column inside SingleChildScrollView instead. ListView has overhead for item reuse.

  2. Forgetting ScrollController dispose: Undisposed controllers cause memory leaks. Always call dispose() in State.dispose().

  3. Not using itemExtent for fixed-height items: Setting itemExtent on ListView.builder improves scroll performance by skipping layout calculations for off-screen items.

  4. Nesting scrollable widgets: Putting a ListView inside a ListView causes gesture conflicts. Use CustomScrollView with multiple slivers instead.

  5. Calling setState during scroll: Avoid heavy setState calls in scroll listeners. Use throttling or debouncing to limit rebuilds.

Practice Questions

  1. What is the difference between ListView and ListView.builder?
  2. How does CustomScrollView differ from ListView?
  3. How do SliverAppBar, SliverList, and SliverGrid work together?
  4. How would you implement infinite scroll with ScrollController?
  5. Challenge: Build a photo gallery app with a grid of thumbnail images. Implement pull-to-refresh to reload images. Add a scroll-to-top button that appears after scrolling 500 pixels. Use a SliverAppBar that shows a cover image on scroll.

Mini Project

Build a contacts list app:

  • Use ListView.builder with 1000 simulated contacts
  • Each contact shows avatar, name, and phone number
  • Implement alphabetical section headers using CustomScrollView
  • Add a scroll-to-top floating action button
  • Implement pull-to-refresh
  • Add infinite scroll loading of more contacts
  • Use animated scroll physics for smooth scrolling

FAQ

When should I use ListView.builder vs ListView?

Use ListView.builder for lists longer than 50 items. It lazily builds only visible items. Use ListView for small, static lists under 50 items.

Can I use ListView horizontally?

Yes. Set scrollDirection: Axis.horizontal on ListView or GridView. Use SizedBox to constrain the height.

How do I scroll to a specific index?

Use ScrollController.jumpTo(index * itemHeight) or animateTo for animated scrolling. Alternatively, use ScrollableState.ensureVisible with a GlobalKey.

What is the difference between slivers and regular widgets?

Slivers are scrollable areas that participate in the CustomScrollView's coordinated scrolling. Regular widgets cannot be placed directly inside CustomScrollView.

How do I handle scroll direction changes?

Listen to ScrollController changes to detect direction. Compare _scrollController.offset with the previous offset to determine scroll direction.

What is Next

Now that you understand scrolling, learn about state management. Proceed to State Management in Flutter for Provider, Riverpod, and BLoC patterns. Then explore Flutter Navigation for routing between screens.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro