Skip to content

ScrollController

Understand how to programmatically control scrolling in Flutter.


What is it?

ScrollController is a controller that manages and controls scrolling behavior in scrollable widgets. It allows you to read the current scroll position, animate to specific positions, listen to scroll events, and control multiple scrollable widgets. ScrollController is essential for implementing features like scroll-to-top, infinite scrolling, and synchronized scrolling.


Why does it exist?

ScrollController exists to:

  • Control scroll position programmatically
  • Read current scroll offset
  • Animate scrolling to positions
  • Listen to scroll events
  • Synchronize multiple scrollable widgets
  • Implement infinite scrolling
  • Create custom scroll behaviors

Basic ScrollController

ScrollController controls scrolling programmatically.

// Basic ScrollController usage
class BasicScrollControllerExample extends StatefulWidget {
  const BasicScrollControllerExample({super.key});

  @override
  State<BasicScrollControllerExample> createState() => _BasicScrollControllerExampleState();
}

class _BasicScrollControllerExampleState extends State<BasicScrollControllerExample> {
  final ScrollController _controller = ScrollController();

  @override
  void initState() {
    super.initState();
    // Listen to scroll events
    _controller.addListener(() {
      print('Scroll position: ${_controller.position.pixels}');
    });
  }

  @override
  void dispose() {
    // Clean up controller
    _controller.dispose();
    super.dispose();
  }

  void _scrollToTop() {
    // Animate to top
    _controller.animateTo(
      0,
      duration: const Duration(milliseconds: 500),
      curve: Curves.easeInOut,
    );
  }

  void _scrollToBottom() {
    // Animate to bottom
    _controller.animateTo(
      _controller.position.maxScrollExtent,
      duration: const Duration(milliseconds: 500),
      curve: Curves.easeInOut,
    );
  }

  void _jumpToMiddle() {
    // Jump to middle instantly
    _controller.jumpTo(_controller.position.maxScrollExtent / 2);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('ScrollController'),
        actions: [
          TextButton(
            onPressed: () {
              setState(() {});
            },
            child: Text(
              'Position: ${_controller.position.pixels.toInt()}',
              style: const TextStyle(color: Colors.white),
            ),
          ),
        ],
      ),
      body: Column(
        children: [
          // Control buttons
          Container(
            padding: const EdgeInsets.all(8),
            color: Colors.grey[200],
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: _scrollToTop,
                  child: const Text('Top'),
                ),
                const SizedBox(width: 8),
                ElevatedButton(
                  onPressed: _jumpToMiddle,
                  child: const Text('Middle'),
                ),
                const SizedBox(width: 8),
                ElevatedButton(
                  onPressed: _scrollToBottom,
                  child: const Text('Bottom'),
                ),
              ],
            ),
          ),

          // Scrollable content
          Expanded(
            child: ListView.builder(
              controller: _controller,
              itemCount: 50,
              itemBuilder: (context, index) {
                return Container(
                  height: 60,
                  color: Colors.blue[100 * (index % 9 + 1)],
                  margin: const EdgeInsets.symmetric(
                    horizontal: 8,
                    vertical: 4,
                  ),
                  child: Center(
                    child: Text(
                      'Item $index',
                      style: const TextStyle(
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                  ),
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

What's happening here? - Controller tracks scroll position - animateTo: animated scroll to position - jumpTo: instant scroll to position - position.pixels: current scroll offset - position.maxScrollExtent: maximum scroll


ScrollController Properties

Key properties of ScrollController.

// ScrollController properties
class ScrollControllerProperties extends StatefulWidget {
  const ScrollControllerProperties({super.key});

  @override
  State<ScrollControllerProperties> createState() => _ScrollControllerPropertiesState();
}

class _ScrollControllerPropertiesState extends State<ScrollControllerProperties> {
  final ScrollController _controller = ScrollController();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Controller Properties'),
      ),
      body: Column(
        children: [
          // Display controller info
          Container(
            padding: const EdgeInsets.all(16),
            color: Colors.grey[200],
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text('Has Clients: ${_controller.hasClients}'),
                Text('Has Listeners: ${_controller.hasListeners}'),
                if (_controller.hasClients) ...[
                  Text('Position: ${_controller.position.pixels}'),
                  Text('Min: ${_controller.position.minScrollExtent}'),
                  Text('Max: ${_controller.position.maxScrollExtent}'),
                  Text('Viewport: ${_controller.position.viewportDimension}'),
                ],
              ],
            ),
          ),

          Expanded(
            child: ListView.builder(
              controller: _controller,
              itemCount: 30,
              itemBuilder: (context, index) {
                return Container(
                  height: 50,
                  color: Colors.blue[100 * (index % 9 + 1)],
                  margin: const EdgeInsets.all(4),
                  child: Center(child: Text('Item $index')),
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

// ScrollController properties:
// 1. hasClients - Whether attached to scrollable
// 2. hasListeners - Whether has listeners
// 3. position - Current scroll position
// 4. offset - Current scroll offset (deprecated)

What's happening here? - hasClients: controller attached? - position.pixels: current offset - position.minScrollExtent: minimum scroll - position.maxScrollExtent: maximum scroll - position.viewportDimension: visible area


Multiple ScrollControllers

Using multiple controllers for synchronized scrolling.

// Multiple controllers example
class MultipleControllersExample extends StatefulWidget {
  const MultipleControllersExample({super.key});

  @override
  State<MultipleControllersExample> createState() => _MultipleControllersExampleState();
}

class _MultipleControllersExampleState extends State<MultipleControllersExample> {
  final ScrollController _controller1 = ScrollController();
  final ScrollController _controller2 = ScrollController();

  @override
  void initState() {
    super.initState();

    // Synchronize scrolling
    _controller1.addListener(() {
      if (_controller1.position.pixels != _controller2.position.pixels) {
        _controller2.jumpTo(_controller1.position.pixels);
      }
    });
  }

  @override
  void dispose() {
    _controller1.dispose();
    _controller2.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Synchronized Scrolling'),
      ),
      body: Row(
        children: [
          // First list
          Expanded(
            child: ListView.builder(
              controller: _controller1,
              itemCount: 30,
              itemBuilder: (context, index) {
                return Container(
                  height: 50,
                  color: Colors.blue[100 * (index % 9 + 1)],
                  margin: const EdgeInsets.all(4),
                  child: Center(child: Text('List 1 - $index')),
                );
              },
            ),
          ),

          const VerticalDivider(),

          // Second list (synchronized)
          Expanded(
            child: ListView.builder(
              controller: _controller2,
              itemCount: 30,
              itemBuilder: (context, index) {
                return Container(
                  height: 50,
                  color: Colors.green[100 * (index % 9 + 1)],
                  margin: const EdgeInsets.all(4),
                  child: Center(child: Text('List 2 - $index')),
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

What's happening here? - Two lists with separate controllers - Synchronized scrolling between lists - Controller listeners track positions - jumpTo synchronizes positions


Real-World Examples

Common patterns using ScrollController.

// 1. Infinite scrolling with pagination
class InfiniteScrollExample extends StatefulWidget {
  const InfiniteScrollExample({super.key});

  @override
  State<InfiniteScrollExample> createState() => _InfiniteScrollExampleState();
}

class _InfiniteScrollExampleState extends State<InfiniteScrollExample> {
  final ScrollController _controller = ScrollController();
  List<int> _items = [];
  bool _isLoading = false;
  int _page = 0;

  @override
  void initState() {
    super.initState();
    _loadMore();
    _controller.addListener(() {
      // Load more when near bottom
      if (_controller.position.pixels >= _controller.position.maxScrollExtent - 200) {
        _loadMore();
      }
    });
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  Future<void> _loadMore() async {
    if (_isLoading) return;
    setState(() => _isLoading = true);

    // Simulate network request
    await Future.delayed(const Duration(seconds: 1));
    final start = _items.length;
    _items.addAll(List.generate(20, (i) => start + i));
    _page++;

    setState(() => _isLoading = false);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Infinite Scroll'),
      ),
      body: ListView.builder(
        controller: _controller,
        itemCount: _items.length + (_isLoading ? 1 : 0),
        itemBuilder: (context, index) {
          if (index == _items.length) {
            return const Padding(
              padding: EdgeInsets.all(16),
              child: Center(child: CircularProgressIndicator()),
            );
          }
          return Container(
            height: 60,
            color: Colors.blue[100 * (_items[index] % 9 + 1)],
            margin: const EdgeInsets.all(4),
            child: Center(
              child: Text(
                'Item ${_items[index]}',
                style: const TextStyle(fontWeight: FontWeight.bold),
              ),
            ),
          );
        },
      ),
    );
  }
}

// 2. Scroll to top button
class ScrollToTopExample extends StatefulWidget {
  const ScrollToTopExample({super.key});

  @override
  State<ScrollToTopExample> createState() => _ScrollToTopExampleState();
}

class _ScrollToTopExampleState extends State<ScrollToTopExample> {
  final ScrollController _controller = ScrollController();
  bool _showButton = false;

  @override
  void initState() {
    super.initState();
    _controller.addListener(() {
      setState(() {
        _showButton = _controller.position.pixels > 200;
      });
    });
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  void _scrollToTop() {
    _controller.animateTo(
      0,
      duration: const Duration(milliseconds: 500),
      curve: Curves.easeInOut,
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Scroll to Top'),
      ),
      body: Stack(
        children: [
          ListView.builder(
            controller: _controller,
            itemCount: 100,
            itemBuilder: (context, index) {
              return Container(
                height: 60,
                color: Colors.blue[100 * (index % 9 + 1)],
                margin: const EdgeInsets.all(4),
                child: Center(
                  child: Text(
                    'Item $index',
                    style: const TextStyle(fontWeight: FontWeight.bold),
                  ),
                ),
              );
            },
          ),
          if (_showButton)
            Positioned(
              bottom: 20,
              right: 20,
              child: FloatingActionButton(
                onPressed: _scrollToTop,
                child: const Icon(Icons.arrow_upward),
              ),
            ),
        ],
      ),
    );
  }
}

// 3. Scroll position indicator
class ScrollIndicatorExample extends StatefulWidget {
  const ScrollIndicatorExample({super.key});

  @override
  State<ScrollIndicatorExample> createState() => _ScrollIndicatorExampleState();
}

class _ScrollIndicatorExampleState extends State<ScrollIndicatorExample> {
  final ScrollController _controller = ScrollController();
  double _scrollProgress = 0;

  @override
  void initState() {
    super.initState();
    _controller.addListener(() {
      if (_controller.position.maxScrollExtent > 0) {
        setState(() {
          _scrollProgress = _controller.position.pixels / _controller.position.maxScrollExtent;
        });
      }
    });
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Scroll Progress'),
        bottom: PreferredSize(
          preferredSize: const Size.fromHeight(4),
          child: Container(
            height: 4,
            color: Colors.grey[300],
            child: Row(
              children: [
                Container(
                  width: _scrollProgress * MediaQuery.of(context).size.width,
                  height: 4,
                  color: Colors.blue,
                ),
              ],
            ),
          ),
        ),
      ),
      body: ListView.builder(
        controller: _controller,
        itemCount: 50,
        itemBuilder: (context, index) {
          return Container(
            height: 60,
            color: Colors.blue[100 * (index % 9 + 1)],
            margin: const EdgeInsets.all(4),
            child: Center(
              child: Text(
                'Item $index',
                style: const TextStyle(fontWeight: FontWeight.bold),
              ),
            ),
          );
        },
      ),
    );
  }
}

What's happening here? - Infinite scrolling with pagination - Scroll to top button with animation - Scroll progress indicator in app bar - Controller listeners for updates


Best Practices

Dispose Controller

// Good - Always dispose
@override
void dispose() {
  _controller.dispose();
  super.dispose();
}

// Bad - Memory leak
@override
void dispose() {
  super.dispose();
}

Use animateTo for Smooth Scrolling

// Good - Smooth scrolling
_controller.animateTo(
  0,
  duration: const Duration(milliseconds: 500),
  curve: Curves.easeInOut,
);

// Bad - Jumping instantly
_controller.jumpTo(0);

Check HasClients

// Good - Safe access
if (_controller.hasClients) {
  _controller.animateTo(...);
}

// Bad - Risk of error
_controller.animateTo(...);

Common Mistakes

Not Disposing Controller

Wrong:

class MyWidget extends StatefulWidget {
  @override
  State<MyWidget> createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
  final ScrollController _controller = ScrollController();
  // No dispose - memory leak!
}

Correct:

class _MyWidgetState extends State<MyWidget> {
  final ScrollController _controller = ScrollController();

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}

Accessing Position Before Client Attached

Wrong:

// Error if no client attached
void _scrollToTop() {
  _controller.animateTo(0, duration: ...);
}

Correct:

// Check hasClients
void _scrollToTop() {
  if (_controller.hasClients) {
    _controller.animateTo(0, duration: ...);
  }
}


Summary

ScrollController provides programmatic control over scrolling. Use it to read scroll position, animate to positions, listen to scroll events, and synchronize multiple scrollable widgets. Always dispose controllers to prevent memory leaks.


Next Steps


Did You Know?

  • ScrollController must be disposed
  • animateTo provides smooth scrolling
  • jumpTo provides instant scrolling
  • position.pixels gives current offset
  • position.maxScrollExtent gives max scroll
  • Controllers can be synchronized
  • Controllers enable infinite scrolling
  • Controllers can be shared between widgets