The Hidden Gem of Flutter Lists: Unlocking scrollable_positioned_list's Superpowers

posted 7 min read

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

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

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';

Hidden Gem #1: Programmatic Scrolling with scrollToIndex

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});
}

Hidden Gem #4: Scroll Offset Tracking

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)}')),
          ),
        ],
      ),
    );
  }
}

Hidden Gem #5: Scroll Spy Navigation

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);
    });
  }
}

Pitfall 2: CustomScrollView Incompatibility

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

Comparison: When to Use Which Widget

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:

  1. Solves Real Problems – Addresses ListView limitations without complexity
  2. Clean API – Familiar ListView.builder pattern with powerful features
  3. Google-Maintained – Part of google/flutter.widgets repository
  4. Production-Ready – Used in apps like API Dash
  5. Feature-RichscrollToIndex(), 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!


1 Comment

1 vote

More Posts

TypeScript Complexity Has Finally Reached the Point of Total Absurdity

Karol Modelskiverified - Apr 23

The End of Data Export: Why the Cloud is a Compliance Trap

Pocket Portfolioverified - Apr 6

I’m a Senior Dev and I’ve Forgotten How to Think Without a Prompt

Karol Modelskiverified - Mar 19

The Audit Trail of Things: Using Hashgraph as a Digital Caliper for Provenance

Ken W. Algerverified - Apr 28

Everyone says DeepSeek is cheaper, but I got tired of guessing the exact math. So I built a calculat

abarth23 - Apr 27
chevron_left

Related Jobs

View all jobs →

Commenters (This Week)

2 comments
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!