With the rail collapsed and Libraries expanded, DOWN from the header landed on a mounted but focus-excluded library node and was swallowed, trapping traversal; the focus order now matches the render exclusion and traversal scans past non-focusable candidates. An action bar rebuilding on count change disposed every node, silently dropping focus and never notifying the TV host; bindings are now reused by stable identity (external focus node, else debugLabel) and the transition is reported. Positional reuse remains the only option for an unlabeled action, but it applies only while the action-list shape is provably unchanged - catalog detail enrichment inserting Request before a focused Trailer used to hand Trailer's focused binding, focus ring and next Select included, to Request. Those actions also carry stable debugLabels so their focus survives enrichment. The text field consumed back on KeyDown outside the shared handler, letting one press act twice. It now marks the back coordinator like handleBackKeyAction does and keeps the down-only shape solely on TV, where the closing IME session swallows the matching KeyUp. Because onBack can move focus (empty search field -> sidebar), the orphaned KeyUp would otherwise drive app-level back on the new focus chain, so the field arms BackKeyUpSuppressor; the suppressor gained a hardware-level KeyUp observer that clears the armed state in a microtask after the press ends, removing the long-documented pinning hazard where an unconsumed KeyUp left it swallowing the next press.
258 lines
9.1 KiB
Dart
258 lines
9.1 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
import '../utils/platform_detector.dart';
|
|
import 'dpad_navigator.dart';
|
|
|
|
/// Handles back key events by popping the current route.
|
|
///
|
|
/// Optionally pass a [result] to return to the previous route.
|
|
///
|
|
/// Use this as an `onKeyEvent` callback for Focus widgets that need
|
|
/// simple back navigation behavior:
|
|
///
|
|
/// ```dart
|
|
/// Focus(
|
|
/// onKeyEvent: (node, event) => handleBackKeyNavigation(context, event),
|
|
/// child: ...
|
|
/// )
|
|
/// ```
|
|
///
|
|
/// With a result value:
|
|
/// ```dart
|
|
/// Focus(
|
|
/// onKeyEvent: (node, event) => handleBackKeyNavigation(
|
|
/// context,
|
|
/// event,
|
|
/// result: _hasChanges,
|
|
/// ),
|
|
/// child: ...
|
|
/// )
|
|
/// ```
|
|
class BackKeyCoordinator {
|
|
static bool _handledThisFrame = false;
|
|
static bool _clearScheduled = false;
|
|
static int _clearGeneration = 0;
|
|
|
|
static void markHandled() {
|
|
_handledThisFrame = true;
|
|
if (_clearScheduled) return;
|
|
|
|
_clearScheduled = true;
|
|
final clearGeneration = _clearGeneration;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (clearGeneration != _clearGeneration) return;
|
|
_handledThisFrame = false;
|
|
_clearScheduled = false;
|
|
});
|
|
// addPostFrameCallback does not request a frame. Ensure the one-shot
|
|
// marker cannot leak when handling Back does not otherwise schedule one.
|
|
WidgetsBinding.instance.scheduleFrame();
|
|
}
|
|
|
|
static void clear() {
|
|
_handledThisFrame = false;
|
|
_clearScheduled = false;
|
|
// Invalidate a pending callback so it cannot clear a newer marker.
|
|
_clearGeneration++;
|
|
}
|
|
|
|
static bool consumeIfHandled() {
|
|
if (!_handledThisFrame) return false;
|
|
clear();
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/// Consumes KeyDown/KeyRepeat to avoid duplicate actions, runs [onBack] on KeyUp.
|
|
KeyEventResult handleBackKeyAction(KeyEvent event, VoidCallback onBack) {
|
|
if (!event.logicalKey.isBackKey) return KeyEventResult.ignored;
|
|
|
|
// Check if this BACK event should be suppressed (e.g., after modal closed)
|
|
if (BackKeyUpSuppressor.consumeIfSuppressed(event)) {
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
// AppleTV back (Siri Remote Menu via engine-synthesized escape): run onBack
|
|
// on KeyDown only; consume KeyUp silently. Some engine paths report Menu as
|
|
// a non-keyboard device, but the same down-only handling is still required.
|
|
// No suppressor arming is needed here: onBack typically calls
|
|
// Navigator.pop, swapping the focus tree before the matching KeyUp is
|
|
// dispatched — but the orphaned KeyUp lands on the new chain, where this
|
|
// same branch consumes it silently.
|
|
if (PlatformDetector.isAppleTV()) {
|
|
if (event is KeyDownEvent) {
|
|
BackKeyCoordinator.markHandled();
|
|
onBack();
|
|
}
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
if (event is KeyUpEvent) {
|
|
BackKeyCoordinator.markHandled();
|
|
onBack();
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (event is KeyDownEvent || event is KeyRepeatEvent) {
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
}
|
|
|
|
KeyEventResult handleBackKeyNavigation<T>(BuildContext context, KeyEvent event, {T? result}) {
|
|
// Let system handle back if there's nothing to pop (e.g. root screen)
|
|
if (!Navigator.canPop(context)) {
|
|
return KeyEventResult.ignored;
|
|
}
|
|
// Don't handle back when a dialog/overlay is on top of our route —
|
|
// the overlay handles its own dismissal via DismissAction.
|
|
if (ModalRoute.of(context)?.isCurrent != true) {
|
|
return KeyEventResult.ignored;
|
|
}
|
|
// Handle on KeyUpEvent to prevent double-pop when returning from child screens
|
|
// (KeyDownEvent can be received by both the popping screen and the returned-to screen)
|
|
return handleBackKeyAction(event, () => Navigator.pop(context, result));
|
|
}
|
|
|
|
/// Consumes all select-key events (down, repeat, up) so they don't reach
|
|
/// platform-level handlers; fires [onActivate] on the initial KeyDown only.
|
|
KeyEventResult handleOneShotSelect(KeyEvent event, VoidCallback onActivate) {
|
|
if (!event.logicalKey.isSelectKey) return KeyEventResult.ignored;
|
|
if (event is KeyDownEvent) onActivate();
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
/// Whether the primary focus currently belongs to an active text editor.
|
|
///
|
|
/// Ancestor key handlers use this to stay off keys a focused field owns.
|
|
/// Flutter dispatches a key event from the focused node upwards, and the
|
|
/// editing shortcuts that turn Backspace into a deletion live in
|
|
/// [DefaultTextEditingShortcuts] at the very top of the app — *above* any
|
|
/// screen. An ancestor that claims Backspace as "back" therefore both steals
|
|
/// the navigation and stops the character from ever being deleted (#1741).
|
|
///
|
|
/// [EditableText] builds its [Focus] internally, so the focused node's context
|
|
/// resolves to the owning [EditableTextState].
|
|
bool isTextEditingFocused() {
|
|
final context = FocusManager.instance.primaryFocus?.context;
|
|
if (context == null) return false;
|
|
return context.findAncestorStateOfType<EditableTextState>() != null;
|
|
}
|
|
|
|
/// Expands a UTF-16 [range] to whole extended grapheme clusters in [text].
|
|
///
|
|
/// Flutter selections use UTF-16 code-unit offsets. Custom editors must pass a
|
|
/// non-empty range containing the code units they intend to replace; the
|
|
/// returned range is normalized, clamped, and safe to use with
|
|
/// [String.replaceRange] without splitting a user-perceived character.
|
|
TextRange expandToGraphemeRange(String text, TextRange range) {
|
|
if (text.isEmpty) return TextRange.empty;
|
|
|
|
final first = range.start.clamp(0, text.length);
|
|
final second = range.end.clamp(0, text.length);
|
|
final start = first <= second ? first : second;
|
|
final end = first <= second ? second : first;
|
|
if (start == end) return TextRange.collapsed(start);
|
|
|
|
final boundary = CharacterBoundary(text);
|
|
return TextRange(
|
|
start: boundary.getLeadingTextBoundaryAt(start) ?? 0,
|
|
end: boundary.getTrailingTextBoundaryAt(end - 1) ?? text.length,
|
|
);
|
|
}
|
|
|
|
/// Creates a [FocusOnKeyEventCallback] that dispatches d-pad / arrow keys to
|
|
/// the provided directional callbacks.
|
|
///
|
|
/// Each callback is optional. Directions without a callback are ignored
|
|
/// (passed through to the framework). Directions mapped to a callback
|
|
/// automatically return [KeyEventResult.handled].
|
|
///
|
|
/// When [trapHorizontalEdges] is true, LEFT/RIGHT with no callback return
|
|
/// [KeyEventResult.handled] (consumed) instead of being passed through. Use
|
|
/// this for a self-contained horizontal group (e.g. a button row) so D-pad
|
|
/// can't escape off the edge into an off-screen "black hole" (#1181); wire an
|
|
/// explicit [onLeft]/[onRight] only where edge-escape into another region is
|
|
/// intended. UP/DOWN are unaffected and always pass through when unmapped.
|
|
///
|
|
/// Directional keys repeat on [KeyRepeatEvent] (via [isActionable]).
|
|
/// Select is one-shot: fires on [KeyDownEvent] only, consumes repeat and up.
|
|
///
|
|
/// ```dart
|
|
/// Focus(
|
|
/// onKeyEvent: dpadKeyHandler(
|
|
/// onUp: () => _focusAppBar(),
|
|
/// onDown: () => _focusContent(),
|
|
/// onLeft: () => _navigateToSidebar(),
|
|
/// onSelect: () => _play(),
|
|
/// ),
|
|
/// child: ...
|
|
/// )
|
|
/// ```
|
|
FocusOnKeyEventCallback dpadKeyHandler({
|
|
VoidCallback? onUp,
|
|
VoidCallback? onDown,
|
|
VoidCallback? onLeft,
|
|
VoidCallback? onRight,
|
|
VoidCallback? onSelect,
|
|
bool trapHorizontalEdges = false,
|
|
}) {
|
|
return (FocusNode _, KeyEvent event) {
|
|
// Select: one-shot activation (no repeat), must run before isActionable
|
|
// filter so KeyUpEvent is also consumed.
|
|
if (onSelect != null) {
|
|
final result = handleOneShotSelect(event, onSelect);
|
|
if (result != KeyEventResult.ignored) return result;
|
|
}
|
|
|
|
if (!event.isActionable) return KeyEventResult.ignored;
|
|
final key = event.logicalKey;
|
|
|
|
if (key.isUpKey && onUp != null) {
|
|
onUp();
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key.isDownKey && onDown != null) {
|
|
onDown();
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key.isLeftKey) {
|
|
if (onLeft != null) {
|
|
onLeft();
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (trapHorizontalEdges) return KeyEventResult.handled;
|
|
}
|
|
if (key.isRightKey) {
|
|
if (onRight != null) {
|
|
onRight();
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (trapHorizontalEdges) return KeyEventResult.handled;
|
|
}
|
|
|
|
return KeyEventResult.ignored;
|
|
};
|
|
}
|
|
|
|
/// Navigator observer that automatically suppresses stray back KeyUp events
|
|
/// after any route pop caused by a back key press.
|
|
///
|
|
/// This catches pops triggered by Flutter's built-in DismissAction (which fires
|
|
/// on KeyDown for dialogs) and Android TV system back gestures, preventing the
|
|
/// orphaned KeyUp from propagating to the underlying screen's back handler.
|
|
class BackKeySuppressorObserver extends NavigatorObserver {
|
|
@override
|
|
void didPop(Route route, Route? previousRoute) {
|
|
// On AppleTV, handleBackKeyAction consumes the KeyUp silently regardless,
|
|
// so the suppressor isn't needed. (The atomic engine fix delivers
|
|
// KeyDown+KeyUp in a single recognizer Began callback, so didPop fires
|
|
// squarely inside the window where BackKeyPressTracker.isBackKeyDown is
|
|
// true.)
|
|
if (PlatformDetector.isAppleTV()) return;
|
|
if (BackKeyPressTracker.isBackKeyDown) {
|
|
BackKeyUpSuppressor.suppressBackUntilKeyUp();
|
|
}
|
|
}
|
|
}
|