fix(detail): scroll-follow the rec rail/grid cursor in Marquee, Dossier, Console - #281
Merged
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Audit found a D-pad focus bug in three detail-page "More like this" regions: they are lazily-built scrollables (
ListView.separatedin Marquee/Dossier,GridView.builderin Console) whose per-cardonFocusChangeonly didsetState— 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 explicitScrollable.ensureVisiblesomewhere in its focus-change plumbing, andmerged_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 rawFocusNode.requestFocus()call (e.g. DOWN from the action row, RIGHT crossing into Dossier's reference pane). A rawrequestFocus()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'sonFocusChangelib/widgets/detail/detail_layout_dossier.dart—_RecPosterState'sonFocusChangelib/widgets/detail/detail_layout_console.dart—_ConsolePosterState'sonFocusChangeEach 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 bydetail_layout_stage.dart's_TabButton,detail_identity.dart's_focusAndReveal, andcatalog_item_tile.dart. This fixes visibility regardless of whether focus arrived via arrow-key traversal (redundant call, harmless) or a directrequestFocus()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
_ReferencePanefindingRead
_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'sGridView.builderis built inside_main, which is a sibling of_reference()/_ReferencePanein theRow(Expanded(child: _main(...))next toSizedBox(width: 272, child: _reference(...)))._ReferencePaneonly 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 ownFocusNodefrom the live tree, callingrequestFocus()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 analyzeon all four changed files — no issues.Risks
Low. The change is additive (an extra
ensureVisiblecall on focus gain) and follows an existing, proven codebase convention used in three sibling layouts already.Duration.zeromatchesdetail_layout_stage.dart's own rec-adjacent usage, so no new animation/timing behavior is introduced.