225 lines
8.0 KiB
Dart
225 lines
8.0 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 void markHandled() {
|
|
_handledThisFrame = true;
|
|
if (_clearScheduled) return;
|
|
_clearScheduled = true;
|
|
// Clear on next frame to avoid blocking unrelated future back presses.
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_handledThisFrame = false;
|
|
_clearScheduled = false;
|
|
});
|
|
}
|
|
|
|
static bool consumeIfHandled() {
|
|
if (_handledThisFrame) {
|
|
_handledThisFrame = false;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// 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 physical-keyboard back (Siri Remote Menu via engine-synthesized
|
|
// escape): run onBack on KeyDown only; consume KeyUp silently. The
|
|
// suppressor-based "arm-on-KeyDown, clear-on-KeyUp" pattern leaks here
|
|
// because onBack typically calls Navigator.pop, swapping the focus tree
|
|
// before the matching KeyUp is dispatched — the orphaned KeyUp then never
|
|
// reaches a consumeIfSuppressed call, pinning the suppressor armed and
|
|
// silently swallowing the next press's KeyDown.
|
|
if (PlatformDetector.isAppleTV() && event.isPhysicalKeyboardEvent) {
|
|
if (event is KeyDownEvent) {
|
|
BackKeyCoordinator.markHandled();
|
|
onBack();
|
|
}
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
if (event is KeyUpEvent) {
|
|
BackKeyCoordinator.markHandled();
|
|
// Mark that we're closing via back key so suppressBackUntilKeyUp() knows to skip
|
|
BackKeyUpSuppressor.markClosedViaBackKey();
|
|
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;
|
|
}
|
|
|
|
/// 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;
|
|
};
|
|
}
|
|
|
|
/// Whether [container] has another focusable descendant beyond the currently
|
|
/// focused one in [direction]. Lets a row's key handler move between interior
|
|
/// items (true) but trap at the row's edge (false) so focus can't escape into
|
|
/// an off-screen "black hole" (#1181). Horizontal only.
|
|
///
|
|
/// Relies on [FocusNode.traversalDescendants] being in reading (left→right)
|
|
/// order, which holds for a flat [Row].
|
|
bool hasHorizontalNeighbor(FocusNode container, TraversalDirection direction) {
|
|
assert(direction == TraversalDirection.left || direction == TraversalDirection.right);
|
|
final items = container.traversalDescendants.toList();
|
|
final index = items.indexWhere((node) => node.hasPrimaryFocus);
|
|
if (index < 0) return false;
|
|
return direction == TraversalDirection.right ? index < items.length - 1 : index > 0;
|
|
}
|
|
|
|
/// 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 and arming it would pin state across the
|
|
// pop's focus-tree swap. (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();
|
|
}
|
|
}
|
|
}
|