As Flutter developers, we've all faced the common challenge: building lists that need more control than the standard ListView provides. Whether it's scrolling to a specific index, tracking which items are visible, or implementing complex navigation patterns, Flutter's built-in widgets sometimes leave us wanting more.
Enter scrollable_positioned_list – a powerful yet often-overlooked package from Google's flutter.widgets repository that solves these problems elegantly.
In this comprehensive guide, we'll explore the hidden gems of this package and demonstrate how it can transform your Flutter applications.
The Problem with Standard ListView
Flutter's ListView is excellent for basic scrolling, but it has significant limitations:
- No built-in
scrollToIndex() – You must develop custom solutions to measure element offsets
- No visibility detection – Determining which items are currently visible requires complex workarounds
- Limited programmatic control – Jumping to specific items is non-trivial
This package addresses all these limitations with a clean, well-documented API that maintains the familiar ListView.builder pattern while adding powerful capabilities.
According to the official documentation, this widget allows scrolling to a specific item and determining what items are currently visible – exactly what advanced Flutter applications need.
Installation and Setup
Add the package to your pubspec.yaml:
dependencies:
scrollable_positioned_list: ^0.3.8
Then import it:
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
The most powerful feature is the ability to scroll to any item programmatically – functionality that standard ListView simply doesn't provide.
class ScrollableListExample extends StatefulWidget {
@override
_ScrollableListExampleState createState() => _ScrollableListExampleState();
}
class _ScrollableListExampleState extends State<ScrollableListExample> {
final ItemScrollController _itemScrollController = ItemScrollController();
final ItemPositionsNotifier _itemPositionsNotifier = ItemPositionsNotifier();
final List<String> _items = List.generate(100, (index) => 'Item ${index + 1}');
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('ScrollablePositionedList Demo')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(onPressed: () => _scrollToIndex(0), child: const Text('To Start')),
ElevatedButton(onPressed: () => _scrollToIndex(_items.length - 1), child: const Text('To End')),
ElevatedButton(onPressed: () => _scrollToIndex(49), child: const Text('To Middle')),
],
),
),
Expanded(
child: ScrollablePositionedList.builder(
itemCount: _items.length,
itemScrollController: _itemScrollController,
itemPositionsNotifier: _itemPositionsNotifier,
itemBuilder: (context, index) => ListTile(
title: Text(_items[index]),
subtitle: Text('Position: ${index + 1}'),
),
),
),
],
),
);
}
void _scrollToIndex(int index) {
_itemScrollController.scrollTo(index: index, duration: const Duration(seconds: 2), curve: Curves.easeInOutCubic);
}
}
Advanced Options:
// Jump immediately (no animation)
void _jumpToIndex(int index) => _itemScrollController.jumpTo(index: index);
// Scroll with custom alignment (0.0 = top, 0.5 = center, 1.0 = bottom)
void _scrollToCenter(int index) {
_itemScrollController.scrollTo(index: index, alignment: 0.5, duration: const Duration(milliseconds: 500));
}
Hidden Gem #2: ItemPositionListener – Tracking What's Visible
The ItemPositionListener monitors which items are visible – perfect for reading progress, lazy loading, or scroll-synced animations.
class ReadingProgressExample extends StatefulWidget {
@override
_ReadingProgressExampleState createState() => _ReadingProgressExampleState();
}
class _ReadingProgressExampleState extends State<ReadingProgressExample> {
final ItemPositionsNotifier _itemPositionsNotifier = ItemPositionsNotifier();
double _readingProgress = 0.0;
@override
void initState() {
super.initState();
_itemPositionsNotifier.itemPositions.addListener(_updateProgress);
}
@override
void dispose() {
_itemPositionsNotifier.itemPositions.removeListener(_updateProgress);
super.dispose();
}
void _updateProgress() {
final positions = _itemPositionsNotifier.itemPositions.value;
if (positions.isNotEmpty) {
final minIndex = positions.where((p) => p.itemLeadingEdge < 1).reduce((min, p) => p.itemLeadingEdge < min.itemLeadingEdge ? p : min).index;
final maxIndex = positions.where((p) => p.itemTrailingEdge > 0).reduce((max, p) => p.itemTrailingEdge > max.itemTrailingEdge ? p : max).index;
setState(() => _readingProgress = ((minIndex + maxIndex) / 200).clamp(0.0, 1.0));
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Reading Progress'), bottom: PreferredSize(preferredSize: const Size.fromHeight(4), child: LinearProgressIndicator(value: _readingProgress))),
body: ScrollablePositionedList.builder(itemCount: 100, itemPositionsNotifier: _itemPositionsNotifier, itemBuilder: (context, index) => Padding(padding: const EdgeInsets.all(16.0), child: Text('Chapter ${index + 1}', style: const TextStyle(fontSize: 18)))),
);
}
}
Detecting Fully Visible Items:
bool isItemFullyVisible(ItemPosition position) => position.itemLeadingEdge >= 0 && position.itemTrailingEdge <= 1;
List<int> getFullyVisibleIndices() => _itemPositionsNotifier.itemPositions.value.where(isItemFullyVisible).map((p) => p.index).toList();
Hidden Gem #3: Variable Height Items Support
This package handles items of varying heights gracefully – crucial for dynamic content apps.
class VariableHeightListExample extends StatelessWidget {
final ItemScrollController _scrollController = ItemScrollController();
final List<_ContentItem> _items = List.generate(50, (index) {
final lines = (index % 5) + 1;
return _ContentItem(title: 'Article ${index + 1}', content: 'This article has $lines lines.\n' * lines);
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Variable Height List'), actions: [
IconButton(icon: const Icon(Icons.arrow_downward), onPressed: () => _scrollController.scrollTo(index: _items.length - 1, duration: const Duration(seconds: 2)))
]),
body: ScrollablePositionedList.builder(
itemCount: _items.length,
itemScrollController: _scrollController,
itemBuilder: (context, index) => Card(
margin: const EdgeInsets.all(8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(_items[index].title, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8), Text(_items[index].content)
]),
),
),
),
);
}
}
class _ContentItem {
final String title, content;
_ContentItem({required this.title, required this.content});
}
class ScrollOffsetExample extends StatefulWidget {
@override
_ScrollOffsetExampleState createState() => _ScrollOffsetExampleState();
}
class _ScrollOffsetExampleState extends State<ScrollOffsetExample> {
final ItemPositionsNotifier _positionsNotifier = ItemPositionsNotifier();
final ItemScrollController _scrollController = ItemScrollController();
double _currentScrollOffset = 0.0;
@override
void initState() {
super.initState();
_positionsNotifier.itemPositions.addListener(() {
final positions = _positionsNotifier.itemPositions.value;
if (positions.isNotEmpty) setState(() => _currentScrollOffset = positions.first.itemLeadingEdge);
});
}
@override
void dispose() {
_positionsNotifier.itemPositions.removeListener(() {});
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Offset: ${_currentScrollOffset.toStringAsFixed(2)}')),
body: Stack(
children: [
Positioned(top: -_currentScrollOffset * 0.5, left: 0, right: 0, child: Container(height: 200, decoration: BoxDecoration(gradient: LinearGradient(colors: [Colors.blue.shade200, Colors.purple.shade200])))),
ScrollablePositionedList.builder(
itemCount: 50,
itemScrollController: _scrollController,
itemPositionsNotifier: _positionsNotifier,
padding: const EdgeInsets.only(top: 150),
itemBuilder: (context, index) => ListTile(title: Text('Item ${index + 1}'), subtitle: Text('Offset: ${_currentScrollOffset.toStringAsFixed(2)}')),
),
],
),
);
}
}
Implement a table of contents that highlights the current section – perfect for documentation sites.
class ScrollSpyExample extends StatefulWidget {
@override
_ScrollSpyExampleState createState() => _ScrollSpyExampleState();
}
class _ScrollSpyExampleState extends State<ScrollSpyExample> {
final ItemScrollController _scrollController = ItemScrollController();
final ItemPositionsNotifier _positionsNotifier = ItemPositionsNotifier();
final List<String> _sections = List.generate(20, (index) => 'Section ${index + 1}');
int _activeSection = 0;
@override
void initState() {
super.initState();
_positionsNotifier.itemPositions.addListener(() {
final positions = _positionsNotifier.itemPositions.value;
if (positions.isNotEmpty) {
final mostVisible = positions.reduce((a, b) => (a.itemLeadingEdge.abs() + a.itemTrailingEdge.abs()) / 2 < (b.itemLeadingEdge.abs() + b.itemTrailingEdge.abs()) / 2 ? a : b);
if (_activeSection != mostVisible.index) setState(() => _activeSection = mostVisible.index);
}
});
}
@override
void dispose() {
_positionsNotifier.itemPositions.removeListener(() {});
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Scroll Spy Navigation')),
body: Row(
children: [
Container(
width: 200,
color: Colors.grey[100],
child: ListView.builder(
itemCount: _sections.length,
itemBuilder: (context, index) {
final isActive = index == _activeSection;
return ListTile(
title: Text(_sections[index], style: TextStyle(fontWeight: isActive ? FontWeight.bold : FontWeight.normal, color: isActive ? Colors.blue : Colors.black87)),
selected: isActive,
onTap: () => _scrollController.scrollTo(index: index, duration: const Duration(milliseconds: 300), alignment: 0.0),
);
},
),
),
Expanded(
child: ScrollablePositionedList.builder(
itemCount: _sections.length,
itemScrollController: _scrollController,
itemPositionsNotifier: _positionsNotifier,
itemBuilder: (context, index) => Container(height: 300, padding: const EdgeInsets.all(24), child: Text(_sections[index], style: Theme.of(context).textTheme.headlineMedium)),
),
),
],
),
);
}
}
Common Pitfalls and Solutions
Pitfall 1: Controller Not Attached
Problem: Calling scrollTo before the controller is attached.
Solution:
void _safeScrollTo(int index) {
if (_itemScrollController.isAttached) {
_itemScrollController.scrollTo(index: index);
} else {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_itemScrollController.isAttached) _itemScrollController.scrollTo(index: index);
});
}
}
Problem: Doesn't work well inside CustomScrollView with other slivers.
Solution: Use as standalone or consider super_sliver_list for complex sliver scenarios. See GitHub Issue #32.
Pitfall 3: Alignment Confusion
Solution: The alignment parameter specifies where the target item positions:
0.0 = Top of viewport
0.5 = Center of viewport
1.0 = Bottom of viewport
| Feature | ListView | scrollable_positioned_list | CustomScrollView |
| Basic scrolling | ✅ | ✅ | ✅ |
| scrollToIndex() | ❌ | ✅ | ❌ |
| Visibility detection | ❌ | ✅ | ❌ |
| Variable height items | ✅ | ✅ | ✅ |
| Custom sliver integration | ❌ | ❌ | ✅ |
Use scrollable_positioned_list for: scroll-to-index, visibility tracking, scroll-offset animations, reading progress, scroll spy navigation.
Use standard ListView for: basic scrolling, maximum performance, CustomScrollView integration.
Real-World Example: Music Player with Queue
class MusicPlayerQueue extends StatefulWidget {
const MusicPlayerQueue({Key? key}) : super(key: key);
@override
_MusicPlayerQueueState createState() => _MusicPlayerQueueState();
}
class _MusicPlayerQueueState extends State<MusicPlayerQueue> {
final ItemScrollController _scrollController = ItemScrollController();
final ItemPositionsNotifier _positionsNotifier = ItemPositionsNotifier();
final List<Song> _playlist = List.generate(50, (index) => Song(title: 'Track ${index + 1}', artist: 'Artist ${(index % 10) + 1}', duration: Duration(minutes: 3, seconds: (index * 7) % 60)));
int _currentTrackIndex = 0;
bool _isPlaying = false;
void _playTrack(int index) {
setState(() => _currentTrackIndex = _currentTrackIndex = index);
_scrollController.scrollTo(index: index, alignment: 0.5, duration: const Duration(milliseconds: 400), curve: Curves.easeInOut);
}
void _nextTrack() => _playTrack((_currentTrackIndex + 1) % _playlist.length);
void _previousTrack() => _playTrack((_currentTrackIndex - 1 + _playlist.length) % _playlist.length);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Music Player'), actions: [
IconButton(icon: Icon(_isPlaying ? Icons.pause : Icons.play_arrow), onPressed: () => setState(() => _isPlaying = !_isPlaying))
]),
body: Column(
children: [
Container(
padding: const EdgeInsets.all(16),
color: Colors.blue.shade50,
child: Row(children: [
const Icon(Icons.music_note, size: 48),
const SizedBox(width: 16),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(_playlist[_currentTrackIndex].title, style: const TextStyle(fontWeight: FontWeight.bold)),
Text(_playlist[_currentTrackIndex].artist),
Text(_formatDuration(_playlist[_currentTrackIndex].duration))
])),
IconButton(icon: const Icon(Icons.skip_previous), onPressed: _previousTrack),
IconButton(icon: const Icon(Icons.skip_next), onPressed: _nextTrack)
]),
),
Expanded(
child: ScrollablePositionedList.builder(
itemCount: _playlist.length,
itemScrollController: _scrollController,
itemPositionsNotifier: _positionsNotifier,
itemBuilder: (context, index) {
final song = _playlist[index];
final isPlaying = index == _currentTrackIndex;
return ListTile(
leading: CircleAvatar(backgroundColor: isPlaying ? Colors.blue : Colors.grey[300], child: Icon(isPlaying ? (_isPlaying ? Icons.pause : Icons.play_arrow) : Icons.music_note, color: isPlaying ? Colors.white : Colors.black54)),
title: Text(song.title, style: TextStyle(fontWeight: isPlaying ? FontWeight.bold : FontWeight.normal, color: isPlaying ? Colors.blue : null)),
subtitle: Text(song.artist),
trailing: Text(_formatDuration(song.duration)),
onTap: () => _playTrack(index),
);
},
),
),
],
),
);
}
String _formatDuration(Duration d) => '${d.inMinutes}:${d.inSeconds.toString().padLeft(2, '0')}';
}
class Song {
final String title, artist;
final Duration duration;
Song({required this.title, required this.artist, required this.duration});
}
Key Takeaways
The scrollable_positioned_list package is a hidden gem every Flutter developer should know:
- Solves Real Problems – Addresses
ListView limitations without complexity
- Clean API – Familiar
ListView.builder pattern with powerful features
- Google-Maintained – Part of
google/flutter.widgets repository
- Production-Ready – Used in apps like API Dash
- Feature-Rich –
scrollToIndex(), visibility tracking, offset monitoring
Perfect for: playlist apps, documentation readers, chat apps, table of contents, any precise scroll control scenario.
Conclusion
The scrollable_positioned_list package fills a crucial gap in Flutter's ecosystem by providing scrollToIndex(), visibility tracking, and scroll offset monitoring – all while maintaining the familiar ListView.builder API. Try it in your next project!