Skip to content

fix(detail): scroll-follow the rec rail/grid cursor in Marquee, Dossier, Console - #281

Merged
ghbarker merged 1 commit into
feature/post-refactorfrom
fix/detail-rec-rail-scroll-follow
Sep 8, 2026
Merged

ghbarker merged 1 commit into
feature/post-refactorfrom
fix/detail-rec-rail-scroll-follow

Conversation

@ghbarker

@ghbarker ghbarker commented Sep 8, 2026

Copy link
Copy Markdown
Owner

Why

Audit found a D-pad focus bug in three detail-page "More like this" regions: they are lazily-built scrollables (ListView.separated in Marquee/Dossier, GridView.builder in Console) whose per-card onFocusChange only did setState — no scroll-into-view.

Every OTHER layout in the family (detail_layout_premium.dart, detail_layout_showcase.dart, detail_layout_stage.dart) already has an explicit Scrollable.ensureVisible somewhere in its focus-change plumbing, and merged_series_detail_screen.dart's own cast/rec rail sidesteps the problem entirely by building all cards eagerly. Marquee, Dossier and Console were the odd ones out.

The concrete failure mode: Flutter's default FocusTraversalPolicy.inDirection (what plain arrow-key traversal uses) already reveals newly-focused nodes on its own, so simple repeated arrow-key walks were actually fine. The real gap is each layout's own jump helper — _focusCollection / _focusFirstCell (Marquee, Console) and _focusRightPane (Dossier) — which hands focus to a rec card via a raw FocusNode.requestFocus() call (e.g. DOWN from the action row, RIGHT crossing into Dossier's reference pane). A raw requestFocus() does not go through the traversal policy, so it never scrolls, and a card built-but-off-screen (within the lazy list's cache extent but outside the visible viewport) keeps focus while remaining invisible.

What

  • lib/widgets/detail/detail_layout_marquee.dart — _RecCardState's onFocusChange
  • lib/widgets/detail/detail_layout_dossier.dart — _RecPosterState's onFocusChange
  • lib/widgets/detail/detail_layout_console.dart — _ConsolePosterState's onFocusChange

Each now calls Scrollable.ensureVisible(context, alignment: 0.5, alignmentPolicy: ScrollPositionAlignmentPolicy.explicit, duration: Duration.zero) in a post-frame callback whenever the card gains focus — matching the exact convention already used by detail_layout_stage.dart's _TabButton, detail_identity.dart's _focusAndReveal, and catalog_item_tile.dart. This fixes visibility regardless of whether focus arrived via arrow-key traversal (redundant call, harmless) or a direct requestFocus() jump (the only thing that scrolls it at all).

Did not switch these rails to eager building — they can hold more items than the merged screen's cast rail, so lazy + scroll-follow is the right fix, not eager building.

Console's _ReferencePane finding

Read _ReferencePane (the wide/TV cast/synopsis/details rail that scrolls by hardcoded ±120px on UP/DOWN as a single focus node) closely, since the ticket asked whether the recs grid's _recNodes ever live inside it. They don't: _recsRegion's GridView.builder is built inside _main, which is a sibling of _reference()/_ReferencePane in the Row (Expanded(child: _main(...)) next to SizedBox(width: 272, child: _reference(...))). _ReferencePane only wraps the reference rail's own content (awards/summary/details/cast/guide), which is explicitly documented as non-focusable throughout ("a cast portrait has no action"). So the grid's cells are independently focusable and reachable by D-pad, unaffected by the pane's own scroll handling.

How verified

  • flutter test --no-pub test/detail_layout_rec_scroll_follow_test.dart — 3 new widget tests (one per layout), each building ~20-30 recommendations in a 960×540 TV viewport, grabbing a built-but-off-screen card's own FocusNode from the live tree, calling requestFocus() on it exactly as the layouts' own jump helpers do, and asserting the resulting rect lies inside the scrollable's viewport. Confirmed each test fails against the pre-fix code (verified by temporarily stashing the fix) and passes with it — a genuine regression test, not just a passing assertion.
  • flutter test --no-pub test/detail_layouts_dpad_test.dart test/detail_theme_test.dart test/detail_theme_layout_test.dart — full shared detail-layout regression suite, 1144 tests, all passing (no visual/behavioral regression).
  • dart analyze on all four changed files — no issues.

Risks

Low. The change is additive (an extra ensureVisible call on focus gain) and follows an existing, proven codebase convention used in three sibling layouts already. Duration.zero matches detail_layout_stage.dart's own rec-adjacent usage, so no new animation/timing behavior is introduced.

…er, Console

The "More like this" rail (Marquee, Dossier) and grid (Console) are
lazily-built scrollables whose per-card onFocusChange only did setState —
no scroll-into-view. Layout jump helpers (_focusCollection, _focusFirstCell,
_focusRightPane) hand focus to a rec card via a raw FocusNode.requestFocus(),
which does not go through FocusTraversalPolicy.inDirection and therefore
never scrolls on its own, so the D-pad cursor could land on a card sitting
outside the visible viewport.

Every card's onFocusChange now calls Scrollable.ensureVisible (alignment
0.5, explicit policy, post-frame, matching the convention already used in
detail_layout_stage.dart's _TabButton, detail_identity.dart and
catalog_item_tile.dart) whenever it gains focus, regardless of whether that
focus arrived via traversal or a direct jump.

Console's grid cells are not affected by _ReferencePane: that widget only
wraps the separate reference rail (cast/details/guide), a sibling region to
_recsRegion's grid, so the grid's own focus nodes are unaffected by its
scroll handling.
@ghbarker
ghbarker merged commit dfaea27 into feature/post-refactor Sep 8, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant