From 3fd3447f9676b71d91ee81e32f335c702cef8e7c Mon Sep 17 00:00:00 2001 From: Kate Lovett Date: Tue, 5 Jan 2021 16:02:44 -0600 Subject: [PATCH] Add PrioritizedIntents to support multiple shortcut configurations (#72560) --- packages/flutter/lib/src/widgets/actions.dart | 47 +++++++++++ packages/flutter/lib/src/widgets/app.dart | 8 +- .../flutter/test/widgets/shortcuts_test.dart | 81 +++++++++++++++++++ 3 files changed, 135 insertions(+), 1 deletion(-) diff --git a/packages/flutter/lib/src/widgets/actions.dart b/packages/flutter/lib/src/widgets/actions.dart index 18afa116a3..3460a8b994 100644 --- a/packages/flutter/lib/src/widgets/actions.dart +++ b/packages/flutter/lib/src/widgets/actions.dart @@ -1321,3 +1321,50 @@ class DismissIntent extends Intent { /// /// This is an abstract class that serves as a base class for dismiss actions. abstract class DismissAction extends Action {} + +/// An [Intent] that evaluates a series of specified [orderedIntents] for +/// execution. +class PrioritizedIntents extends Intent { + /// Creates a set of const [PrioritizedIntents]. + const PrioritizedIntents({ + required this.orderedIntents, + }) : assert(orderedIntents != null); + + /// List of intents to be evaluated in order for execution. When an + /// [Action.isEnabled] returns true, that action will be invoked and + /// progression through the ordered intents stops. + final List orderedIntents; +} + +/// An [Action] that iterates through a list of [Intent]s, invoking the first +/// that is enabled. +class PrioritizedAction extends Action { + late Action _selectedAction; + late Intent _selectedIntent; + + @override + bool isEnabled(PrioritizedIntents intent) { + final FocusNode? focus = primaryFocus; + if (focus == null || focus.context == null) + return false; + for (final Intent candidateIntent in intent.orderedIntents) { + final Action? candidateAction = Actions.maybeFind( + focus.context!, + intent: candidateIntent, + ); + if (candidateAction != null && candidateAction.isEnabled(candidateIntent)) { + _selectedAction = candidateAction; + _selectedIntent = candidateIntent; + return true; + } + } + return false; + } + + @override + Object? invoke(PrioritizedIntents intent) { + assert(_selectedAction != null); + assert(_selectedIntent != null); + _selectedAction.invoke(_selectedIntent); + } +} diff --git a/packages/flutter/lib/src/widgets/app.dart b/packages/flutter/lib/src/widgets/app.dart index 083c8219c6..338ad88001 100644 --- a/packages/flutter/lib/src/widgets/app.dart +++ b/packages/flutter/lib/src/widgets/app.dart @@ -1024,7 +1024,12 @@ class WidgetsApp extends StatefulWidget { // Default shortcuts for the web platform. static final Map _defaultWebShortcuts = { // Activation - LogicalKeySet(LogicalKeyboardKey.space): const ActivateIntent(), + LogicalKeySet(LogicalKeyboardKey.space): const PrioritizedIntents( + orderedIntents: [ + ActivateIntent(), + ScrollIntent(direction: AxisDirection.down, type: ScrollIncrementType.page), + ] + ), // On the web, enter activates buttons, but not other controls. LogicalKeySet(LogicalKeyboardKey.enter): const ButtonActivateIntent(), @@ -1100,6 +1105,7 @@ class WidgetsApp extends StatefulWidget { PreviousFocusIntent: PreviousFocusAction(), DirectionalFocusIntent: DirectionalFocusAction(), ScrollIntent: ScrollAction(), + PrioritizedIntents: PrioritizedAction(), }; @override diff --git a/packages/flutter/test/widgets/shortcuts_test.dart b/packages/flutter/test/widgets/shortcuts_test.dart index d0947e4af4..f3c288476c 100644 --- a/packages/flutter/test/widgets/shortcuts_test.dart +++ b/packages/flutter/test/widgets/shortcuts_test.dart @@ -579,5 +579,86 @@ void main() { expect(description[0], equalsIgnoringHashCodes('manager: ShortcutManager#00000(shortcuts: {})')); expect(description[1], equalsIgnoringHashCodes('shortcuts: {{Key A + Key B}: ActivateIntent#00000}')); }); + + testWidgets('Shortcuts support multiple intents', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + bool? value = true; + Widget buildApp() { + return MaterialApp( + shortcuts: { + LogicalKeySet(LogicalKeyboardKey.space): const PrioritizedIntents( + orderedIntents: [ + ActivateIntent(), + ScrollIntent(direction: AxisDirection.down, type: ScrollIncrementType.page), + ] + ), + LogicalKeySet(LogicalKeyboardKey.tab): const NextFocusIntent(), + LogicalKeySet(LogicalKeyboardKey.pageUp): const ScrollIntent(direction: AxisDirection.up, type: ScrollIncrementType.page), + }, + home: Material( + child: Center( + child: ListView( + primary: true, + children: [ + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Checkbox( + value: value, + onChanged: (bool? newValue) => setState(() { value = newValue; }), + focusColor: Colors.orange[500], + ); + }, + ), + Container( + color: Colors.blue, + height: 1000, + ) + ], + ), + ), + ), + ); + } + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect( + tester.binding.focusManager.primaryFocus!.toStringShort(), + equalsIgnoringHashCodes('FocusScopeNode#00000(_ModalScopeState Focus Scope [PRIMARY FOCUS])'), + ); + final ScrollController controller = PrimaryScrollController.of( + tester.element(find.byType(ListView)) + )!; + expect(controller.position.pixels, 0.0); + expect(value, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + // ScrollView scrolls + expect(controller.position.pixels, 448.0); + expect(value, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.pageUp); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + // Focus is now on the checkbox. + expect( + tester.binding.focusManager.primaryFocus!.toStringShort(), + equalsIgnoringHashCodes('FocusNode#00000([PRIMARY FOCUS])'), + ); + expect(value, isTrue); + expect(controller.position.pixels, 0.0); + + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + // Checkbox is toggled, scroll view does not scroll. + expect(value, isFalse); + expect(controller.position.pixels, 0.0); + + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + expect(value, isTrue); + expect(controller.position.pixels, 0.0); + }); }); }