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
8 changes: 8 additions & 0 deletions modules/ensemble/assets/schema/ensemble_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -3339,6 +3339,10 @@
"reverse": {
"type": "boolean",
"description": "Show list in reverse order. Default is false"
},
"minHeight": {
"type": "integer",
"description": "Minimum height of the list"
}
}
}
Expand Down Expand Up @@ -6822,6 +6826,10 @@
"indicatorThickness": {
"type": "integer",
"description": "The thickness of the selected tab's indicator"
},
"revealTabBarOnFocus": {
"type": "boolean",
"description": "Reveal the whole TabBar when a tab receives focus"
}
}
},
Expand Down
47 changes: 43 additions & 4 deletions modules/ensemble/lib/framework/tv/tv_focus_scroll.dart
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,26 @@ ScrollableState? findNearestVerticalScrollable(BuildContext context) {
return scrollable;
}

/// Finds the outermost vertical scrollable ancestor for route-scoped memory.
ScrollableState? findOutermostVerticalScrollable(BuildContext context) {
ScrollableState? scrollable;

context.visitAncestorElements((element) {
if (element.widget is Scrollable) {
final state = (element as StatefulElement).state;
if (state is ScrollableState) {
final axis = state.axisDirection;
if (axis == AxisDirection.up || axis == AxisDirection.down) {
scrollable = state;
}
}
}
return true;
});

return scrollable;
}

/// Scrolls ONLY [scrollable] so that [itemBox] is fully visible vertically.
/// Unlike Scrollable.ensureVisible(), this does NOT affect horizontal scroll.
/// [verticalPadding] controls the threshold from viewport edges (use larger
Expand Down Expand Up @@ -104,15 +124,13 @@ void scrollVerticalOnly(
return;
}

// Calculate how much to scroll
// Use verticalPadding to position the item nicely within the viewport,
// not as a trigger threshold - so horizontal navigation doesn't jitter.
// Calculate how much to scroll. Use verticalPadding to position the item
// nicely within the viewport, not as a trigger threshold.
double scrollDelta = 0.0;
if (isAboveScreen) {
// Item is above visible area - scroll up (decrease scroll position)
scrollDelta = itemTop - (viewportTop + verticalPadding);
} else if (isBelowScreen) {
// Item is below visible area - scroll down (increase scroll position)
scrollDelta = itemBottom - (viewportBottom - verticalPadding);
}

Expand Down Expand Up @@ -152,3 +170,24 @@ void scrollWidgetIntoView(
curve: curve,
);
}

// =============================================================================
// TV Focus - Active Vertical Scrollable Memory (route-scoped)
// =============================================================================

final Map<Object, ScrollableState> _activeVerticalScrollables = {};

Object _activeScrollableRouteKey(Route<dynamic>? route) =>
route == null ? 'noRoute' : identityHashCode(route);

void rememberActiveVerticalScrollable(
Route<dynamic>? route, ScrollableState scrollable) {
_activeVerticalScrollables[_activeScrollableRouteKey(route)] = scrollable;
}

ScrollableState? activeVerticalScrollable(Route<dynamic>? route) =>
_activeVerticalScrollables[_activeScrollableRouteKey(route)];

void clearActiveVerticalScrollableForRoute(Route<dynamic>? route) {
_activeVerticalScrollables.remove(_activeScrollableRouteKey(route));
}
4 changes: 4 additions & 0 deletions modules/ensemble/lib/framework/tv/tv_focus_widget.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:ensemble/framework/tv/tv_focus_order.dart';
import 'package:ensemble/framework/tv/tv_focus_registry.dart';
import 'package:ensemble/framework/tv/tv_focus_scroll.dart';
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
Expand Down Expand Up @@ -460,19 +461,22 @@ class TVFocusRouteObserver extends NavigatorObserver {
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
super.didPop(route, previousRoute);
TVFocusWidget.clearRowMemoryForRoute(route);
clearActiveVerticalScrollableForRoute(route);
}

@override
void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) {
super.didRemove(route, previousRoute);
TVFocusWidget.clearRowMemoryForRoute(route);
clearActiveVerticalScrollableForRoute(route);
}

@override
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
if (oldRoute != null) {
TVFocusWidget.clearRowMemoryForRoute(oldRoute);
clearActiveVerticalScrollableForRoute(oldRoute);
}
}
}
73 changes: 73 additions & 0 deletions modules/ensemble/lib/framework/tv/tv_scrollbar_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,15 @@ class TVScrollbarWidgetState extends State<TVScrollbarWidget> {
bool _isScrollable = false;
bool _isInitialized = false;

// `scrollbarOptions.autofocus` is claimed explicitly (see
// [_maybeAutofocusScrollbar]) because a competing autofocus elsewhere on the
// screen (e.g. a BackArrow fallback) makes Flutter discard the scrollbar's
// own autofocus request. Only honored during the initial settling window so
// content that becomes scrollable much later cannot yank focus away.
bool _didAutofocusScrollbar = false;
static const Duration _autofocusWindow = Duration(milliseconds: 1500);
final DateTime _createdAt = DateTime.now();

// Thumb offset/height are pushed here on scroll so only the thumb rebuilds
// (via ValueListenableBuilder) instead of the whole scrollbar subtree.
final ValueNotifier<({double offset, double height})> _thumbVN =
Expand Down Expand Up @@ -126,6 +135,17 @@ class TVScrollbarWidgetState extends State<TVScrollbarWidget> {
_focusNode.requestFocus();
}

@override
void didUpdateWidget(covariant TVScrollbarWidget oldWidget) {
super.didUpdateWidget(oldWidget);
// The ListView resolves `fallbackFocus` asynchronously, after the first
// frame. Retry the entry autofocus once it becomes a registered focus
// target (see [_maybeAutofocusScrollbar]).
if (widget.fallbackFocus != null && oldWidget.fallbackFocus == null) {
_maybeAutofocusScrollbar();
}
}

/// Refresh visibility when the owning scrollable's content dimensions change.
/// A [ScrollController] only notifies listeners for scroll-position changes,
/// not for a new maxScrollExtent produced by asynchronous content.
Expand All @@ -140,6 +160,13 @@ class TVScrollbarWidgetState extends State<TVScrollbarWidget> {

bool get _isFallbackFocusTarget => widget.fallbackFocus != null;

ScrollableState? _owningScrollable() {
if (!widget.scrollController.hasClients) return null;
final storageContext =
widget.scrollController.position.context.storageContext;
return Scrollable.maybeOf(storageContext);
}

bool get _canScrollUp {
if (!widget.scrollController.hasClients) return false;
final position = widget.scrollController.position;
Expand Down Expand Up @@ -213,6 +240,41 @@ class TVScrollbarWidgetState extends State<TVScrollbarWidget> {
: 0.0;
_thumbOffset = (maxThumbOffset * scrollRatio).clamp(0.0, maxThumbOffset);
_thumbVN.value = (offset: _thumbOffset, height: _thumbHeight);
_maybeAutofocusScrollbar();
}

/// When a scrollbar is configured with `autofocus` and content starts to
/// overflow, claim focus once after the frame. If the content never overflows
/// the scrollbar never renders, so a screen-provided fallback (e.g. a
/// BackArrow autofocus) keeps focus instead.
void _maybeAutofocusScrollbar() {
if (_didAutofocusScrollbar ||
!widget.options.autofocus ||
!_isScrollable) {
return;
}
if (DateTime.now().difference(_createdAt) > _autofocusWindow) {
_didAutofocusScrollbar = true;
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted ||
_didAutofocusScrollbar ||
!_isScrollable ||
_focusNode.hasFocus) {
if (mounted && _focusNode.hasFocus) {
_didAutofocusScrollbar = true;
}
return;
}
// Only autofocus when the scrollbar is a real TV focus target (the
// ListView had no focusable content and registered it with the page
// focus grid). Otherwise it is reached via edge handoff, and focusing
// it directly would bypass the grid and trap UP/DOWN.
if (!_isFallbackFocusTarget) return;
_didAutofocusScrollbar = true;
_focusNode.requestFocus();
});
}

void _scrollDown() {
Expand Down Expand Up @@ -315,6 +377,17 @@ class TVScrollbarWidgetState extends State<TVScrollbarWidget> {
// Re-check `_isFocused` at callback time in case focus has
// already moved away before the frame completes.
if (hasFocus) {
final owningScrollable = _owningScrollable();
final activeScrollable = owningScrollable == null
? findOutermostVerticalScrollable(context)
: findOutermostVerticalScrollable(
widget.scrollController.position.context
.storageContext) ??
owningScrollable;
if (activeScrollable != null) {
rememberActiveVerticalScrollable(
ModalRoute.of(context), activeScrollable);
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && context.mounted && _isFocused) {
scrollWidgetIntoView(
Expand Down
Loading
Loading