Flutter Scrolling — ListView, GridView, and Scrollable Widgets
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
Using ListView for small, static content: For fewer than 10 items that rarely change, use
ColumninsideSingleChildScrollViewinstead.ListViewhas overhead for item reuse.Forgetting ScrollController dispose: Undisposed controllers cause memory leaks. Always call
dispose()inState.dispose().Not using itemExtent for fixed-height items: Setting
itemExtentonListView.builderimproves scroll performance by skipping layout calculations for off-screen items.Nesting scrollable widgets: Putting a
ListViewinside aListViewcauses gesture conflicts. UseCustomScrollViewwith multiple slivers instead.Calling setState during scroll: Avoid heavy
setStatecalls in scroll listeners. Use throttling or debouncing to limit rebuilds.
Practice Questions
- What is the difference between
ListViewandListView.builder? - How does CustomScrollView differ from ListView?
- How do SliverAppBar, SliverList, and SliverGrid work together?
- How would you implement infinite scroll with ScrollController?
- 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
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