ListView & Scroll Physics
Introduction to ListView
The ListView widget is the most commonly used scrollable widget in Flutter. It displays its children one after another in the scroll direction (vertical by default). Understanding the different constructors and their performance implications is critical for building smooth, efficient scrolling interfaces.
ListView with Children (Default Constructor)
The default ListView constructor takes a list of children widgets. All children are built immediately, even those not visible on screen. This is suitable for small lists with a known number of items (roughly fewer than 20-30 items).
Basic ListView
ListView(
padding: const EdgeInsets.all(16.0),
children: const [
ListTile(
leading: Icon(Icons.person),
title: Text('Ahmed Al-Farsi'),
subtitle: Text('Software Engineer'),
),
Divider(),
ListTile(
leading: Icon(Icons.person),
title: Text('Sara Johnson'),
subtitle: Text('Product Designer'),
),
Divider(),
ListTile(
leading: Icon(Icons.person),
title: Text('Omar Khalid'),
subtitle: Text('Data Scientist'),
),
],
)
ListView.builder instead.
ListView.builder (Lazy Loading)
The ListView.builder constructor creates items lazily — only building widgets that are currently visible on screen (plus a small buffer). This is the recommended approach for most lists, especially those with many items or items loaded from a database or API.
ListView.builder Example
// Contact list with 1000 items - only visible items are built
class ContactListScreen extends StatelessWidget {
final List<String> contacts = List.generate(
1000,
(index) => 'Contact \${index + 1}',
);
ContactListScreen({super.key});
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: contacts.length,
itemBuilder: (context, index) {
return ListTile(
leading: CircleAvatar(
child: Text(contacts[index][0]),
),
title: Text(contacts[index]),
subtitle: Text('+966 5\${index.toString().padLeft(8, '0')}'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
// Handle tap
},
);
},
);
}
}
ListView.separated
The ListView.separated constructor is similar to ListView.builder but adds a separator widget between each item. This is perfect for lists that need dividers, spacing, or alternating backgrounds.
ListView.separated Example
ListView.separated(
itemCount: messages.length,
separatorBuilder: (context, index) => const Divider(
height: 1.0,
indent: 72.0, // Align with text after avatar
),
itemBuilder: (context, index) {
final message = messages[index];
return ListTile(
leading: CircleAvatar(
backgroundImage: NetworkImage(message.avatarUrl),
),
title: Text(message.sender),
subtitle: Text(
message.text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Text(
message.timeAgo,
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
);
},
)
ListView.custom
The ListView.custom constructor provides maximum control by accepting a SliverChildDelegate. You can use SliverChildBuilderDelegate for lazy building or SliverChildListDelegate for static lists with advanced options like findChildIndexCallback for efficient reordering.
ListView.custom Example
ListView.custom(
childrenDelegate: SliverChildBuilderDelegate(
(context, index) {
return Card(
key: ValueKey(items[index].id),
child: ListTile(
title: Text(items[index].name),
),
);
},
childCount: items.length,
findChildIndexCallback: (Key key) {
// Helps Flutter efficiently find widgets during reordering
final valueKey = key as ValueKey<int>;
final index = items.indexWhere((item) => item.id == valueKey.value);
return index != -1 ? index : null;
},
),
)
ScrollController
A ScrollController lets you programmatically control and monitor the scroll position. You can scroll to specific positions, listen for scroll events, and determine the current scroll offset.
ScrollController Usage
class ScrollableListScreen extends StatefulWidget {
const ScrollableListScreen({super.key});
@override
State<ScrollableListScreen> createState() => _ScrollableListScreenState();
}
class _ScrollableListScreenState extends State<ScrollableListScreen> {
final ScrollController _scrollController = ScrollController();
bool _showScrollToTop = false;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
void _onScroll() {
final showButton = _scrollController.offset > 200;
if (showButton != _showScrollToTop) {
setState(() => _showScrollToTop = showButton);
}
}
void _scrollToTop() {
_scrollController.animateTo(
0.0,
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: ListView.builder(
controller: _scrollController,
itemCount: 100,
itemBuilder: (context, index) => ListTile(
title: Text('Item \${index + 1}'),
),
),
floatingActionButton: _showScrollToTop
? FloatingActionButton(
onPressed: _scrollToTop,
child: const Icon(Icons.arrow_upward),
)
: null,
);
}
}
dispose() method to prevent memory leaks. Also remove listeners if you added them manually.
Scroll Physics
Flutter provides different ScrollPhysics implementations that control how a scrollable widget responds to user input — how it bounces, clamps, or behaves when reaching the edge.
Scroll Physics Types
// BouncingScrollPhysics - iOS-style bounce at edges
ListView.builder(
physics: const BouncingScrollPhysics(),
itemCount: 50,
itemBuilder: (context, index) => ListTile(title: Text('Item \$index')),
)
// ClampingScrollPhysics - Android-style glow at edges (default on Android)
ListView.builder(
physics: const ClampingScrollPhysics(),
itemCount: 50,
itemBuilder: (context, index) => ListTile(title: Text('Item \$index')),
)
// NeverScrollableScrollPhysics - Disables scrolling entirely
// Useful for nested ListViews where parent handles scrolling
ListView.builder(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: 5,
itemBuilder: (context, index) => ListTile(title: Text('Item \$index')),
)
// AlwaysScrollableScrollPhysics - Allows scrolling even if content fits
ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [Text('Short content that still scrolls')],
)
BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()) to get the iOS bounce effect while ensuring the list is always scrollable — this is useful for pull-to-refresh functionality.
Scroll Direction and Horizontal Lists
By default, ListView scrolls vertically. Set scrollDirection: Axis.horizontal to create horizontal scrolling lists, commonly used for carousels, category selectors, and image galleries.
Horizontal Carousel
// Horizontal image carousel
SizedBox(
height: 200.0, // Must constrain height for horizontal ListView
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: imageUrls.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(12.0),
child: Image.network(
imageUrls[index],
width: 300.0,
fit: BoxFit.cover,
),
),
);
},
),
)
shrinkWrap and itemExtent
Two important properties that affect ListView performance and behavior:
shrinkWrap and itemExtent
// shrinkWrap: true - ListView takes only the space it needs
// WARNING: This disables lazy loading! Use sparingly.
Column(
children: [
const Text('Header'),
ListView.builder(
shrinkWrap: true, // Wraps to content height
physics: const NeverScrollableScrollPhysics(), // Disable its own scrolling
itemCount: 5,
itemBuilder: (context, index) => ListTile(
title: Text('Item \$index'),
),
),
const Text('Footer'),
],
)
// itemExtent: Forces each item to exact height (improves performance)
ListView.builder(
itemExtent: 72.0, // Each item is exactly 72 pixels tall
itemCount: 1000,
itemBuilder: (context, index) => ListTile(
leading: CircleAvatar(child: Text('\${index + 1}')),
title: Text('Fixed height item'),
),
)
itemExtent significantly improves scrolling performance because Flutter does not need to measure each child — it already knows the exact layout. Use it whenever all items have the same height.
Practical Example: Chat Messages
Chat Message List
class ChatScreen extends StatefulWidget {
const ChatScreen({super.key});
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
final ScrollController _scrollController = ScrollController();
final List<ChatMessage> _messages = [];
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(
child: ListView.builder(
controller: _scrollController,
reverse: true, // Start from bottom like chat apps
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.all(16.0),
itemCount: _messages.length,
itemBuilder: (context, index) {
final message = _messages[index];
return Align(
alignment: message.isMine
? Alignment.centerRight
: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 8.0),
padding: const EdgeInsets.all(12.0),
decoration: BoxDecoration(
color: message.isMine
? Colors.blue.shade100
: Colors.grey.shade200,
borderRadius: BorderRadius.circular(16.0),
),
child: Text(message.text),
),
);
},
),
),
// Message input area would go here
],
);
}
}
reverse: true on a ListView makes it start from the bottom, which is the natural behavior for chat applications. New messages appear at the bottom and the user scrolls up to see older messages.