Lists / RecyclerView
ListView.builder, GridView.builder, RefreshIndicator pull to refresh, infinite scroll with ScrollController, and ListTile widget.
ListView.builder
ListView.builder lazily builds list items on demand — similar to Android's RecyclerView. Use it for any list longer than a handful of items.
class UserListPage extends StatelessWidget {
final List<Map<String, String>> users = const [
{'name': 'Alice', 'role': 'Admin'},
{'name': 'Bob', 'role': 'Editor'},
{'name': 'Carol', 'role': 'Viewer'},
];
const UserListPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('User List')),
body: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: users.length,
itemBuilder: (context, index) {
final user = users[index];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(
leading: CircleAvatar(child: Text(user['name']![0])),
title: Text(user['name']!),
subtitle: Text(user['role']!),
trailing: const Icon(Icons.chevron_right),
onTap: () => print('Tapped ${user['name']}'),
),
);
},
),
);
}
}
GridView.builder
GridView.builder renders a lazy grid. Use SliverGridDelegateWithFixedCrossAxisCount to control column count and spacing.
class PhotoGridPage extends StatelessWidget {
final List<String> imageUrls;
const PhotoGridPage({super.key, required this.imageUrls});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Photo Grid')),
body: GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, // 3 columns
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 1, // square cells
),
itemCount: imageUrls.length,
itemBuilder: (context, index) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
imageUrls[index],
fit: BoxFit.cover,
loadingBuilder: (_, child, progress) => progress == null
? child
: const Center(child: CircularProgressIndicator()),
),
);
},
),
);
}
}
RefreshIndicator (Pull to Refresh)
Wrap a scrollable with RefreshIndicator and provide an onRefresh async callback. The callback must return a Future.
class RefreshableList extends StatefulWidget {
const RefreshableList({super.key});
@override
State<RefreshableList> createState() => _RefreshableListState();
}
class _RefreshableListState extends State<RefreshableList> {
List<String> _items = ['Item A', 'Item B', 'Item C'];
Future<void> _onRefresh() async {
// Simulate a network call
await Future.delayed(const Duration(seconds: 2));
setState(() {
_items = ['Fresh Item 1', 'Fresh Item 2', 'Fresh Item 3', 'Fresh Item 4'];
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Pull to Refresh')),
body: RefreshIndicator(
onRefresh: _onRefresh,
color: Colors.blue,
backgroundColor: Colors.white,
child: ListView.builder(
// Important: always use physics that allows overscroll
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _items.length,
itemBuilder: (_, index) => ListTile(
title: Text(_items[index]),
leading: const Icon(Icons.circle, size: 12),
),
),
),
);
}
}
Infinite Scroll with ScrollController
Attach a ScrollController and listen for when the user reaches near the bottom of the list to automatically load the next page.
class InfiniteScrollList extends StatefulWidget {
const InfiniteScrollList({super.key});
@override
State<InfiniteScrollList> createState() => _InfiniteScrollListState();
}
class _InfiniteScrollListState extends State<InfiniteScrollList> {
final ScrollController _scrollController = ScrollController();
final List<String> _items = List.generate(20, (i) => 'Item ${i + 1}');
bool _isLoading = false;
int _page = 1;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
void _onScroll() {
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.position.pixels;
// Trigger load when 200px from bottom
if (currentScroll >= maxScroll - 200 && !_isLoading) {
_loadMore();
}
}
Future<void> _loadMore() async {
setState(() => _isLoading = true);
await Future.delayed(const Duration(seconds: 1)); // simulate API
_page++;
final newItems = List.generate(10, (i) => 'Page $_page – Item ${i + 1}');
setState(() {
_items.addAll(newItems);
_isLoading = false;
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Infinite Scroll')),
body: ListView.builder(
controller: _scrollController,
itemCount: _items.length + (_isLoading ? 1 : 0),
itemBuilder: (_, index) {
if (index == _items.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
return ListTile(title: Text(_items[index]));
},
),
);
}
}
ListTile Widget
ListTile is a convenient Material widget for row-based lists. It supports leading/trailing icons, title, subtitle, and tap/long-press events.
// Basic ListTile
ListTile(
leading: const CircleAvatar(
backgroundImage: NetworkImage('https://i.pravatar.cc/150'),
),
title: const Text('John Doe'),
subtitle: const Text('john@example.com'),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: () => print('Tapped'),
onLongPress: () => print('Long pressed'),
)
// ListTile with checkbox (CheckboxListTile)
CheckboxListTile(
value: true,
onChanged: (val) {},
title: const Text('Enable Notifications'),
subtitle: const Text('Receive push alerts'),
secondary: const Icon(Icons.notifications),
)
// ListTile with switch (SwitchListTile)
SwitchListTile(
value: false,
onChanged: (val) {},
title: const Text('Dark Mode'),
subtitle: const Text('Switch app theme'),
)
// Divider between tiles
const Divider(height: 1, thickness: 1, indent: 72)