Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions modules/ensemble/doc/tv_developer_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,12 +161,13 @@ tvOptions:
margin: 8 # Margin when focused

# Scroll Behavior (Optional - for horizontal lists)
fixedFocusScroll: true # Enable Netflix-style scrolling
fixedFocusOffset: 48 # Offset from left edge (pixels)
verticalScrollPadding: 100 # Extra padding when scrolling vertically
scrollMode: keepVisible # 'legacy' (default) or 'keepVisible' minimal reveal
fixedFocusScroll: true # Enable Netflix-style scrolling (legacy only)
fixedFocusOffset: 48 # Offset from left edge (pixels) (legacy only)
verticalScrollPadding: 100 # Extra padding when scrolling vertically (legacy only)
scrollAnimationDuration: 200 # Scroll animation duration (ms)
scrollAnimationCurve: easeOut # Animation curve
horizontalScrollPadding: 16 # Horizontal padding for visibility checks
horizontalScrollPadding: 16 # Horizontal padding for visibility checks (legacy only)

# Horizontal Navigation Control (for carousels)
delegateHorizontalNavigation: true # Delegate LEFT/RIGHT to parent FocusScope
Expand Down Expand Up @@ -239,6 +240,32 @@ These properties change the **widget's appearance** when focused (not the focus
| **horizontalScrollPadding** | `double` | `16.0` | Horizontal padding for visibility checks during scrolling. |
| **scrollAnimationDuration** | `int` | `200` | Duration of scroll animations in milliseconds. |
| **scrollAnimationCurve** | `String` | `easeOut` | Animation curve: easeIn, easeOut, easeInOut, linear, decelerate, ease. |
| **scrollMode** | `String` | `legacy` | Auto-scroll strategy: `legacy` or `keepVisible` (see below). |

##### `scrollMode: keepVisible`

`legacy` (default) keeps the existing behavior: manual, animated scrolling that centers
horizontal items (or pins them to `fixedFocusOffset`) and applies `verticalScrollPadding`.

`keepVisible` reveals the focused item with **minimal movement** using each scrollable's own
`ScrollPosition.ensureVisible` with `keepVisibleAtStart`/`End`, like Flutter's default focus
traversal. It is correct for slivers, pinned/overlay headers, content padding and nested
scrollables, and it only scrolls when the item is actually offscreen.

```yaml
Row:
styles:
tvOptions:
row: 3
scrollMode: keepVisible # minimal, geometry-aware reveal
```

When `scrollMode: keepVisible` is set, `fixedFocusScroll`, `fixedFocusOffset`,
`verticalScrollPadding` and `horizontalScrollPadding` are ignored — they only apply to
`legacy`. `scrollAnimationDuration`, `scrollAnimationCurve` and `resetScrollOnFocus` still
apply. Prefer `keepVisible` for new TV layouts; `legacy` remains the default for backward
compatibility.


#### Carousel-Specific Properties

Expand Down
10 changes: 10 additions & 0 deletions modules/ensemble/lib/framework/tv/tv_focus_order.dart
Original file line number Diff line number Diff line change
Expand Up @@ -490,11 +490,17 @@ class TVFocusOrderNode {
// tv_focus_order call sites). Non-null => restrict to that group (matches
// _moveFocus).
// - [focusGroup] null => all focus groups.
// - [includeDescendantScan] when false, skips PASS 2 (the app-wide live
// focus-tree walk). Callers that know every focusable in scope registers a
// [TVFocusTarget] (e.g. the host's PageFocusWidget) can set this to false to
// avoid an O(all focus nodes) scan on every D-pad press. Defaults to true so
// existing behavior (Case-2 bracket/tab leaves) is unchanged.
// ─────────────────────────────────────────────────────────────────────────
static Map<TVFocusOrderNode, TVFocusOrderNode> collectInScope({
required ModalRoute<dynamic>? route,
FocusTraversalGroup? traversalGroup,
String? focusGroup,
bool includeDescendantScan = true,
}) {
final result = <TVFocusOrderNode, TVFocusOrderNode>{};

Expand All @@ -511,6 +517,10 @@ class TVFocusOrderNode {
);
}

if (!includeDescendantScan) {
return result;
}

// PASS 2 — live focus-tree scan for unregistered Case-2 leaves.
for (final focusNode in FocusManager.instance.rootScope.descendants) {
final nodeContext = focusNode.context;
Expand Down
157 changes: 153 additions & 4 deletions modules/ensemble/lib/framework/tv/tv_focus_registry.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:collection';

import 'package:flutter/material.dart';

/// Explicit focus target for a TV focus coordinate.
Expand All @@ -13,6 +15,7 @@ class TVFocusTarget {
required this.order,
required this.context,
this.route,
this.traversalGroup,
this.focusGroup,
this.isRowEntryPoint = false,
this.lockHorizontalNavigation = false,
Expand All @@ -31,11 +34,18 @@ class TVFocusTarget {
/// instead of a `ModalRoute.of` ancestor walk on every navigation query (which
/// ran for every registered target). [ModalRoute] identity is stable across
/// rebuilds, and the registrar re-registers on dependency changes, so this
/// stays current. (The enclosing FocusTraversalGroup is deliberately NOT
/// cached — its widget instance is recreated on rebuild, so [isInTraversalGroup]
/// must resolve it live.)
/// stays current.
final ModalRoute<dynamic>? route;

/// The enclosing [FocusTraversalGroup] captured at registration time.
///
/// The group widget instance is recreated on rebuild, so it is re-captured by
/// [TVFocusTargetRegistrar._register] on every rebuild (which is when a new
/// [TVFocusTarget] is constructed). This makes [isInTraversalGroup] an O(1)
/// identity comparison instead of a `findAncestorWidgetOfExactType` ancestor
/// walk that previously ran for every registered target on every D-pad press.
final FocusTraversalGroup? traversalGroup;

final String? focusGroup;
final bool isRowEntryPoint;
final bool lockHorizontalNavigation;
Expand All @@ -57,6 +67,17 @@ class TVFocusTarget {
if (traversalGroup == null) {
return true;
}
// Fast path: the group captured at registration is the one being queried.
if (identical(this.traversalGroup, traversalGroup)) {
return true;
}
// If we captured a (different) live group at registration, the target is
// known to belong to that other group and cannot be in the queried one.
// Fall back to a live walk only when registration captured nothing (e.g. a
// target registered before the group existed in its ancestry).
if (this.traversalGroup != null) {
return false;
}
final targetContext = effectiveContext;
return targetContext
?.findAncestorWidgetOfExactType<FocusTraversalGroup>() ==
Expand All @@ -65,18 +86,141 @@ class TVFocusTarget {
}

/// Route-aware registry of explicit TV focus targets.
///
/// Keeps two views of the same registrations:
/// - [_targets]: flat map keyed by [FocusNode] (used by the legacy
/// [targets] scan, kept for backward compatibility).
/// - [_index]: a route → row → order index built on registration so navigation
/// can resolve a neighbour with direct lookups instead of rebuilding the whole
/// grid from every focus node on each D-pad press.
class TVFocusRegistry {
TVFocusRegistry._();

static final Map<FocusNode, TVFocusTarget> _targets =
<FocusNode, TVFocusTarget>{};

// routeKey -> row -> order -> target. Rows and orders are SplayTreeMaps so
// predecessor/successor (LEFT/RIGHT) and nearest-row (UP/DOWN) queries are
// O(log n) on the relevant row instead of a full scan.
static final Map<Object, SplayTreeMap<double, SplayTreeMap<double, TVFocusTarget>>>
_index = {};

/// Stable map key for a route (or a shared bucket when the route is null).
static Object _routeKey(ModalRoute<dynamic>? route) =>
route == null ? 'noRoute' : identityHashCode(route);

static void register(TVFocusTarget target) {
// Remove any prior entry for this node (re-registration on rebuild, or a
// node whose row/order changed) before inserting the fresh target.
final previous = _targets[target.focusNode];
if (previous != null && !identical(previous, target)) {
_removeFromIndex(previous);
}
_targets[target.focusNode] = target;
_insertIntoIndex(target);
}

static void unregister(FocusNode focusNode) {
_targets.remove(focusNode);
final removed = _targets.remove(focusNode);
if (removed != null) {
_removeFromIndex(removed);
}
}

static void _insertIntoIndex(TVFocusTarget target) {
final orders = _index
.putIfAbsent(_routeKey(target.route), () => SplayTreeMap<double,
SplayTreeMap<double, TVFocusTarget>>())
.putIfAbsent(
target.row,
() => SplayTreeMap<double, TVFocusTarget>());
orders[target.order] = target;
}

static void _removeFromIndex(TVFocusTarget target) {
final rows = _index[_routeKey(target.route)];
if (rows == null) return;
final orders = rows[target.row];
if (orders == null) return;
// Only drop the entry if it still points at this exact registration.
if (identical(orders[target.order], target)) {
orders.remove(target.order);
}
if (orders.isEmpty) {
rows.remove(target.row);
}
if (rows.isEmpty) {
_index.remove(_routeKey(target.route));
}
}

/// All requestable targets in [row] for the current [route], ordered by
/// `order`. O(log n) row lookup plus O(k) copy of the row.
static List<TVFocusTarget> rowTargets({
required ModalRoute<dynamic>? route,
required double row,
}) {
final rows = _index[_routeKey(route)];
final orders = rows?[row];
if (orders == null) return const [];
return orders.values.where((t) => t.isRequestable).toList(growable: false);
}

/// The target at the exact cell [row]/[order], or null. O(1)–O(log n).
static TVFocusTarget? cellTarget({
required ModalRoute<dynamic>? route,
required double row,
required double order,
}) {
final target = _index[_routeKey(route)]?[row]?[order];
if (target == null || !target.isRequestable) return null;
return target;
}

/// The nearest row value strictly after [row] (down direction), or null.
static double? nextRow({
required ModalRoute<dynamic>? route,
required double row,
}) {
final rows = _index[_routeKey(route)];
return rows?.firstKeyAfter(row);
}

/// The nearest row value strictly before [row] (up direction), or null.
static double? previousRow({
required ModalRoute<dynamic>? route,
required double row,
}) {
final rows = _index[_routeKey(route)];
return rows?.lastKeyBefore(row);
}

/// Ordered row values present for [route], ascending. O(r) copy.
static List<double> rowValues({required ModalRoute<dynamic>? route}) {
final rows = _index[_routeKey(route)];
if (rows == null) return const [];
return rows.keys.toList(growable: false);
}

/// Whether any requestable target exists at [row]/[order].
static bool hasCell({
required ModalRoute<dynamic>? route,
required double row,
required double order,
}) =>
cellTarget(route: route, row: row, order: order) != null;

/// Removes every registration for a route (used on route disposal, if ever
/// needed). Individual targets unregister themselves on dispose.
static void clearRoute(ModalRoute<dynamic>? route) {
final targetNodes = _targets.entries
.where((e) => identical(_routeKey(e.value.route), _routeKey(route)))
.map((e) => e.key)
.toList(growable: false);
for (final node in targetNodes) {
unregister(node);
}
_index.remove(_routeKey(route));
}

static Iterable<TVFocusTarget> targets<T extends FocusOrder>({
Expand Down Expand Up @@ -197,6 +341,11 @@ class _TVFocusTargetRegistrarState extends State<TVFocusTargetRegistrar> {
delegateHorizontalNavigation: widget.delegateHorizontalNavigation,
context: context,
route: _route,
// Resolve once at registration so navigation queries are O(1). The
// group widget is recreated on rebuild, but this registrar re-registers
// (constructing a new target) on every rebuild, so the captured group
// stays in sync.
traversalGroup: context.findAncestorWidgetOfExactType<FocusTraversalGroup>(),
),
);
}
Expand Down
Loading
Loading