Custom Scroll Effects with ScrollController
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.
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.0the header is at full height with full opacity and large text. - At
progress == 1.0the 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.
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
Opacitybacked byTransformover animating aContainerbackground, because the former uses the compositing layer and avoids layout work. - If the rebuild budget becomes tight, consider
AnimationController+AnimatedBuilderinstead ofsetState— it only rebuilds theAnimatedBuildersubtree, not the whole widget.
_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
ScrollControllerininitState/dispose. - Add a listener that stores
_scroll.offsetviasetState. - Clamp and normalize the offset into a
[0, 1]progressvalue. - Use
lerpDoubleto mapprogressto opacity, scale, translate, or any other property. - Compose effects with
Opacity,Transform.scale,Transform.translate, andPositionedinside aStack.
These primitives cover collapsing headers, parallax backgrounds, and sticky-reveal toolbars — all with plain StatefulWidget code you can understand and debug at a glance.