Unverified Commit bedf46d0 authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Add shortcuts and actions for default focus traversal (#40186)

This adds the default shortcuts and actions for keyboard-based focus traversal of apps.

This list of shortcuts includes shortcuts for TAB, SHIFT TAB, RIGHT_ARROW, LEFT_ARROW, UP_ARROW, DOWN_ARROW, and the four DPAD keys for game controllers (because the DPAD produces arrow key events).

It doesn't yet include functionality for triggering a control (e.g. SPACE, ENTER, or controller buttons), because that involves restructuring some of the Flutter controls to trigger animations differently, and so will be done in another PR (#41220)
parent aeede207
......@@ -247,8 +247,8 @@ abstract class UndoableAction extends Action {
}
}
class SetFocusActionBase extends UndoableAction {
SetFocusActionBase(LocalKey name) : super(name);
class UndoableFocusActionBase extends UndoableAction {
UndoableFocusActionBase(LocalKey name) : super(name);
FocusNode _previousFocus;
......@@ -286,10 +286,8 @@ class SetFocusActionBase extends UndoableAction {
}
}
class SetFocusAction extends SetFocusActionBase {
SetFocusAction() : super(key);
static const LocalKey key = ValueKey<Type>(SetFocusAction);
class UndoableRequestFocusAction extends UndoableFocusActionBase {
UndoableRequestFocusAction() : super(RequestFocusAction.key);
@override
void invoke(FocusNode node, Intent intent) {
......@@ -299,10 +297,8 @@ class SetFocusAction extends SetFocusActionBase {
}
/// Actions for manipulating focus.
class NextFocusAction extends SetFocusActionBase {
NextFocusAction() : super(key);
static const LocalKey key = ValueKey<Type>(NextFocusAction);
class UndoableNextFocusAction extends UndoableFocusActionBase {
UndoableNextFocusAction() : super(NextFocusAction.key);
@override
void invoke(FocusNode node, Intent intent) {
......@@ -311,10 +307,8 @@ class NextFocusAction extends SetFocusActionBase {
}
}
class PreviousFocusAction extends SetFocusActionBase {
PreviousFocusAction() : super(key);
static const LocalKey key = ValueKey<Type>(PreviousFocusAction);
class UndoablePreviousFocusAction extends UndoableFocusActionBase {
UndoablePreviousFocusAction() : super(PreviousFocusAction.key);
@override
void invoke(FocusNode node, Intent intent) {
......@@ -323,16 +317,8 @@ class PreviousFocusAction extends SetFocusActionBase {
}
}
class DirectionalFocusIntent extends Intent {
const DirectionalFocusIntent(this.direction) : super(DirectionalFocusAction.key);
final TraversalDirection direction;
}
class DirectionalFocusAction extends SetFocusActionBase {
DirectionalFocusAction() : super(key);
static const LocalKey key = ValueKey<Type>(DirectionalFocusAction);
class UndoableDirectionalFocusAction extends UndoableFocusActionBase {
UndoableDirectionalFocusAction() : super(DirectionalFocusAction.key);
TraversalDirection direction;
......@@ -366,7 +352,7 @@ class _DemoButtonState extends State<DemoButton> {
void _handleOnPressed() {
print('Button ${widget.name} pressed.');
setState(() {
Actions.invoke(context, const Intent(SetFocusAction.key), focusNode: _focusNode);
Actions.invoke(context, const Intent(RequestFocusAction.key), focusNode: _focusNode);
});
}
......@@ -434,22 +420,13 @@ class _FocusDemoState extends State<FocusDemo> {
@override
Widget build(BuildContext context) {
final TextTheme textTheme = Theme.of(context).textTheme;
return Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.tab): const Intent(NextFocusAction.key),
LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.tab): const Intent(PreviousFocusAction.key),
LogicalKeySet(LogicalKeyboardKey.arrowUp): const DirectionalFocusIntent(TraversalDirection.up),
LogicalKeySet(LogicalKeyboardKey.arrowDown): const DirectionalFocusIntent(TraversalDirection.down),
LogicalKeySet(LogicalKeyboardKey.arrowLeft): const DirectionalFocusIntent(TraversalDirection.left),
LogicalKeySet(LogicalKeyboardKey.arrowRight): const DirectionalFocusIntent(TraversalDirection.right),
},
child: Actions(
return Actions(
dispatcher: dispatcher,
actions: <LocalKey, ActionFactory>{
SetFocusAction.key: () => SetFocusAction(),
NextFocusAction.key: () => NextFocusAction(),
PreviousFocusAction.key: () => PreviousFocusAction(),
DirectionalFocusAction.key: () => DirectionalFocusAction(),
RequestFocusAction.key: () => UndoableRequestFocusAction(),
NextFocusAction.key: () => UndoableNextFocusAction(),
PreviousFocusAction.key: () => UndoablePreviousFocusAction(),
DirectionalFocusAction.key: () => UndoableDirectionalFocusAction(),
kUndoActionKey: () => kUndoAction,
kRedoActionKey: () => kRedoAction,
},
......@@ -534,7 +511,6 @@ class _FocusDemoState extends State<FocusDemo> {
),
),
),
),
);
}
}
......@@ -1199,11 +1199,21 @@ class _WidgetsAppState extends State<WidgetsApp> implements WidgetsBindingObserv
return Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.tab): const Intent(NextFocusAction.key),
LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.tab): const Intent(PreviousFocusAction.key),
LogicalKeySet(LogicalKeyboardKey.arrowLeft): const DirectionalFocusIntent(TraversalDirection.left),
LogicalKeySet(LogicalKeyboardKey.arrowRight): const DirectionalFocusIntent(TraversalDirection.right),
LogicalKeySet(LogicalKeyboardKey.arrowDown): const DirectionalFocusIntent(TraversalDirection.down),
LogicalKeySet(LogicalKeyboardKey.arrowUp): const DirectionalFocusIntent(TraversalDirection.up),
LogicalKeySet(LogicalKeyboardKey.enter): const Intent(ActivateAction.key),
},
child: Actions(
actions: <LocalKey, ActionFactory>{
DoNothingAction.key: () => const DoNothingAction(),
RequestFocusAction.key: () => RequestFocusAction(),
NextFocusAction.key: () => NextFocusAction(),
PreviousFocusAction.key: () => PreviousFocusAction(),
DirectionalFocusAction.key: () => DirectionalFocusAction(),
},
child: DefaultFocusTraversal(
policy: ReadingOrderTraversalPolicy(),
......
......@@ -2,9 +2,12 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'actions.dart';
import 'basic.dart';
import 'binding.dart';
import 'focus_manager.dart';
......@@ -790,3 +793,138 @@ class DefaultFocusTraversal extends InheritedWidget {
@override
bool updateShouldNotify(DefaultFocusTraversal oldWidget) => policy != oldWidget.policy;
}
// A base class for all of the default actions that request focus for a node.
class _RequestFocusActionBase extends Action {
_RequestFocusActionBase(LocalKey name) : super(name);
FocusNode _previousFocus;
@override
void invoke(FocusNode node, Intent tag) {
_previousFocus = WidgetsBinding.instance.focusManager.primaryFocus;
node.requestFocus();
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<FocusNode>('previous', _previousFocus));
}
}
/// An [Action] that requests the focus on the node it is invoked on.
///
/// This action can be used to request focus for a particular node, by calling
/// [Action.invoke] like so:
///
/// ```dart
/// Actions.invoke(context, const Intent(RequestFocusAction.key), focusNode: _focusNode);
/// ```
///
/// Where the `_focusNode` is the node for which the focus will be requested.
///
/// The difference between requesting focus in this way versus calling
/// [_focusNode.requestFocus] directly is that it will use the [Action]
/// registered in the nearest [Actions] widget associated with [key] to make the
/// request, rather than just requesting focus directly. This allows the action
/// to have additional side effects, like logging, or undo and redo
/// functionality.
///
/// However, this [RequestFocusAction] is the default action associated with the
/// [key] in the [WidgetsApp], and it simply requests focus and has no side
/// effects.
class RequestFocusAction extends _RequestFocusActionBase {
/// Creates a [RequestFocusAction] with a fixed [key].
RequestFocusAction() : super(key);
/// The [LocalKey] that uniquely identifies this action to an [Intent].
static const LocalKey key = ValueKey<Type>(RequestFocusAction);
@override
void invoke(FocusNode node, Intent tag) {
super.invoke(node, tag);
node.requestFocus();
}
}
/// An [Action] that moves the focus to the next focusable node in the focus
/// order.
///
/// This action is the default action registered for the [key], and by default
/// is bound to the [LogicalKeyboardKey.tab] key in the [WidgetsApp].
class NextFocusAction extends _RequestFocusActionBase {
/// Creates a [NextFocusAction] with a fixed [key];
NextFocusAction() : super(key);
/// The [LocalKey] that uniquely identifies this action to an [Intent].
static const LocalKey key = ValueKey<Type>(NextFocusAction);
@override
void invoke(FocusNode node, Intent tag) {
super.invoke(node, tag);
node.nextFocus();
}
}
/// An [Action] that moves the focus to the previous focusable node in the focus
/// order.
///
/// This action is the default action registered for the [key], and by default
/// is bound to a combination of the [LogicalKeyboardKey.tab] key and the
/// [LogicalKeyboardKey.shift] key in the [WidgetsApp].
class PreviousFocusAction extends _RequestFocusActionBase {
/// Creates a [PreviousFocusAction] with a fixed [key];
PreviousFocusAction() : super(key);
/// The [LocalKey] that uniquely identifies this action to an [Intent].
static const LocalKey key = ValueKey<Type>(PreviousFocusAction);
@override
void invoke(FocusNode node, Intent tag) {
super.invoke(node, tag);
node.previousFocus();
}
}
/// An [Intent] that represents moving to the next focusable node in the given
/// [direction].
///
/// This is the [Intent] bound by default to the [LogicalKeyboardKey.arrowUp],
/// [LogicalKeyboardKey.arrowDown], [LogicalKeyboardKey.arrowLeft], and
/// [LogicalKeyboardKey.arrowRight] keys in the [WidgetsApp], with the
/// appropriate associated directions.
class DirectionalFocusIntent extends Intent {
/// Creates a [DirectionalFocusIntent] with a fixed [key], and the given
/// [direction].
const DirectionalFocusIntent(this.direction) : super(DirectionalFocusAction.key);
/// The direction in which to look for the next focusable node when the
/// associated [DirectionalFocusAction] is invoked.
final TraversalDirection direction;
}
/// An [Action] that moves the focus to the focusable node in the given
/// [direction] configured by the associated [DirectionalFocusIntent].
///
/// This is the [Action] associated with the [key] and bound by default to the
/// [LogicalKeyboardKey.arrowUp], [LogicalKeyboardKey.arrowDown],
/// [LogicalKeyboardKey.arrowLeft], and [LogicalKeyboardKey.arrowRight] keys in
/// the [WidgetsApp], with the appropriate associated directions.
class DirectionalFocusAction extends _RequestFocusActionBase {
/// Creates a [DirectionalFocusAction] with a fixed [key];
DirectionalFocusAction() : super(key);
/// The [LocalKey] that uniquely identifies this action to [DirectionalFocusIntent].
static const LocalKey key = ValueKey<Type>(DirectionalFocusAction);
/// The direction in which to look for the next focusable node when invoked.
TraversalDirection direction;
@override
void invoke(FocusNode node, DirectionalFocusIntent tag) {
super.invoke(node, tag);
final DirectionalFocusIntent args = tag;
node.focusInDirection(args.direction);
}
}
......@@ -2,6 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/painting.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
......@@ -914,5 +916,112 @@ void main() {
expect(focusCenter.hasFocus, isFalse);
expect(focusTop.hasFocus, isTrue);
});
testWidgets('Focus traversal actions are invoked when shortcuts are used.', (WidgetTester tester) async {
final GlobalKey upperLeftKey = GlobalKey(debugLabel: 'upperLeftKey');
final GlobalKey upperRightKey = GlobalKey(debugLabel: 'upperRightKey');
final GlobalKey lowerLeftKey = GlobalKey(debugLabel: 'lowerLeftKey');
final GlobalKey lowerRightKey = GlobalKey(debugLabel: 'lowerRightKey');
await tester.pumpWidget(
WidgetsApp(
color: const Color(0xFFFFFFFF),
onGenerateRoute: (RouteSettings settings) {
return TestRoute(
child: Directionality(
textDirection: TextDirection.ltr,
child: FocusScope(
debugLabel: 'scope',
child: Column(
children: <Widget>[
Row(
children: <Widget>[
Focus(
autofocus: true,
debugLabel: 'upperLeft',
child: Container(width: 100, height: 100, key: upperLeftKey),
),
Focus(
debugLabel: 'upperRight',
child: Container(width: 100, height: 100, key: upperRightKey),
),
],
),
Row(
children: <Widget>[
Focus(
debugLabel: 'lowerLeft',
child: Container(width: 100, height: 100, key: lowerLeftKey),
),
Focus(
debugLabel: 'lowerRight',
child: Container(width: 100, height: 100, key: lowerRightKey),
),
],
),
],
),
),
),
);
},
),
);
// Initial focus happens.
expect(Focus.of(upperLeftKey.currentContext).hasPrimaryFocus, isTrue);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
expect(Focus.of(upperRightKey.currentContext).hasPrimaryFocus, isTrue);
// Initial focus happens.
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
expect(Focus.of(lowerLeftKey.currentContext).hasPrimaryFocus, isTrue);
// Initial focus happens.
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
expect(Focus.of(lowerRightKey.currentContext).hasPrimaryFocus, isTrue);
// Initial focus happens.
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
expect(Focus.of(upperLeftKey.currentContext).hasPrimaryFocus, isTrue);
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
expect(Focus.of(lowerRightKey.currentContext).hasPrimaryFocus, isTrue);
// Initial focus happens.
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
expect(Focus.of(lowerLeftKey.currentContext).hasPrimaryFocus, isTrue);
// Initial focus happens.
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
expect(Focus.of(upperRightKey.currentContext).hasPrimaryFocus, isTrue);
// Initial focus happens.
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
expect(Focus.of(upperLeftKey.currentContext).hasPrimaryFocus, isTrue);
// Traverse in a direction
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
expect(Focus.of(upperRightKey.currentContext).hasPrimaryFocus, isTrue);
// Initial focus happens.
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
expect(Focus.of(lowerRightKey.currentContext).hasPrimaryFocus, isTrue);
// Initial focus happens.
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
expect(Focus.of(lowerLeftKey.currentContext).hasPrimaryFocus, isTrue);
// Initial focus happens.
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
expect(Focus.of(upperLeftKey.currentContext).hasPrimaryFocus, isTrue);
});
});
}
class TestRoute extends PageRouteBuilder<void> {
TestRoute({Widget child})
: super(
pageBuilder: (BuildContext _, Animation<double> __, Animation<double> ___) {
return child;
},
);
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment