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 { ...@@ -247,8 +247,8 @@ abstract class UndoableAction extends Action {
} }
} }
class SetFocusActionBase extends UndoableAction { class UndoableFocusActionBase extends UndoableAction {
SetFocusActionBase(LocalKey name) : super(name); UndoableFocusActionBase(LocalKey name) : super(name);
FocusNode _previousFocus; FocusNode _previousFocus;
...@@ -286,10 +286,8 @@ class SetFocusActionBase extends UndoableAction { ...@@ -286,10 +286,8 @@ class SetFocusActionBase extends UndoableAction {
} }
} }
class SetFocusAction extends SetFocusActionBase { class UndoableRequestFocusAction extends UndoableFocusActionBase {
SetFocusAction() : super(key); UndoableRequestFocusAction() : super(RequestFocusAction.key);
static const LocalKey key = ValueKey<Type>(SetFocusAction);
@override @override
void invoke(FocusNode node, Intent intent) { void invoke(FocusNode node, Intent intent) {
...@@ -299,10 +297,8 @@ class SetFocusAction extends SetFocusActionBase { ...@@ -299,10 +297,8 @@ class SetFocusAction extends SetFocusActionBase {
} }
/// Actions for manipulating focus. /// Actions for manipulating focus.
class NextFocusAction extends SetFocusActionBase { class UndoableNextFocusAction extends UndoableFocusActionBase {
NextFocusAction() : super(key); UndoableNextFocusAction() : super(NextFocusAction.key);
static const LocalKey key = ValueKey<Type>(NextFocusAction);
@override @override
void invoke(FocusNode node, Intent intent) { void invoke(FocusNode node, Intent intent) {
...@@ -311,10 +307,8 @@ class NextFocusAction extends SetFocusActionBase { ...@@ -311,10 +307,8 @@ class NextFocusAction extends SetFocusActionBase {
} }
} }
class PreviousFocusAction extends SetFocusActionBase { class UndoablePreviousFocusAction extends UndoableFocusActionBase {
PreviousFocusAction() : super(key); UndoablePreviousFocusAction() : super(PreviousFocusAction.key);
static const LocalKey key = ValueKey<Type>(PreviousFocusAction);
@override @override
void invoke(FocusNode node, Intent intent) { void invoke(FocusNode node, Intent intent) {
...@@ -323,16 +317,8 @@ class PreviousFocusAction extends SetFocusActionBase { ...@@ -323,16 +317,8 @@ class PreviousFocusAction extends SetFocusActionBase {
} }
} }
class DirectionalFocusIntent extends Intent { class UndoableDirectionalFocusAction extends UndoableFocusActionBase {
const DirectionalFocusIntent(this.direction) : super(DirectionalFocusAction.key); UndoableDirectionalFocusAction() : super(DirectionalFocusAction.key);
final TraversalDirection direction;
}
class DirectionalFocusAction extends SetFocusActionBase {
DirectionalFocusAction() : super(key);
static const LocalKey key = ValueKey<Type>(DirectionalFocusAction);
TraversalDirection direction; TraversalDirection direction;
...@@ -366,7 +352,7 @@ class _DemoButtonState extends State<DemoButton> { ...@@ -366,7 +352,7 @@ class _DemoButtonState extends State<DemoButton> {
void _handleOnPressed() { void _handleOnPressed() {
print('Button ${widget.name} pressed.'); print('Button ${widget.name} pressed.');
setState(() { setState(() {
Actions.invoke(context, const Intent(SetFocusAction.key), focusNode: _focusNode); Actions.invoke(context, const Intent(RequestFocusAction.key), focusNode: _focusNode);
}); });
} }
...@@ -434,101 +420,91 @@ class _FocusDemoState extends State<FocusDemo> { ...@@ -434,101 +420,91 @@ class _FocusDemoState extends State<FocusDemo> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final TextTheme textTheme = Theme.of(context).textTheme; final TextTheme textTheme = Theme.of(context).textTheme;
return Shortcuts( return Actions(
shortcuts: <LogicalKeySet, Intent>{ dispatcher: dispatcher,
LogicalKeySet(LogicalKeyboardKey.tab): const Intent(NextFocusAction.key), actions: <LocalKey, ActionFactory>{
LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.tab): const Intent(PreviousFocusAction.key), RequestFocusAction.key: () => UndoableRequestFocusAction(),
LogicalKeySet(LogicalKeyboardKey.arrowUp): const DirectionalFocusIntent(TraversalDirection.up), NextFocusAction.key: () => UndoableNextFocusAction(),
LogicalKeySet(LogicalKeyboardKey.arrowDown): const DirectionalFocusIntent(TraversalDirection.down), PreviousFocusAction.key: () => UndoablePreviousFocusAction(),
LogicalKeySet(LogicalKeyboardKey.arrowLeft): const DirectionalFocusIntent(TraversalDirection.left), DirectionalFocusAction.key: () => UndoableDirectionalFocusAction(),
LogicalKeySet(LogicalKeyboardKey.arrowRight): const DirectionalFocusIntent(TraversalDirection.right), kUndoActionKey: () => kUndoAction,
kRedoActionKey: () => kRedoAction,
}, },
child: Actions( child: DefaultFocusTraversal(
dispatcher: dispatcher, policy: ReadingOrderTraversalPolicy(),
actions: <LocalKey, ActionFactory>{ child: Shortcuts(
SetFocusAction.key: () => SetFocusAction(), shortcuts: <LogicalKeySet, Intent>{
NextFocusAction.key: () => NextFocusAction(), LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.keyZ): kRedoIntent,
PreviousFocusAction.key: () => PreviousFocusAction(), LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyZ): kUndoIntent,
DirectionalFocusAction.key: () => DirectionalFocusAction(), },
kUndoActionKey: () => kUndoAction, child: FocusScope(
kRedoActionKey: () => kRedoAction, debugLabel: 'Scope',
}, autofocus: true,
child: DefaultFocusTraversal( child: DefaultTextStyle(
policy: ReadingOrderTraversalPolicy(), style: textTheme.display1,
child: Shortcuts( child: Scaffold(
shortcuts: <LogicalKeySet, Intent>{ appBar: AppBar(
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.keyZ): kRedoIntent, title: const Text('Actions Demo'),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyZ): kUndoIntent, ),
}, body: Center(
child: FocusScope( child: Builder(builder: (BuildContext context) {
debugLabel: 'Scope', return Column(
autofocus: true, mainAxisAlignment: MainAxisAlignment.center,
child: DefaultTextStyle( children: <Widget>[
style: textTheme.display1, Row(
child: Scaffold( mainAxisAlignment: MainAxisAlignment.center,
appBar: AppBar( children: const <Widget>[
title: const Text('Actions Demo'), DemoButton(name: 'One'),
), DemoButton(name: 'Two'),
body: Center( DemoButton(name: 'Three'),
child: Builder(builder: (BuildContext context) { ],
return Column( ),
mainAxisAlignment: MainAxisAlignment.center, Row(
children: <Widget>[ mainAxisAlignment: MainAxisAlignment.center,
Row( children: const <Widget>[
mainAxisAlignment: MainAxisAlignment.center, DemoButton(name: 'Four'),
children: const <Widget>[ DemoButton(name: 'Five'),
DemoButton(name: 'One'), DemoButton(name: 'Six'),
DemoButton(name: 'Two'), ],
DemoButton(name: 'Three'), ),
], Row(
), mainAxisAlignment: MainAxisAlignment.center,
Row( children: const <Widget>[
mainAxisAlignment: MainAxisAlignment.center, DemoButton(name: 'Seven'),
children: const <Widget>[ DemoButton(name: 'Eight'),
DemoButton(name: 'Four'), DemoButton(name: 'Nine'),
DemoButton(name: 'Five'), ],
DemoButton(name: 'Six'), ),
], Row(
), mainAxisAlignment: MainAxisAlignment.center,
Row( children: <Widget>[
mainAxisAlignment: MainAxisAlignment.center, Padding(
children: const <Widget>[ padding: const EdgeInsets.all(8.0),
DemoButton(name: 'Seven'), child: RaisedButton(
DemoButton(name: 'Eight'), child: const Text('UNDO'),
DemoButton(name: 'Nine'), onPressed: canUndo
], ? () {
), Actions.invoke(context, kUndoIntent);
Row( }
mainAxisAlignment: MainAxisAlignment.center, : null,
children: <Widget>[
Padding(
padding: const EdgeInsets.all(8.0),
child: RaisedButton(
child: const Text('UNDO'),
onPressed: canUndo
? () {
Actions.invoke(context, kUndoIntent);
}
: null,
),
), ),
Padding( ),
padding: const EdgeInsets.all(8.0), Padding(
child: RaisedButton( padding: const EdgeInsets.all(8.0),
child: const Text('REDO'), child: RaisedButton(
onPressed: canRedo child: const Text('REDO'),
? () { onPressed: canRedo
Actions.invoke(context, kRedoIntent); ? () {
} Actions.invoke(context, kRedoIntent);
: null, }
), : null,
), ),
], ),
), ],
], ),
); ],
}), );
), }),
), ),
), ),
), ),
......
...@@ -1199,11 +1199,21 @@ class _WidgetsAppState extends State<WidgetsApp> implements WidgetsBindingObserv ...@@ -1199,11 +1199,21 @@ class _WidgetsAppState extends State<WidgetsApp> implements WidgetsBindingObserv
return Shortcuts( return Shortcuts(
shortcuts: <LogicalKeySet, Intent>{ 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), LogicalKeySet(LogicalKeyboardKey.enter): const Intent(ActivateAction.key),
}, },
child: Actions( child: Actions(
actions: <LocalKey, ActionFactory>{ actions: <LocalKey, ActionFactory>{
DoNothingAction.key: () => const DoNothingAction(), DoNothingAction.key: () => const DoNothingAction(),
RequestFocusAction.key: () => RequestFocusAction(),
NextFocusAction.key: () => NextFocusAction(),
PreviousFocusAction.key: () => PreviousFocusAction(),
DirectionalFocusAction.key: () => DirectionalFocusAction(),
}, },
child: DefaultFocusTraversal( child: DefaultFocusTraversal(
policy: ReadingOrderTraversalPolicy(), policy: ReadingOrderTraversalPolicy(),
......
...@@ -2,9 +2,12 @@ ...@@ -2,9 +2,12 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:ui';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart'; import 'package:flutter/painting.dart';
import 'actions.dart';
import 'basic.dart'; import 'basic.dart';
import 'binding.dart'; import 'binding.dart';
import 'focus_manager.dart'; import 'focus_manager.dart';
...@@ -790,3 +793,138 @@ class DefaultFocusTraversal extends InheritedWidget { ...@@ -790,3 +793,138 @@ class DefaultFocusTraversal extends InheritedWidget {
@override @override
bool updateShouldNotify(DefaultFocusTraversal oldWidget) => policy != oldWidget.policy; 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 @@ ...@@ -2,6 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // 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_test/flutter_test.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
...@@ -914,5 +916,112 @@ void main() { ...@@ -914,5 +916,112 @@ void main() {
expect(focusCenter.hasFocus, isFalse); expect(focusCenter.hasFocus, isFalse);
expect(focusTop.hasFocus, isTrue); 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