scroll_spy 1.0.0-dev.1
scroll_spy: ^1.0.0-dev.1 copied to clipboard
Compute focused items and a stable primary item in a scrollable viewport (feed/autoplay/analytics).
scroll_spy #
What it answers #
Every scrolling feed eventually needs to know:
- Which items are visible right now? (detach video controllers off-screen)
- Which items are inside my "attention area"? (a line or zone in the viewport)
- Which single item should be the one? (autoplay this card, highlight this TOC section, log this impression) - and how do I keep that choice from flickering while the user scrolls?
scroll_spy computes all of this for you, every scroll, with a configurable pipeline and zero manual geometry math:
scroll event
|
v
Measure each registered item's rect in viewport coordinates
|
v
Region does the item intersect the focus line/zone? -> isFocused + metrics
|
v
Policy rank focused items, pick one winner -> primary candidate
|
v
Stability hysteresis + min-duration to prevent flicker -> primaryId
|
v
Controller ValueListenables: primaryId / focusedIds / snapshot / per-item
Why this package #
- A stable primary, not just visibility. Visibility detection tells you an item is 60% on screen. scroll_spy additionally picks one winner using a policy (closest to anchor, largest visible fraction, ...) and smooths it with hysteresis and minimum-hold-time so autoplay does not stutter between two half-visible cards.
- Rich per-item metrics.
visibleFraction,distanceToAnchorPx,focusProgress(0..1 center-closeness, great for scale/parallax effects), andfocusOverlapFraction. - Built for scroll performance. During steady scrolling the engine derives each item's position with O(1) arithmetic from a cached scroll model - no render-tree walks, no matrix math, no allocations on the hot path. Snapshots are materialized lazily, only when something listens. Items under transforms or exotic custom slivers are detected automatically and measured with a general path, so correctness never depends on your layout being "standard".
- Granular rebuild control. Per-item notifiers, boolean-only "lite" items, diff-only global signals, and listener widgets for side effects that should not rebuild anything.
- A real debug overlay. Paint the focus region, item bounds, and primary/focused outlines directly over your list while you tune it.
Install #
dependencies:
scroll_spy: ^1.0.0
import 'package:scroll_spy/scroll_spy.dart';
Quick start #
The fastest path is a drop-in wrapper. ScrollSpyListView.builder mirrors
ListView.builder and wires the scope for you; you wrap each item in
ScrollSpyItem and react to its focus state:
class FeedPage extends StatefulWidget {
const FeedPage({super.key});
@override
State<FeedPage> createState() => _FeedPageState();
}
class _FeedPageState extends State<FeedPage> {
final _spy = ScrollSpyController<int>();
@override
void dispose() {
_spy.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ScrollSpyListView<int>.builder(
controller: _spy, // focus controller, NOT a ScrollController
region: ScrollSpyRegion.zone(
anchor: const ScrollSpyAnchor.fraction(0.5), // viewport center
extentPx: 200, // 200px attention band
),
policy: const ScrollSpyPolicy<int>.closestToAnchor(),
stability: const ScrollSpyStability(
hysteresisPx: 24,
minPrimaryDuration: Duration(milliseconds: 150),
),
itemCount: 60,
itemExtent: 220,
itemBuilder: (context, index) {
return ScrollSpyItem<int>(
id: index,
// `child` is your static subtree; it does NOT rebuild on focus changes.
child: FeedCardBody(index: index),
builder: (context, focus, child) {
return AnimatedScale(
scale: focus.isPrimary ? 1.0 : 0.95,
duration: const Duration(milliseconds: 200),
child: child,
);
},
);
},
);
}
}
Then react to the winner anywhere - for example, drive video playback without rebuilding a single widget:
ScrollSpyPrimaryListener<int>(
controller: _spy,
onChanged: (previousId, currentId) {
playerFor(previousId)?.pause();
playerFor(currentId)?.play();
},
child: feed,
)
That is the whole loop: region decides who is focused, policy picks the primary, stability keeps it calm, the controller tells you about it.
Bring your own scrollable #
The wrappers (ScrollSpyListView, ScrollSpyGridView,
ScrollSpyCustomScrollView, ScrollSpyPageView) are conveniences. For any
other scrollable, wrap it in a ScrollSpyScope yourself:
ScrollSpyScope<int>(
controller: _spy,
region: ScrollSpyRegion.zone(
anchor: const ScrollSpyAnchor.fraction(0.5),
extentPx: 200,
),
policy: const ScrollSpyPolicy<int>.closestToAnchor(),
child: CustomScrollView(
slivers: [
// ... slivers containing ScrollSpyItem<int> descendants ...
],
),
)
The scope listens to scroll notifications bubbling from its child; items register themselves with the nearest scope of matching type parameter.
Core concepts #
The controller #
ScrollSpyController<T> is the read side. The scope writes into it; you listen
to it. T is your item ID type (int index, String post ID, whatever is stable
and unique within the scope).
| Listenable | Type | Fires when |
|---|---|---|
controller.primaryId |
ValueListenable<T?> |
the primary winner changes |
controller.focusedIds |
ValueListenable<Set<T>> |
the focused set changes |
controller.snapshot |
ValueListenable<ScrollSpySnapshot<T>> |
any tracked state changes (most detail, most updates) |
controller.itemFocusOf(id) |
ValueListenable<ScrollSpyItemFocus<T>> |
that item's focus state or metrics change |
controller.itemIsPrimaryOf(id) |
ValueListenable<bool> |
that item gains/loses primary |
controller.itemIsFocusedOf(id) |
ValueListenable<bool> |
that item enters/leaves the focus region |
controller.itemIsVisibleOf(id) |
ValueListenable<bool> |
that item enters/leaves the viewport |
Per-item notifiers are lazy (created on first access), diff-optimized
(fire only on real changes), and auto-evicted (disposed when the item
leaves and nothing listens). Create the controller in initState (or as a
field) and dispose() it.
ScrollSpySnapshot<T> carries primaryId, focusedIds, visibleIds,
items (a Map<T, ScrollSpyItemFocus<T>> of everything measured), and
computedAt.
Per-item focus state #
ScrollSpyItemFocus<T> is what item builders receive:
| Field | Meaning |
|---|---|
isVisible |
any part of the item is inside the viewport |
isFocused |
the item intersects the focus region (many can be focused) |
isPrimary |
this item is the single stable winner |
visibleFraction |
0..1 fraction of the item currently visible |
distanceToAnchorPx |
signed px from item center to the anchor (negative = before it) |
focusProgress |
0..1 "how centered on the anchor" - ideal for scale/opacity effects |
focusOverlapFraction |
fraction of the region's band this item covers |
itemRectInViewport / visibleRectInViewport |
geometry, null unless debug/rect capture is on |
Note: isVisible is about the viewport, isFocused is about the region. An
item can be fully visible and not focused.
Regions and anchors #
The region is the "attention area" inside the viewport. It is positioned by an anchor along the scroll axis:
const ScrollSpyAnchor.fraction(0.5); // viewport center
const ScrollSpyAnchor.fraction(0.0, offsetPx: 80); // 80px from the top edge
const ScrollSpyAnchor.pixels(120); // fixed 120px from the start
Three region shapes:
// Zone (recommended default): a band centered on the anchor.
// Robust for feeds - focus does not drop when an item drifts slightly off-center.
ScrollSpyRegion.zone(
anchor: const ScrollSpyAnchor.fraction(0.5),
extentPx: 200,
)
// Line: a thin line (or thin band) at the anchor.
// Great for "reading position" UIs - exactly what is under the line is focused.
ScrollSpyRegion.line(
anchor: const ScrollSpyAnchor.fraction(0.25),
thicknessPx: 0, // 0 = infinitesimal line
)
// Custom: bring your own evaluator for asymmetric or exotic shapes.
ScrollSpyRegion.custom(
anchor: const ScrollSpyAnchor.fraction(0.3),
evaluator: (input) {
// e.g. focus only items whose start edge is past the anchor
final focused = input.itemMainAxisStart >= input.anchorOffsetPx;
return ScrollSpyRegionResult(
isFocused: focused,
focusProgress: focused ? 1.0 : 0.0,
overlapFraction: focused ? 1.0 : 0.0,
);
},
)
Primary selection policies #
When several items are focused, the policy ranks them and proposes one winner:
const ScrollSpyPolicy<int>.closestToAnchor(); // smallest |distanceToAnchorPx| (default choice for feeds)
const ScrollSpyPolicy<int>.largestVisibleFraction(); // most on-screen wins
const ScrollSpyPolicy<int>.largestFocusOverlap(); // covers most of the region band
const ScrollSpyPolicy<int>.largestFocusProgress(); // most centered on the anchor
// Or fully custom:
ScrollSpyPolicy<int>.custom(
compare: (a, b) {
// < 0 -> a wins, > 0 -> b wins, 0 -> deterministic tie-break rules apply
return b.visibleFraction.compareTo(a.visibleFraction);
},
)
Stability (anti-flicker) #
Raw per-frame ranking flickers when two items are nearly tied. Stability rules are applied after ranking:
const ScrollSpyStability(
hysteresisPx: 24, // challenger must beat the incumbent by 24px
minPrimaryDuration: Duration(milliseconds: 150), // incumbent holds at least 150ms
preferCurrentPrimary: true, // bias toward the incumbent (default)
allowPrimaryWhenNoItemFocused: true, // keep a "best available" primary when the
// region falls between items (default; key
// for gapless autoplay)
)
Defaults are all-off for hysteresis/duration (hysteresisPx: 0,
minPrimaryDuration: zero); for feeds, 10..50 px and 100..250 ms are good
starting points. ScrollSpyStability.disabled() turns off stickiness entirely.
Update policies (semantics, not CPU lifelines) #
The engine's compute pass costs on the order of tens of microseconds even with
hundreds of mounted items, so perFrame() is a safe default. Pick a different
policy for semantic reasons:
// Most responsive; computes at most once per rendered frame. (default)
const ScrollSpyUpdatePolicy.perFrame();
// State updates only after scrolling settles - right when reactions should
// fire at rest (impressions, autoplay-on-settle).
ScrollSpyUpdatePolicy.onScrollEnd(debounce: Duration(milliseconds: 80));
// Per-frame while dragging, throttled during fling, guaranteed final settle
// pass. Limits rebuild pressure for UIs listening to continuous metrics.
ScrollSpyUpdatePolicy.hybrid(
scrollEndDebounce: Duration(milliseconds: 80),
ballisticInterval: Duration(milliseconds: 50),
computePerFrameWhileDragging: true,
);
Note: create
onScrollEnd(...)andhybrid(...)withoutconst(their constructors assert onDurationvalues, which the const evaluator cannot check). They have no==, so build them once and store them in a field rather than constructing a fresh instance on everybuild.
Reading focus state #
You rarely touch the controller's listenables directly - there is a widget for each shape of consumer:
| Widget | Rebuilds/fires when | Use for |
|---|---|---|
ScrollSpyPrimaryBuilder |
primary changes | "Now playing" headers, dots indicators |
ScrollSpyPrimaryListener |
primary changes (no rebuild) | play/pause, analytics, haptics |
ScrollSpyFocusedIdsBuilder |
focused set changes | counters, group highlights |
ScrollSpySnapshotBuilder |
any tracked state changes | dashboards, debug HUDs |
ScrollSpyItemFocusBuilder |
one item's focus/metrics change | metric-driven effects outside the item |
ScrollSpyItemPrimaryBuilder / ScrollSpyItemFocusedBuilder / ScrollSpyItemVisibleBuilder |
one item's boolean toggles | cheapest possible per-item rebuilds |
ScrollSpyItemPrimaryListener / ScrollSpyItemFocusedListener / ScrollSpyItemVisibleListener |
one item's boolean toggles (no rebuild) | per-item side effects (attach/detach players, impressions) |
// Rebuild a header when the primary changes:
ScrollSpyPrimaryBuilder<int>(
controller: _spy,
builder: (context, primaryId, _) => Text('Now playing: ${primaryId ?? "-"}'),
)
// Fire an effect when item 7 enters/leaves the viewport, rebuilding nothing:
ScrollSpyItemVisibleListener<int>(
controller: _spy,
id: 7,
onChanged: (wasVisible, isVisible) {
if (isVisible) prefetch(7);
},
child: itemSubtree,
)
Performance: booleans-first items #
ScrollSpyItem's builder runs whenever the item's focus metrics change,
which during scrolling means often (the metrics drift every frame). That is
what you want for focusProgress-driven effects. When you only care about
state toggles, use ScrollSpyItemLite - a drop-in variant whose builder runs
only when isPrimary or isFocused flips:
ScrollSpyItemLite<int>(
id: index,
child: const FeedCardBody(), // static; never rebuilds with focus
builder: (context, isPrimary, isFocused, child) {
return DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: isPrimary ? Colors.green : Colors.transparent,
width: 3,
),
),
child: child,
);
},
)
Guidelines for large feeds:
- Prefer
ScrollSpyItemLiteunless an item genuinely animates from metrics. - Always pass the static subtree via
child:so it is built once. - Use listener widgets (not builders) for side effects.
- Nobody listening to
snapshotmeans no snapshot is ever materialized - the engine works booleans-first and bills you only for what you consume.
The example app's Perf lab (1000 items + frame-time HUD) exists exactly to let you verify this with DevTools.
Recipes #
Autoplay feed #
Zone region at the center, closest-to-anchor policy, moderate stability, and a
primary listener that flips players. See the Quick start above; the full
version (with focusProgress-driven scale and a now-playing bar) is the
Autoplay feed demo.
Reading position / table of contents #
A thin line near the top of the viewport; whatever section is under the line is "current":
ScrollSpyCustomScrollView<String>(
controller: _spy,
region: ScrollSpyRegion.line(
anchor: const ScrollSpyAnchor.fraction(0.22),
thicknessPx: 2,
),
policy: const ScrollSpyPolicy<String>.closestToAnchor(),
slivers: [
SliverList.builder(
itemCount: sections.length,
itemBuilder: (context, i) => ScrollSpyItemLite<String>(
id: sections[i].slug,
builder: (context, isPrimary, isFocused, child) => SectionView(
section: sections[i],
highlighted: isPrimary,
),
),
),
],
)
Full version: Reading progress demo.
Impression analytics #
Log an impression the first time an item crosses a visibility threshold, and enter/exit events as booleans toggle:
// Threshold crossings: scan the snapshot.
_spy.snapshot.addListener(() {
for (final item in _spy.snapshot.value.items.values) {
if (item.visibleFraction >= 0.6 && _impressed.add(item.id)) {
analytics.logImpression(item.id);
}
}
});
// Enter/exit events: per-item boolean listener, zero rebuilds.
ScrollSpyItemVisibleListener<int>(
controller: _spy,
id: id,
onChanged: (prev, curr) => analytics.log(curr ? 'enter' : 'exit', id),
child: itemSubtree,
)
Full version: Impression tracking demo.
Carousels and PageViews #
ScrollSpyPageView.builder mirrors PageView.builder (including
viewportFraction); the centered page becomes primary and focusProgress
drives the scale-up effect. Full version:
Carousel demo.
Pinned headers / obstructed viewport #
If pinned headers (SliverAppBar(pinned: true), pinned tabs) or overlays
(SafeArea, bottom nav, a mini player) cover part of the viewport, pass
viewportInsets so the focus region lives in the unobstructed part:
ScrollSpyCustomScrollView<int>(
controller: _spy,
region: const ScrollSpyRegion.line(anchor: ScrollSpyAnchor.pixels(0)),
policy: const ScrollSpyPolicy<int>.closestToAnchor(),
// SliverAppBar (56) + pinned TabBar (48):
viewportInsets: const EdgeInsets.only(top: 104),
slivers: [ /* ... */ ],
)
By default insets also affect visibility (items fully behind the inset are not
visible/focused). To only offset the anchor while treating the full viewport
as visible, set insetsAffectVisibility: false. For bottom overlays:
EdgeInsets.only(bottom: kBottomNavigationBarHeight + MediaQuery.paddingOf(context).bottom).
Nested scrollables #
If items contain their own scrollables (horizontal carousels inside a vertical feed), make sure only the intended scrollable drives the scope:
ScrollSpyScope<int>(
notificationDepth: 0, // 0 = immediate child scrollable (default)
// or filter precisely:
notificationPredicate: (n) => n.metrics.axis == Axis.vertical,
metricsNotificationPredicate: (n) => n.metrics.axis == Axis.vertical,
// ...
)
Programmatic jumps #
Scroll notifications cover gestures and animations, but a bare jumpTo on an
external controller can bypass them. If you drive scrolling programmatically,
hand the same ScrollController to the scope so it observes those jumps too:
ScrollSpyScope<int>(
scrollController: myScrollController,
// ...
)
The wrappers accept scrollController (or pageController) and wire this for
you.
Debug overlay #
Flip one flag to see exactly what the engine sees - the focus region (red), focused outlines (yellow), the primary outline (green), visible bounds (blue), and optional labels:
ScrollSpyScope<int>(
debug: true,
debugConfig: const ScrollSpyDebugConfig(
showFocusRegion: true,
showPrimaryOutline: true,
showFocusedOutlines: true,
showVisibleBounds: true,
showLabels: true,
),
child: /* ... */,
)
All wrappers take the same debug / debugConfig parameters. Debug
bookkeeping runs only when debug: true; a non-debug scope spends nothing
on it. includeItemRectsInFrame additionally populates
itemRectInViewport / visibleRectInViewport on focus objects (extra
allocations; keep it for debugging).
Example app #
The example is a full showcase - a gallery of seven demos, each a realistic pattern with an in-app list of the APIs it uses:
| Demo | Pattern | Highlights |
|---|---|---|
| Autoplay feed | video-feed autoplay | zone region, stability, ScrollSpyPrimaryListener, focusProgress effects |
| Playground | interactive lab | every region/policy/stability/update-policy knob, live debug overlay |
| Reading progress | docs / articles | line region, ScrollSpyCustomScrollView, TOC highlighting |
| Impression tracking | analytics | visibleFraction thresholds, visibility listeners, event log |
| Carousel | horizontal pager | ScrollSpyPageView, viewportFraction, dots indicator |
| Gallery grid | photo grid | ScrollSpyGridView, largestVisibleFraction policy |
| Perf lab | profiling | 1000 items, ScrollSpyItemLite, frame-time HUD |
cd example
flutter run
Or just open the live web demo.
API map #
Core:
ScrollSpyScope<T>- owns the engine for a scrollable subtreeScrollSpyItem<T>/ScrollSpyItemLite<T>- register items, react to focusScrollSpyController<T>- listenables for results
Models:
ScrollSpyItemFocus<T>- per-item state and metricsScrollSpySnapshot<T>- full frame:primaryId,focusedIds,visibleIds,items
Configuration:
ScrollSpyRegion.line / .zone / .custom+ScrollSpyAnchor.fraction / .pixelsScrollSpyPolicy.closestToAnchor / .largestVisibleFraction / .largestFocusOverlap / .largestFocusProgress / .customScrollSpyStabilityScrollSpyUpdatePolicy.perFrame / .onScrollEnd / .hybrid
Wrappers:
ScrollSpyListView.builder / .separatedScrollSpyGridView.builderScrollSpyCustomScrollViewScrollSpyPageView.builder
Builders and listeners:
- Scope-level:
ScrollSpyPrimaryBuilder,ScrollSpyPrimaryListener,ScrollSpyFocusedIdsBuilder,ScrollSpySnapshotBuilder - Per-item:
ScrollSpyItemFocusBuilder,ScrollSpyItemPrimaryBuilder,ScrollSpyItemFocusedBuilder,ScrollSpyItemVisibleBuilder, and the matching...Listenervariants
Debug:
ScrollSpyDebugConfig,ScrollSpyDebugOverlay
Contributing #
Contributions are welcome. Before opening a PR:
dart format .
flutter analyze
flutter test
flutter pub publish --dry-run
If you change public behavior or API surface, update the docs and changelog alongside the code.
License #
MIT. See LICENSE.