diff --git a/dev/manual_tests/lib/actions.dart b/dev/manual_tests/lib/actions.dart index a09d75450e6..b76fad18b11 100644 --- a/dev/manual_tests/lib/actions.dart +++ b/dev/manual_tests/lib/actions.dart @@ -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(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(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(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(DirectionalFocusAction); +class UndoableDirectionalFocusAction extends UndoableFocusActionBase { + UndoableDirectionalFocusAction() : super(DirectionalFocusAction.key); TraversalDirection direction; @@ -366,7 +352,7 @@ class _DemoButtonState extends State { 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,101 +420,91 @@ class _FocusDemoState extends State { @override Widget build(BuildContext context) { final TextTheme textTheme = Theme.of(context).textTheme; - return Shortcuts( - shortcuts: { - 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), + return Actions( + dispatcher: dispatcher, + actions: { + RequestFocusAction.key: () => UndoableRequestFocusAction(), + NextFocusAction.key: () => UndoableNextFocusAction(), + PreviousFocusAction.key: () => UndoablePreviousFocusAction(), + DirectionalFocusAction.key: () => UndoableDirectionalFocusAction(), + kUndoActionKey: () => kUndoAction, + kRedoActionKey: () => kRedoAction, }, - child: Actions( - dispatcher: dispatcher, - actions: { - SetFocusAction.key: () => SetFocusAction(), - NextFocusAction.key: () => NextFocusAction(), - PreviousFocusAction.key: () => PreviousFocusAction(), - DirectionalFocusAction.key: () => DirectionalFocusAction(), - kUndoActionKey: () => kUndoAction, - kRedoActionKey: () => kRedoAction, - }, - child: DefaultFocusTraversal( - policy: ReadingOrderTraversalPolicy(), - child: Shortcuts( - shortcuts: { - LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.keyZ): kRedoIntent, - LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyZ): kUndoIntent, - }, - child: FocusScope( - debugLabel: 'Scope', - autofocus: true, - child: DefaultTextStyle( - style: textTheme.display1, - child: Scaffold( - appBar: AppBar( - title: const Text('Actions Demo'), - ), - body: Center( - child: Builder(builder: (BuildContext context) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: const [ - DemoButton(name: 'One'), - DemoButton(name: 'Two'), - DemoButton(name: 'Three'), - ], - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: const [ - DemoButton(name: 'Four'), - DemoButton(name: 'Five'), - DemoButton(name: 'Six'), - ], - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: const [ - DemoButton(name: 'Seven'), - DemoButton(name: 'Eight'), - DemoButton(name: 'Nine'), - ], - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: RaisedButton( - child: const Text('UNDO'), - onPressed: canUndo - ? () { - Actions.invoke(context, kUndoIntent); - } - : null, - ), + child: DefaultFocusTraversal( + policy: ReadingOrderTraversalPolicy(), + child: Shortcuts( + shortcuts: { + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.keyZ): kRedoIntent, + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyZ): kUndoIntent, + }, + child: FocusScope( + debugLabel: 'Scope', + autofocus: true, + child: DefaultTextStyle( + style: textTheme.display1, + child: Scaffold( + appBar: AppBar( + title: const Text('Actions Demo'), + ), + body: Center( + child: Builder(builder: (BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + DemoButton(name: 'One'), + DemoButton(name: 'Two'), + DemoButton(name: 'Three'), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + DemoButton(name: 'Four'), + DemoButton(name: 'Five'), + DemoButton(name: 'Six'), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + DemoButton(name: 'Seven'), + DemoButton(name: 'Eight'), + DemoButton(name: 'Nine'), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + 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), - child: RaisedButton( - child: const Text('REDO'), - onPressed: canRedo - ? () { - Actions.invoke(context, kRedoIntent); - } - : null, - ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: RaisedButton( + child: const Text('REDO'), + onPressed: canRedo + ? () { + Actions.invoke(context, kRedoIntent); + } + : null, ), - ], - ), - ], - ); - }), - ), + ), + ], + ), + ], + ); + }), ), ), ), diff --git a/packages/flutter/lib/src/widgets/app.dart b/packages/flutter/lib/src/widgets/app.dart index d830082d00b..c923c1e4171 100644 --- a/packages/flutter/lib/src/widgets/app.dart +++ b/packages/flutter/lib/src/widgets/app.dart @@ -1199,11 +1199,21 @@ class _WidgetsAppState extends State implements WidgetsBindingObserv return Shortcuts( shortcuts: { + 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: { DoNothingAction.key: () => const DoNothingAction(), + RequestFocusAction.key: () => RequestFocusAction(), + NextFocusAction.key: () => NextFocusAction(), + PreviousFocusAction.key: () => PreviousFocusAction(), + DirectionalFocusAction.key: () => DirectionalFocusAction(), }, child: DefaultFocusTraversal( policy: ReadingOrderTraversalPolicy(), diff --git a/packages/flutter/lib/src/widgets/focus_traversal.dart b/packages/flutter/lib/src/widgets/focus_traversal.dart index e063d81a0ed..e0a85893432 100644 --- a/packages/flutter/lib/src/widgets/focus_traversal.dart +++ b/packages/flutter/lib/src/widgets/focus_traversal.dart @@ -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('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(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(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(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(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); + } +} diff --git a/packages/flutter/test/widgets/focus_traversal_test.dart b/packages/flutter/test/widgets/focus_traversal_test.dart index b2ce03bb47e..bfc7824b299 100644 --- a/packages/flutter/test/widgets/focus_traversal_test.dart +++ b/packages/flutter/test/widgets/focus_traversal_test.dart @@ -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: [ + Row( + children: [ + 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: [ + 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 { + TestRoute({Widget child}) + : super( + pageBuilder: (BuildContext _, Animation __, Animation ___) { + return child; + }, + ); +}