DEV Community

Cover image for The Hidden Gem of Flutter Lists: Unlocking scrollable_positioned_list's Superpowers
Anurag Dubey
Anurag Dubey

Posted on

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

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
Enter fullscreen mode Exit fullscreen mode

Then import it in your Dart file:

import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
Enter fullscreen mode Exit fullscreen mode

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,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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),
  );
}
Enter fullscreen mode Exit fullscreen mode

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),
          ),
        ),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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});
}
Enter fullscreen mode Exit fullscreen mode

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)}'),
              );
            },
          ),
        ],
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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,
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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]),
  ),
)
Enter fullscreen mode Exit fullscreen mode

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);
      }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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});
}
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

The scrollable_positioned_list package is a hidden gem that every Flutter developer should have in their toolkit. Here's why:

  1. Solves Real Problems: It addresses genuine limitations of Flutter's standard ListView without introducing unnecessary complexity.

  2. Clean API: The package maintains familiarity with ListView.builder while adding powerful features.

  3. Google-Maintained: As part of the google/flutter.widgets repository, it receives regular updates and bug fixes.

  4. Production-Ready: Used in real applications like API Dash, proving its reliability.

  5. 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.