Custom Widgets & Custom Painting

Custom Scroll Effects with ScrollController

16 min Lesson 10 of 12

Custom Scroll Effects with ScrollController

Flutter's ScrollController is a powerful tool that lets you listen to the exact scroll offset of a scrollable widget at any point in time. By reading that offset, you can derive animated properties — opacity, scale, translation, color — and drive visually rich scroll effects entirely with StatefulWidget and setState, without reaching for slivers or third-party packages.

The pattern is simple: attach a ScrollController to a ListView, SingleChildScrollView, or any Scrollable; add a listener that calls setState; then inside build, convert the raw offset into an interpolated value that drives your widget properties.

Note: ScrollController must be created in initState and disposed in dispose. Forgetting dispose() leaks the controller and its listeners, causing subtle bugs and memory pressure.

Setting Up a ScrollController

The minimum wiring looks like this:

class _ScrollDemoState extends State<ScrollDemo> {
  late final ScrollController _scroll;
  double _offset = 0;

  @override
  void initState() {
    super.initState();
    _scroll = ScrollController();
    // Rebuild the widget tree whenever the scroll position changes
    _scroll.addListener(() {
      setState(() {
        _offset = _scroll.offset;
      });
    });
  }

  @override
  void dispose() {
    _scroll.dispose(); // Always dispose to free resources
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      controller: _scroll, // Attach the controller
      itemCount: 40,
      itemBuilder: (context, index) => ListTile(
        title: Text('Item $index  •  offset: ${_offset.toStringAsFixed(1)}'),
      ),
    );
  }
}

Interpolating Offset into Widget Properties

Raw pixel offsets are rarely useful as-is. You need to clamp and normalize the value into a [0.0, 1.0] progress factor, then interpolate toward your target property value. Dart's lerpDouble (from dart:ui) or simple arithmetic both work well:

import 'dart:ui' show lerpDouble;

// Inside build(), after _offset is known:
const double kCollapseDistance = 200.0;

// progress goes from 0.0 (top) to 1.0 (scrolled 200 px or more)
final double progress = (_offset / kCollapseDistance).clamp(0.0, 1.0);

// Opacity: header fades from 1.0 → 0.0 as the user scrolls down
final double headerOpacity = lerpDouble(1.0, 0.0, progress)!;

// Scale: header shrinks from 1.0 → 0.85
final double headerScale = lerpDouble(1.0, 0.85, progress)!;

// Parallax: background translates upward at 40 % of scroll speed
final double parallaxOffset = _offset * 0.4;

return Stack(
  children: [
    // Background image with parallax
    Positioned(
      top: -parallaxOffset,
      left: 0, right: 0,
      child: Image.asset('assets/hero.jpg', height: 300, fit: BoxFit.cover),
    ),
    // Collapsing title overlay
    Positioned(
      top: 0, left: 0, right: 0,
      child: Opacity(
        opacity: headerOpacity,
        child: Transform.scale(
          scale: headerScale,
          child: const _HeroTitle(),
        ),
      ),
    ),
    // The actual scrollable content
    ListView.builder(
      controller: _scroll,
      itemCount: 30,
      padding: const EdgeInsets.only(top: 260),
      itemBuilder: (context, i) => ListTile(title: Text('Item $i')),
    ),
  ],
);

Collapsing Header Effect

A collapsing header transitions from a tall, prominent hero into a compact app bar as the user scrolls. The key insight is that the header height, font size, and opacity are all functions of the same progress factor:

  • At progress == 0.0 the header is at full height with full opacity and large text.
  • At progress == 1.0 the header has collapsed to the app-bar height, is nearly transparent, and the compact title has faded in.
  • Between those extremes, every property is smoothly interpolated.
Tip: Wrap the collapsing header in a SizedBox whose height is computed from lerpDouble(expandedHeight, kToolbarHeight, progress). Flutter will smoothly reflow the content below it on every setState call.

Sticky-Reveal Effect

A sticky-reveal (sometimes called a "floating action bar") means a secondary toolbar or filter row that slides in from the top only once the user has scrolled past a threshold. Use a Transform.translate with a dy that goes from -toolbarHeight to 0:

const double kRevealThreshold = 300.0;
const double kRevealHeight   = 56.0;

// 0.0 = hidden above screen, 1.0 = fully revealed
final double revealProgress =
    ((_offset - kRevealThreshold) / kRevealHeight).clamp(0.0, 1.0);

final double revealDy = lerpDouble(-kRevealHeight, 0.0, revealProgress)!;

// In the Stack:
Positioned(
  top: 0, left: 0, right: 0,
  child: Transform.translate(
    offset: Offset(0, revealDy),
    child: Material(
      elevation: 4,
      child: SizedBox(
        height: kRevealHeight,
        child: Row(
          children: const [
            Icon(Icons.filter_list),
            Text('Filter & Sort'),
          ],
        ),
      ),
    ),
  ),
),

Performance Considerations

Each scroll event triggers setState, which rebuilds the widget subtree. Keep the subtree shallow and avoid expensive computations inside build:

  • Hoist heavy child widgets to const constructors so Flutter can short-circuit their diff.
  • Prefer Opacity backed by Transform over animating a Container background, because the former uses the compositing layer and avoids layout work.
  • If the rebuild budget becomes tight, consider AnimationController + AnimatedBuilder instead of setState — it only rebuilds the AnimatedBuilder subtree, not the whole widget.
Warning: Do not read _scroll.offset before the controller is attached to a scroll view. Accessing offset before attachment throws a StateError. Guard with _scroll.hasClients when needed.

Summary

You now have a complete recipe for scroll-driven effects without slivers:

  • Create and dispose a ScrollController in initState/dispose.
  • Add a listener that stores _scroll.offset via setState.
  • Clamp and normalize the offset into a [0, 1] progress value.
  • Use lerpDouble to map progress to opacity, scale, translate, or any other property.
  • Compose effects with Opacity, Transform.scale, Transform.translate, and Positioned inside a Stack.

These primitives cover collapsing headers, parallax backgrounds, and sticky-reveal toolbars — all with plain StatefulWidget code you can understand and debug at a glance.