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
4 changes: 4 additions & 0 deletions modules/ensemble/doc/layout-widgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ renders only the tab navigation. Both are backed by `BaseTabBar`,
| `navigateTo(index)` | Programmatically selects a tab. `changeTabItem(index)` is kept as a legacy alias. |
| `useIndexedTab` | Builds each tab body on first visit and caches it for later tab switches. |
| `persistentTabBar` | Wraps tab bodies in a scroll view so the tab bar remains pinned while body content scrolls. |
| `styles.tabFocusOnBack` | TV-only, opt-in: the first Back press from tab content restores focus to the selected tab; a separate next press uses normal navigation. Defaults to `false`. |

`tabFocusOnBack` applies to `TabBar` content; `TabBarOnly` has no body to return
from, and `ScrollableTabBar` retains its scroll-driven selection behavior.

### Example

Expand Down
14 changes: 14 additions & 0 deletions modules/ensemble/lib/framework/device.dart
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@ class Device
void openAppSettings([String? target]) {
AppSettingAction.openAppSettings(target ?? 'settings');
}

/// Test-only override of [DeviceInfoCapability.isTV] so widget tests can
/// exercise TV-only rendering without real Android device info.
/// Pass null to clear the override and fall back to platform detection.
@visibleForTesting
static void debugOverrideIsTV(bool? value) {
DeviceInfoCapability._isTVOverride = value;
}
}

mixin MediaQueryCapability {
Expand Down Expand Up @@ -132,11 +140,17 @@ mixin DeviceInfoCapability {
// Android TV detection cache
static bool? _isTV;

/// Test-only override consulted before device detection, so a test build can
/// pretend to run on a TV without populating [androidInfo]. Declared here (not
/// in [Device]) because [isTV] reads it. See [Device.debugOverrideIsTV].
static bool? _isTVOverride;

DevicePlatform? get platform => _platform;

/// Returns true if the device is an Android TV
/// Checks for TV-specific system features
bool get isTV {
if (_isTVOverride != null) return _isTVOverride!;
if (_isTV != null) return _isTV!;

// Web is never a TV, and it's known synchronously — safe to cache.
Expand Down
3 changes: 2 additions & 1 deletion modules/ensemble/lib/framework/tv/tv_focus_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,8 @@ class TVFocusWidget extends StatelessWidget {
}

// Check if trying to exit at top boundary (UP and at first row in grid)
// Let the event propagate to native focus handling (e.g., sport tab)
// Let the event propagate to native focus handling (e.g., a native tab or
// screen hosting this Ensemble view)
// so users can navigate back to native content from Ensemble content
if (yOffset == -1 && y == 0) {
if (topEdgeHandler != null) {
Expand Down
117 changes: 117 additions & 0 deletions modules/ensemble/lib/layout/tab/base_tab_bar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import 'package:ensemble/framework/widget/widget.dart';
import 'package:ensemble/layout/tab/tab_bar_controller.dart';
import 'package:ensemble/layout/tab_bar.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:ensemble/framework/widget/icon.dart' as ensemble;

// =============================================================================
Expand All @@ -27,6 +28,99 @@ abstract class BaseTabBarState extends EWidgetState<BaseTabBar>
// execute when a tab has (already) changed. Use this to run extra logic (and don't modify the tabController again
void onTabChanged(int index);

// ---------------------------------------------------------------------------
// TV: return focus to the selected tab on Back
// ---------------------------------------------------------------------------

/// The mounted TV tab buttons, keyed by visible tab index.
///
/// Populated by [_TVTabButtonState] so [focusSelectedTab] can target the
/// selected tab directly, rather than through grid coordinates (whose
/// nearest-match fallback could land on a different control).
final Map<int, _TVTabFocusEntry> _tvTabFocusEntries = {};

void _registerTVTabFocusEntry(int index, _TVTabFocusEntry entry) {
_tvTabFocusEntries[index] = entry;
}

void _unregisterTVTabFocusEntry(int index, _TVTabFocusEntry entry) {
// Only drop the entry if it is still this one: a replacement button for the
// same index must not be removed by the outgoing one.
if (identical(_tvTabFocusEntries[index], entry)) {
_tvTabFocusEntries.remove(index);
}
}

/// Focuses the currently selected tab button, returning whether focus moved.
///
/// Uses the index the strip itself treats as selected, and reveals the strip
/// explicitly: the first focus gain on a tab is intentionally not revealed
/// (legacy initial-autofocus guard), so a Back press from scrolled-away
/// content would otherwise move focus somewhere the user cannot see.
///
/// Returns false when this TabBar has no TV tab buttons (mobile/web builds),
/// which leaves the Back press to the platform.
bool focusSelectedTab() {
final entry = _tvTabFocusEntries[tabController.index];
if (entry == null || entry.node.hasFocus || !entry.node.canRequestFocus) {
return false;
}
entry.node.requestFocus();
entry.reveal();
return true;
}

// A Back key-down handled by the content scope must also consume the paired
// key-up. Android tracks the down/up pair and can redispatch an unhandled up
// to Activity.onKeyUp, which may pop the route even though focus has moved.
bool _handledTabBackKeySequence = false;

KeyEventResult handleTabBackKeySequence(KeyEvent event) {
if (event.logicalKey != LogicalKeyboardKey.goBack ||
!_handledTabBackKeySequence) {
return KeyEventResult.ignored;
}

// A non-repeat down is a new physical press (normally preceded by the
// release); don't let a missing/cancelled up leave navigation disabled.
if (event is KeyDownEvent) {
_handledTabBackKeySequence = false;
return KeyEventResult.ignored;
}
if (event is KeyUpEvent) {
_handledTabBackKeySequence = false;
}
// Consume repeats and the paired release for the handled press.
return KeyEventResult.handled;
}

/// Key handler for the opt-in TabBar content wrapper
/// (`styles.tabFocusOnBack: true`).
///
/// Consumes a plain Back key-down only when focus successfully moves. That
/// handled press's repeats and key-up are then consumed by the surrounding
/// TabBar scope, so they cannot re-trigger or navigate by themselves.
/// The wrapper surrounds the tab *content* only, so once focus is on this
/// TabBar's own strip the handler is no longer in the focus path and the next
/// Back press reaches normal navigation untouched. If this TabBar is nested
/// inside another opted-in TabBar, Back walks out one tab level per press.
KeyEventResult handleTabBodyBackKey(KeyEvent event) {
if (_handledTabBackKeySequence) {
final result = handleTabBackKeySequence(event);
// A fresh key-down means the previous press's key-up never arrived; the
// flag is now cleared, so handle this press normally.
if (event is! KeyDownEvent) return result;
}
if (event is! KeyDownEvent ||
event.logicalKey != LogicalKeyboardKey.goBack) {
return KeyEventResult.ignored;
}
if (!focusSelectedTab()) return KeyEventResult.ignored;

_handledTabBackKeySequence = true;
return KeyEventResult.handled;
}

// common method for both scrollable and simple Tabbar
bool evaluateCondition(ScopeManager scopeManager, String expression) {
try {
Expand Down Expand Up @@ -232,6 +326,8 @@ abstract class BaseTabBarState extends EWidgetState<BaseTabBar>
tabPadding: widget.controller.tabPadding,
scrollPadding: widget.controller.tvOptions?.verticalScrollPadding,
revealTabBarOnFocus: widget.controller.revealTabBarOnFocus,
registerFocusEntry: _registerTVTabFocusEntry,
unregisterFocusEntry: _unregisterTVTabFocusEntry,
onTap: () {
tabController.animateTo(index);
onTabChanged(index);
Expand Down Expand Up @@ -394,6 +490,16 @@ mixin TabBarAction on EWidgetState<BaseTabBar> {
void changeTab(int index);
}

/// A mounted TV tab button, as seen by its owning TabBar: the focus node that
/// Back should target, plus the reveal that makes it visible (the button only
/// auto-reveals on a repeat focus gain, not on the first one).
class _TVTabFocusEntry {
const _TVTabFocusEntry(this.node, this.reveal);

final FocusNode node;
final VoidCallback reveal;
}

/// TV-specific focusable tab button using row/order-based navigation.
/// Each tab uses TVFocusOrder coordinates.
/// If TabBar has tvRow set, tabs use that row in the main page grid.
Expand All @@ -418,6 +524,8 @@ class _TVTabButton extends StatefulWidget {
this.tabPadding,
this.scrollPadding,
this.revealTabBarOnFocus = false,
required this.registerFocusEntry,
required this.unregisterFocusEntry,
});

final TabItem tabItem;
Expand All @@ -441,23 +549,32 @@ class _TVTabButton extends StatefulWidget {
final double? scrollPadding;
final bool revealTabBarOnFocus;

/// Registers/unregisters this button with the owning TabBar, so Back inside
/// the tab content can return focus to the selected tab.
final void Function(int index, _TVTabFocusEntry entry) registerFocusEntry;
final void Function(int index, _TVTabFocusEntry entry) unregisterFocusEntry;

@override
State<_TVTabButton> createState() => _TVTabButtonState();
}

class _TVTabButtonState extends State<_TVTabButton> {
late final FocusNode _focusNode;
late final _TVTabFocusEntry _focusEntry;
bool _hasReceivedFocus = false;

@override
void initState() {
super.initState();
_focusNode = FocusNode(debugLabel: 'TVTabButton_${widget.index}');
_focusNode.addListener(_onFocusChange);
_focusEntry = _TVTabFocusEntry(_focusNode, _scrollIntoView);
widget.registerFocusEntry(widget.index, _focusEntry);
}

@override
void dispose() {
widget.unregisterFocusEntry(widget.index, _focusEntry);
_focusNode.removeListener(_onFocusChange);
_focusNode.dispose();
super.dispose();
Expand Down
13 changes: 13 additions & 0 deletions modules/ensemble/lib/layout/tab/tab_bar_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,17 @@ class TabBarController extends BoxController {
/// TV: reveal the whole TabBar when a tab receives focus.
bool revealTabBarOnFocus = false;

/// TV: when true, pressing Back while focus is inside this TabBar's content
/// moves focus back to the selected tab instead of leaving the screen. The
/// next Back press (now on the tab strip) navigates as usual. Opt-in and
/// default false, so existing behavior is unchanged.
///
/// Applies to `TabBar` (TabBarContainer) content. `TabBarOnly` has no content
/// to return from, and `ScrollableTabBar` selects by scroll position rather
/// than by a per-tab body, so both ignore this flag.
/// Set with `styles.tabFocusOnBack`.
bool tabFocusOnBack = false;

/// Whether the TabBar body occupies the available flex height.
bool get isStretched {
if (flexMode == FlexMode.expanded ||
Expand Down Expand Up @@ -100,6 +111,8 @@ class TabBarController extends BoxController {
'tvRow': (value) => tvRow = Utils.optionalDouble(value),
'revealTabBarOnFocus': (value) =>
revealTabBarOnFocus = Utils.getBool(value, fallback: false),
'tabFocusOnBack': (value) =>
tabFocusOnBack = Utils.getBool(value, fallback: false),
});
return setters;
}
Expand Down
22 changes: 22 additions & 0 deletions modules/ensemble/lib/layout/tab_bar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,19 @@ class TabBarState extends BaseTabBarState {
return buildSelectedTab();
},
);
// Opt-in TV Back handling (`styles.tabFocusOnBack: true`): while
// focus is inside this TabBar's content, Back moves focus to the selected
// tab instead of leaving the screen. The wrapper surrounds the content
// only, so the tab strip and everything outside this TabBar keep their
// existing Back behavior. Non-TV builds have no TV tab buttons
// registered, so handleTabBodyBackKey leaves the press to the platform.
if (widget._controller.tabFocusOnBack) {
tabContent = Focus(
canRequestFocus: false,
onKeyEvent: (_, KeyEvent event) => handleTabBodyBackKey(event),
child: tabContent,
);
}
if (isExpanded) {
tabContent = Expanded(child: tabContent);
}
Expand All @@ -345,6 +358,15 @@ class TabBarState extends BaseTabBarState {
tabContent,
],
);
if (widget._controller.tabFocusOnBack) {
// Keep this Focus below the outer Expanded. Expanded must remain the
// direct Flex child when the TabBar sits in a Column with `expanded: true`.
tabWidget = Focus(
canRequestFocus: false,
onKeyEvent: (_, event) => handleTabBackKeySequence(event),
child: tabWidget,
);
}
// if Expanded is set, stretch our column to left-over height
if (isExpanded) {
tabWidget = Expanded(child: tabWidget);
Expand Down
Loading
Loading