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.
Why scrollable_positioned_list Matters
Before diving into implementation, let's understand what makes this package special:
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
The scrollable_positioned_list Solution
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
Getting started is straightforward. Add the package to your pubspec.yaml:
dependencies:
scrollable_positioned_list: ^0.3.8
Then import it in your Dart file:
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
Hidden Gem #1: Programmatic Scrolling with scrollToIndex
The most powerful feature of scrollable_positioned_list is its ability to scroll to any item programmatically. This is functionality that standard ListView simply doesn't provide out of the box.
Basic Implementation
class ScrollableListExample extends StatefulWidget {
@override
_ScrollableListExampleState createState() => _ScrollableListExampleState();
}
class _ScrollableListExampleState extends State<ScrollableListExample> {
/// Controller for programmatic scrolling
final ItemScrollController _itemScrollController = ItemScrollController();
/// Controller for listening to position changes
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: [
// Control buttons
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'),
),
],
),
),
// The scrollable list
Expanded(
child: ScrollablePositionedList.builder(
itemCount: _items.length,
itemScrollController: _itemScrollController,
itemPositionsNotifier: _itemPositionsNotifier,
itemBuilder: (context, index) => ListTile(
title: Text(_items[index]),
subtitle: Text('Position: ${index + 1}'),
),
),
),
],
),
);
}
/// Scroll to a specific index with animation
void _scrollToIndex(int index) {
_itemScrollController.scrollTo(
index: index,
duration: const Duration(seconds: 2),
curve: Curves.easeInOutCubic,
);
}
}
Advanced: Jump vs. Animated Scroll
Sometimes you need immediate positioning without animation:
/// Jump immediately to an item (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
One of the most powerful yet underutilized features is the ItemPositionListener. It allows you to monitor exactly which items are currently visible in the viewport – perfect for implementing reading progress indicators, lazy loading, or scroll-synced animations.
Implementing a Reading Progress Indicator
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();
// Listen to position changes and update progress
_itemPositionsNotifier.itemPositions.addListener(_updateProgress);
}
@override
void dispose() {
_itemPositionsNotifier.itemPositions.removeListener(_updateProgress);
super.dispose();
}
void _updateProgress() {
// Get the current visible items
final positions = _itemPositionsNotifier.itemPositions.value;
if (positions.isNotEmpty) {
// Find the minimum and maximum visible indices
final minIndex = positions
.where((ItemPosition position) => position.itemLeadingEdge < 1)
.reduce((min, position) =>
position.itemLeadingEdge < min.itemLeadingEdge ? position : min)
.index;
final maxIndex = positions
.where((ItemPosition position) => position.itemTrailingEdge > 0)
.reduce((max, position) =>
position.itemTrailingEdge > max.itemTrailingEdge ? position : max)
.index;
// Calculate progress based on visible range
final totalItems = 100; // Your total item count
final progress = (minIndex + maxIndex) / (2 * totalItems);
setState(() {
_readingProgress = progress.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
/// Check if an item is completely visible within the viewport
bool isItemFullyVisible(ItemPosition position) {
return position.itemLeadingEdge >= 0 && position.itemTrailingEdge <= 1;
}
/// Get all currently fully visible item indices
List<int> getFullyVisibleIndices() {
return _itemPositionsNotifier.itemPositions.value
.where(isItemFullyVisible)
.map((position) => position.index)
.toList();
}
Hidden Gem #3: Variable Height Items Support
Unlike some workarounds for ListView, scrollable_positioned_list handles items of varying heights gracefully. This is crucial for real-world applications with dynamic content.
class VariableHeightListExample extends StatelessWidget {
final ItemScrollController _scrollController = ItemScrollController();
/// Generate content items with varying heights
final List<_ContentItem> _items = List.generate(50, (index) {
final lines = (index % 5) + 1; // Varying content length
return _ContentItem(
title: 'Article ${index + 1}',
content: 'This article has $lines lines of content.\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) {
final item = _items[index];
return Card(
margin: const EdgeInsets.all(8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(item.content),
],
),
),
);
},
),
);
}
}
class _ContentItem {
final String title;
final String content;
_ContentItem({required this.title, required this.content});
}
Hidden Gem #4: Scroll Offset Tracking
For advanced use cases, you can track the exact scroll offset of items, enabling precise scroll-synchronized animations and effects.
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(_updateScrollOffset);
}
@override
void dispose() {
_positionsNotifier.itemPositions.removeListener(_updateScrollOffset);
super.dispose();
}
void _updateScrollOffset() {
final positions = _positionsNotifier.itemPositions.value;
if (positions.isNotEmpty) {
setState(() {
// Track the leading edge of the first visible item
_currentScrollOffset = positions.first.itemLeadingEdge;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Offset: ${_currentScrollOffset.toStringAsFixed(2)}'),
),
body: Stack(
children: [
// Background parallax effect based on scroll position
Positioned(
top: -_currentScrollOffset * 0.5,
left: 0,
right: 0,
child: Container(
height: 200,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.blue.shade200, Colors.purple.shade200],
),
),
),
),
// The list content
ScrollablePositionedList.builder(
itemCount: 50,
itemScrollController: _scrollController,
itemPositionsNotifier: _positionsNotifier,
padding: const EdgeInsets.only(top: 150),
itemBuilder: (context, index) {
return ListTile(
title: Text('Item ${index + 1}'),
subtitle: Text('Scroll offset: ${_currentScrollOffset.toStringAsFixed(2)}'),
);
},
),
],
),
);
}
}
Hidden Gem #5: Scroll Spy Navigation
Implement a table of contents that highlights the current section – a pattern commonly seen in documentation sites and long-form content apps.
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(_updateActiveSection);
}
@override
void dispose() {
_positionsNotifier.itemPositions.removeListener(_updateActiveSection);
super.dispose();
}
void _updateActiveSection() {
final positions = _positionsNotifier.itemPositions.value;
if (positions.isNotEmpty) {
// Find the item that's most visible (closest to center)
final mostVisible = positions.reduce((a, b) {
final aDist = (a.itemLeadingEdge.abs() + a.itemTrailingEdge.abs()) / 2;
final bDist = (b.itemLeadingEdge.abs() + b.itemTrailingEdge.abs()) / 2;
return aDist < bDist ? a : b;
});
if (_activeSection != mostVisible.index) {
setState(() {
_activeSection = mostVisible.index;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Scroll Spy Navigation')),
body: Row(
children: [
// Sidebar navigation
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,
),
);
},
),
),
// Main content
Expanded(
child: ScrollablePositionedList.builder(
itemCount: _sections.length,
itemScrollController: _scrollController,
itemPositionsNotifier: _positionsNotifier,
itemBuilder: (context, index) => Container(
height: 300, // Simulated section height
padding: const EdgeInsets.all(24),
child: Text(
_sections[index],
style: Theme.of(context).textTheme.headlineMedium,
),
),
),
),
],
),
);
}
}
Hidden Gem #6: Performance Optimization with ShrinkWrapping
For lists with a known small number of items, you can optimize performance:
ScrollablePositionedList.builder(
itemCount: _items.length,
shrinkWrap: true, // Size list to its content
physics: const NeverScrollableScrollPhysics(), // Disable scrolling if in a vertical column
itemBuilder: (context, index) => ListTile(
title: Text(_items[index]),
),
)
Common Pitfalls and Solutions
Pitfall 1: Controller Not Attached
Problem: Calling scrollTo before the controller is attached to the widget.
Solution:
void _safeScrollTo(int index) {
if (_itemScrollController.isAttached) {
_itemScrollController.scrollTo(index: index);
} else {
// Schedule scroll for next frame
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_itemScrollController.isAttached) {
_itemScrollController.scrollTo(index: index);
}
});
}
}
Pitfall 2: CustomScrollView Incompatibility
Problem: ScrollablePositionedList doesn't work well inside CustomScrollView with other slivers.
Solution: Use the list as a standalone widget or consider alternatives like super_sliver_list for complex sliver scenarios.
Note: This is a known limitation discussed in GitHub Issue #32.
Pitfall 3: Alignment Confusion
Problem: Understanding the alignment parameter.
Solution: The alignment parameter specifies where the target item should be positioned after scrolling:
-
0.0= Top of viewport -
0.5= Center of viewport -
1.0= Bottom of viewport
Comparison: When to Use Which Widget
| Feature | ListView | scrollable_positioned_list | CustomScrollView |
|---|---|---|---|
| Basic scrolling | ✅ | ✅ | ✅ |
| scrollToIndex() | ❌ | ✅ | ❌ |
| Visibility detection | ❌ | ✅ | ❌ |
| Variable height items | ✅ | ✅ | ✅ |
| Custom sliver integration | ❌ | ❌ | ✅ |
| Performance | Excellent | Very Good | Good |
Use scrollable_positioned_list when:
- You need to scroll to specific indices
- You want to track visible items
- You need scroll-offset-based animations
- You're implementing reading progress or scroll spy navigation
Use standard ListView when:
- You only need basic scrolling
- Performance is critical for very large lists
- You're working inside a CustomScrollView
Real-World Example: Music Player with Queue
Let's build a practical example combining multiple features:
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) {
return 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 = index;
_isPlaying = true;
});
// Scroll to the playing track
_scrollController.scrollTo(
index: index,
alignment: 0.5, // Center the playing track
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
);
}
void _nextTrack() {
final nextIndex = (_currentTrackIndex + 1) % _playlist.length;
_playTrack(nextIndex);
}
void _previousTrack() {
final prevIndex = (_currentTrackIndex - 1) % _playlist.length;
_playTrack(prevIndex < 0 ? _playlist.length - 1 : prevIndex);
}
@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: [
// Now playing card
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,
),
],
),
),
// Queue list
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 duration) {
return '${duration.inMinutes}:${duration.inSeconds.toString().padLeft(2, '0')}';
}
}
class Song {
final String title;
final String 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 that every Flutter developer should have in their toolkit. Here's why:
Solves Real Problems: It addresses genuine limitations of Flutter's standard
ListViewwithout introducing unnecessary complexity.Clean API: The package maintains familiarity with
ListView.builderwhile adding powerful features.Google-Maintained: As part of the
google/flutter.widgetsrepository, it receives regular updates and bug fixes.Production-Ready: Used in real applications like API Dash, proving its reliability.
Feature-Rich: From
scrollToIndex()to visibility tracking and offset monitoring, it provides everything needed for advanced list interactions.
When to Reach for This Package
- Building music/playlist apps
- Implementing documentation or ebook readers
- Creating chat applications with unread message indicators
- Developing apps with table of contents navigation
- Any scenario requiring precise scroll control
Conclusion
The scrollable_positioned_list package exemplifies the philosophy that the best tools are those that solve specific problems elegantly. By providing scrollToIndex() functionality, visibility tracking, and scroll offset monitoring – all while maintaining the familiar ListView.builder API – it fills a crucial gap in Flutter's widget ecosystem.
Whether you're building a music player, a documentation app, or any interface requiring precise list navigation, this package will save you hours of custom implementation and provide a more robust solution.
Give it a try in your next project – you might wonder how you ever lived without it!
Top comments (2)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.