scroll_spy 1.0.2
scroll_spy: ^1.0.2 copied to clipboard
Detect visible items and select one stable primary item in Flutter scrollables for video autoplay, analytics, reading position, and prefetching.
scroll_spy #
Know what is visible. Choose one stable winner.
Viewport focus detection and stable primary item selection for Flutter scrollables, built for video autoplay feeds, analytics, reading position, carousels, and prefetching.
Product guide · Real video feed demo · API docs
scroll_spy is the layer between raw Flutter widget visibility and a product
decision. It reports every visible and focused item, then applies an explicit
policy and stability rules to select one primaryId. A video engine can pause
the previous ID and play the next without two half-visible cards fighting over
audio.
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.
How it differs from visibility detection #
Choose by the decision your screen needs:
| Package | Core job | One primary winner | Built-in anti-flicker |
|---|---|---|---|
| scroll_spy | Decide which visible item should own attention | Yes | Hysteresis + minimum hold |
visibility_detector |
Report how much of an individual widget is visible | Application code | Application code |
inview_notifier_list |
Notify an indexed list child when it enters a configured in-view range | Application code | Application code |
Use visibility_detector when per-widget visibility information is the whole
job. Use inview_notifier_list when simple indexed-list threshold notification
is enough. Use scroll_spy when multiple visible items compete and exactly one
must drive playback, highlighting, reading position, or another attention-based
side effect.
This comparison describes package responsibilities, not a universal speed ranking. The comparison benchmark is reproducible so you can inspect its scenario and run it on your own hardware.
Install #
dependencies:
scroll_spy: ^1.0.2
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.
// Keeps feed focus from dropping 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 | real bundled video playback | one active player, bounded neighbor pool, lifecycle-safe pause/dispose |
| 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 open the live web demo.
The autoplay example uses bundled media, so it is repeatable without depending
on a remote video host. Its small player pool keeps the current item and nearby
items warm while disposing controllers that move outside that window. See
feed_autoplay_page.dart and
feed_video_pool.dart.
Reproducible comparison benchmark #
The benchmark app compares complete, documented integration
patterns for scroll_spy, visibility_detector, and inview_notifier_list under
the same fixed-extent feed and scripted scroll workload. It records mounted
items, normalized visibility transitions, reactive builds, callbacks, and
repeated state deliveries.
One reference run on Flutter 3.44.4 produced these deterministic counts with 201 mounted items and the same 20 normalized visibility transitions:
| Implementation | Reactive builds | Callbacks | Repeated deliveries |
|---|---|---|---|
scroll_spy |
40 | 20 | 10 |
inview_notifier_list |
4,020 | not exposed | 3,990 |
visibility_detector |
30 | 80 | 60 |
Treat the output as a local engineering measurement, not a permanent package leaderboard. Flutter version, device, build mode, list shape, and application work all affect the result. A callback and a builder invocation are not equal units of CPU work. The harness prints debug step timing only for regression checks and does not present it as device frame time or FPS. Read the methodology and run it on the hardware and workload that matter to your app.
AI coding-assistant support #
scroll_spy ships an installable, instructions-only plugin for Claude Code and
OpenAI Codex. It teaches an assistant the package's integration, stability,
diagnosis, performance, 0.x migration, and visibility_detector conversion
workflows. It is development tooling, not part of the Flutter runtime package.
Claude Code:
/plugin marketplace add omar-hanafy/scroll_spy
/plugin install scroll-spy@scroll-spy
OpenAI Codex:
codex plugin marketplace add omar-hanafy/scroll_spy
codex plugin add scroll-spy@scroll-spy
Start a new session after installation, then ask for the outcome directly, for example: "Add scroll_spy autoplay to my feed so exactly one video plays."
The plugin has no hooks, MCP servers, executable scripts, or network access. See AI assistant setup and troubleshooting for the full capability list and explicit skill names.
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
dart run tool/validate_ai_plugin.dart
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.