From ScrollSpy Assistant
Integrates the scroll_spy Flutter package for tracking visible items, autoplay, reading position, impression analytics, and carousels. Wires ScrollSpyScope, items, and controller.
How this skill is triggered — by the user, by Claude, or both
Slash command
/scroll-spy:integrate-scroll-spyThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
scroll_spy computes, per scroll: which items are visible, which intersect a
scroll_spy computes, per scroll: which items are visible, which intersect a configurable focus region, and which single item is the stable primary. You wire four things: a scope (owns the engine), items (register geometry), a controller (read side), and a reaction (builder or listener).
Before writing code, inspect the project: confirm scroll_spy: ^1.x in
pubspec.yaml (0.x: run migrate-scroll-spy-v0-to-v1 first), find any existing
ScrollSpyScope/controller to extend instead of duplicating, and note the
scrollable type you are instrumenting. Exact signatures for everything used
below: references/api-cheatsheet.md.
Use a wrapper when the scrollable is a plain ListView/GridView/PageView/
CustomScrollView: ScrollSpyListView.builder/.separated,
ScrollSpyGridView.builder, ScrollSpyPageView.builder,
ScrollSpyCustomScrollView. Wrappers mirror the Flutter constructor and,
critically, wire the scroll/page controller to both the view and the scope.
For any other scrollable, wrap it in ScrollSpyScope<T> yourself and pass
the same ScrollController to both the scrollable and the scope's
scrollController: parameter.
| Use case | region | policy | stability | updatePolicy |
|---|---|---|---|---|
| Autoplay feed | zone(anchor: fraction(0.5), extentPx: ~200) | closestToAnchor | hysteresisPx: 24, minPrimaryDuration: 150ms | default perFrame |
| Reading position / TOC | line(anchor: fraction(0.2..0.3), thicknessPx: 0..2) | closestToAnchor | small or none; allowPrimaryWhenNoItemFocused: false for strict "nothing under the line" | perFrame |
| Impressions / analytics at rest | zone or generous zone | closestToAnchor | defaults | onScrollEnd(debounce: 80ms) if events should fire only after settling |
| Carousel / PageView | zone(anchor: fraction(0.5), extentPx: ~page extent) | closestToAnchor or largestFocusProgress | small hysteresis | perFrame (drives focusProgress effects) |
| Photo grid | zone | largestVisibleFraction | minPrimaryDuration: ~120ms | perFrame |
Stability defaults are all-off; feeds must set values or the primary will chatter. To adjust behavior later, use the tune-scroll-spy-stability skill.
| Need | Use |
|---|---|
| Side effects only (play/pause, analytics, haptics) | ScrollSpyPrimaryListener / ScrollSpyItemPrimaryListener / ...FocusedListener / ...VisibleListener: no rebuilds, onChanged(previous, current), no initial call |
| Rebuild on boolean flips only (highlight, badge) | ScrollSpyItemLite<T> (builder gets isPrimary, isFocused) or ScrollSpyItem{Primary,Focused,Visible}Builder |
Rebuild on live metrics (focusProgress scale/parallax) | ScrollSpyItem<T> (builder gets full ScrollSpyItemFocus) or ScrollSpyItemFocusBuilder |
| Screen-level "now playing" header | ScrollSpyPrimaryBuilder (controller optional inside the scope subtree; required outside it) |
| Whole-frame view (dashboards, prefetch windows) | ScrollSpySnapshotBuilder / controller.snapshot (notifies every pass; costliest, use last) |
Always pass the expensive static subtree through the child: slot so it is
built once; rebuild only the small reactive part around it.
controller: on scopes/wrappers is a ScrollSpyController, not a
ScrollController. The scroll controller goes in scrollController:.T everywhere: scope, items, controller, builders, listeners. A
mismatch asserts in debug and silently tracks nothing in release.State field and dispose() it. Never in
build.onScrollEnd(...) and hybrid(...) are non-const and have no ==:
store them in a field, or every rebuild churns engine config.ScrollSpyListView.separated with keyed reordering: use
findItemIndexCallback (item indices), not findChildIndexCallback.ids must be stable and unique within the scope. Index ids are fine
for static lists; use record ids when items insert/remove/reorder.viewportInsets so the region lives in the unobstructed part.With a centered anchor, the first and last items can never reach the anchor
(the list cannot overscroll them to center), so they lose closestToAnchor
at the extremes. If the product needs "top card plays when at the top",
add symmetric main-axis list padding of (viewportExtent - itemExtent) / 2
(compute via LayoutBuilder; this is what the package's own autoplay demo
does), or bias the anchor toward the start (fraction(0.4) or
offsetPx: -x). Padding keeps true centering mid-feed; anchor bias slightly
shifts the "current" feel everywhere. Say which you chose and why.
class _FeedState extends State<Feed> {
final _spy = ScrollSpyController<int>();
final _scroll = ScrollController();
@override
void dispose() {
_spy.dispose();
_scroll.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ScrollSpyPrimaryListener<int>(
controller: _spy,
onChanged: (previous, current) => players.playOnly(current),
child: ScrollSpyListView<int>.builder(
controller: _spy,
scrollController: _scroll,
region: ScrollSpyRegion.zone(
anchor: const ScrollSpyAnchor.fraction(0.5),
extentPx: 200,
),
policy: const ScrollSpyPolicy<int>.closestToAnchor(),
stability: const ScrollSpyStability(
hysteresisPx: 24,
minPrimaryDuration: Duration(milliseconds: 150),
),
itemCount: items.length,
itemExtent: 320,
itemBuilder: (context, i) => ScrollSpyItemLite<int>(
id: i,
child: FeedCardBody(item: items[i]), // static; never rebuilds
builder: (context, isPrimary, isFocused, child) => PlayFrame(
playing: isPrimary,
child: child!,
),
),
),
);
}
}
dart format changed files; flutter analyze clean.debug: true on the scope/wrapper; the red
region, yellow focused outlines, and green primary outline must match
intent. Remove before committing.pumpWidget; onScrollEnd needs its debounce
pumped. Assert primary at rest and after a scroll/jump.If the integration produces nulls, missed jumps, or offset highlights, stop guessing and use the diagnose-scroll-spy skill's symptom table.
npx claudepluginhub omar-hanafy/scroll_spy --plugin scroll-spyGuides completion of development work by verifying tests, detecting environment, and presenting structured options for merge, PR, or cleanup.
Guides creation and editing of skills using test-driven development with pressure scenarios and subagents to verify agent compliance.
Dispatches multiple subagents concurrently for independent tasks without shared state. Use when facing 2+ unrelated failures or subsystems that can be investigated in parallel.