Unverified Commit 0f68b46f authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Revise Action API (#42940)

This updates the Action API in accordance with the design doc for the changes: flutter.dev/go/actions-and-shortcuts-design-revision

Fixes #53276
parent c663cd55
...@@ -2,6 +2,9 @@ ...@@ -2,6 +2,9 @@
// 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:collection';
import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
...@@ -14,6 +17,43 @@ void main() { ...@@ -14,6 +17,43 @@ void main() {
)); ));
} }
/// A class that can hold invocation information that an [UndoableAction] can
/// use to undo/redo itself.
///
/// Instances of this class are returned from [UndoableAction]s and placed on
/// the undo stack when they are invoked.
class Memento extends Object with DiagnosticableMixin implements Diagnosticable {
const Memento({
@required this.name,
@required this.undo,
@required this.redo,
});
/// Returns true if this Memento can be used to undo.
///
/// Subclasses could override to provide their own conditions when a command is
/// undoable.
bool get canUndo => true;
/// Returns true if this Memento can be used to redo.
///
/// Subclasses could override to provide their own conditions when a command is
/// redoable.
bool get canRedo => true;
final String name;
final VoidCallback undo;
final ValueGetter<Memento> redo;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(StringProperty('name', name));
properties.add(FlagProperty('undo', value: undo != null, ifTrue: 'undo'));
properties.add(FlagProperty('redo', value: redo != null, ifTrue: 'redo'));
}
}
/// Undoable Actions /// Undoable Actions
/// An [ActionDispatcher] subclass that manages the invocation of undoable /// An [ActionDispatcher] subclass that manages the invocation of undoable
...@@ -29,10 +69,10 @@ class UndoableActionDispatcher extends ActionDispatcher implements Listenable { ...@@ -29,10 +69,10 @@ class UndoableActionDispatcher extends ActionDispatcher implements Listenable {
// A stack of actions that have been performed. The most recent action // A stack of actions that have been performed. The most recent action
// performed is at the end of the list. // performed is at the end of the list.
final List<UndoableAction> _completedActions = <UndoableAction>[]; final DoubleLinkedQueue<Memento> _completedActions = DoubleLinkedQueue<Memento>();
// A stack of actions that can be redone. The most recent action performed is // A stack of actions that can be redone. The most recent action performed is
// at the end of the list. // at the end of the list.
final List<UndoableAction> _undoneActions = <UndoableAction>[]; final List<Memento> _undoneActions = <Memento>[];
static const int _defaultMaxUndoLevels = 1000; static const int _defaultMaxUndoLevels = 1000;
...@@ -71,11 +111,11 @@ class UndoableActionDispatcher extends ActionDispatcher implements Listenable { ...@@ -71,11 +111,11 @@ class UndoableActionDispatcher extends ActionDispatcher implements Listenable {
} }
@override @override
bool invokeAction(Action action, Intent intent, {FocusNode focusNode}) { Object invokeAction(Action<Intent> action, Intent intent, [BuildContext context]) {
final bool result = super.invokeAction(action, intent, focusNode: focusNode); final Object result = super.invokeAction(action, intent, context);
print('Invoking ${action is UndoableAction ? 'undoable ' : ''}$intent as $action: $this '); print('Invoking ${action is UndoableAction ? 'undoable ' : ''}$intent as $action: $this ');
if (action is UndoableAction) { if (action is UndoableAction) {
_completedActions.add(action); _completedActions.addLast(result as Memento);
_undoneActions.clear(); _undoneActions.clear();
_pruneActions(); _pruneActions();
notifyListeners(); notifyListeners();
...@@ -86,15 +126,14 @@ class UndoableActionDispatcher extends ActionDispatcher implements Listenable { ...@@ -86,15 +126,14 @@ class UndoableActionDispatcher extends ActionDispatcher implements Listenable {
// Enforces undo level limit. // Enforces undo level limit.
void _pruneActions() { void _pruneActions() {
while (_completedActions.length > _maxUndoLevels) { while (_completedActions.length > _maxUndoLevels) {
_completedActions.removeAt(0); _completedActions.removeFirst();
} }
} }
/// Returns true if there is an action on the stack that can be undone. /// Returns true if there is an action on the stack that can be undone.
bool get canUndo { bool get canUndo {
if (_completedActions.isNotEmpty) { if (_completedActions.isNotEmpty) {
final Intent lastIntent = _completedActions.last.invocationIntent; return _completedActions.first.canUndo;
return lastIntent.isEnabled(primaryFocus.context);
} }
return false; return false;
} }
...@@ -102,8 +141,7 @@ class UndoableActionDispatcher extends ActionDispatcher implements Listenable { ...@@ -102,8 +141,7 @@ class UndoableActionDispatcher extends ActionDispatcher implements Listenable {
/// Returns true if an action that has been undone can be re-invoked. /// Returns true if an action that has been undone can be re-invoked.
bool get canRedo { bool get canRedo {
if (_undoneActions.isNotEmpty) { if (_undoneActions.isNotEmpty) {
final Intent lastIntent = _undoneActions.last.invocationIntent; return _undoneActions.first.canRedo;
return lastIntent.isEnabled(primaryFocus?.context);
} }
return false; return false;
} }
...@@ -116,9 +154,9 @@ class UndoableActionDispatcher extends ActionDispatcher implements Listenable { ...@@ -116,9 +154,9 @@ class UndoableActionDispatcher extends ActionDispatcher implements Listenable {
if (!canUndo) { if (!canUndo) {
return false; return false;
} }
final UndoableAction action = _completedActions.removeLast(); final Memento memento = _completedActions.removeLast();
action.undo(); memento.undo();
_undoneActions.add(action); _undoneActions.add(memento);
notifyListeners(); notifyListeners();
return true; return true;
} }
...@@ -131,9 +169,9 @@ class UndoableActionDispatcher extends ActionDispatcher implements Listenable { ...@@ -131,9 +169,9 @@ class UndoableActionDispatcher extends ActionDispatcher implements Listenable {
if (!canRedo) { if (!canRedo) {
return false; return false;
} }
final UndoableAction action = _undoneActions.removeLast(); final Memento memento = _undoneActions.removeLast();
action.invoke(action.invocationNode, action.invocationIntent); final Memento replacement = memento.redo();
_completedActions.add(action); _completedActions.add(replacement);
_pruneActions(); _pruneActions();
notifyListeners(); notifyListeners();
return true; return true;
...@@ -144,71 +182,50 @@ class UndoableActionDispatcher extends ActionDispatcher implements Listenable { ...@@ -144,71 +182,50 @@ class UndoableActionDispatcher extends ActionDispatcher implements Listenable {
super.debugFillProperties(properties); super.debugFillProperties(properties);
properties.add(IntProperty('undoable items', _completedActions.length)); properties.add(IntProperty('undoable items', _completedActions.length));
properties.add(IntProperty('redoable items', _undoneActions.length)); properties.add(IntProperty('redoable items', _undoneActions.length));
properties.add(IterableProperty<UndoableAction>('undo stack', _completedActions)); properties.add(IterableProperty<Memento>('undo stack', _completedActions));
properties.add(IterableProperty<UndoableAction>('redo stack', _undoneActions)); properties.add(IterableProperty<Memento>('redo stack', _undoneActions));
} }
} }
class UndoIntent extends Intent { class UndoIntent extends Intent {
const UndoIntent() : super(kUndoActionKey); const UndoIntent();
}
class UndoAction extends Action<UndoIntent> {
@override @override
bool isEnabled(BuildContext context) { bool get enabled {
final UndoableActionDispatcher manager = Actions.of(context, nullOk: true) as UndoableActionDispatcher; final UndoableActionDispatcher manager = Actions.of(primaryFocus?.context ?? FocusDemo.appKey.currentContext, nullOk: true) as UndoableActionDispatcher;
return manager.canUndo; return manager.canUndo;
} }
@override
void invoke(UndoIntent intent) {
final UndoableActionDispatcher manager = Actions.of(primaryFocus?.context ?? FocusDemo.appKey.currentContext, nullOk: true) as UndoableActionDispatcher;
manager?.undo();
}
} }
class RedoIntent extends Intent { class RedoIntent extends Intent {
const RedoIntent() : super(kRedoActionKey); const RedoIntent();
}
class RedoAction extends Action<RedoIntent> {
@override @override
bool isEnabled(BuildContext context) { bool get enabled {
final UndoableActionDispatcher manager = Actions.of(context, nullOk: true) as UndoableActionDispatcher; final UndoableActionDispatcher manager = Actions.of(primaryFocus.context, nullOk: true) as UndoableActionDispatcher;
return manager.canRedo; return manager.canRedo;
} }
}
const LocalKey kUndoActionKey = ValueKey<String>('Undo'); @override
const Intent kUndoIntent = UndoIntent(); RedoAction invoke(RedoIntent intent) {
final Action kUndoAction = CallbackAction( final UndoableActionDispatcher manager = Actions.of(primaryFocus.context, nullOk: true) as UndoableActionDispatcher;
kUndoActionKey,
onInvoke: (FocusNode node, Intent tag) {
if (node?.context == null) {
return;
}
final UndoableActionDispatcher manager = Actions.of(node.context, nullOk: true) as UndoableActionDispatcher;
manager?.undo();
},
);
const LocalKey kRedoActionKey = ValueKey<String>('Redo');
const Intent kRedoIntent = RedoIntent();
final Action kRedoAction = CallbackAction(
kRedoActionKey,
onInvoke: (FocusNode node, Intent tag) {
if (node?.context == null) {
return;
}
final UndoableActionDispatcher manager = Actions.of(node.context, nullOk: true) as UndoableActionDispatcher;
manager?.redo(); manager?.redo();
}, return this;
); }
}
/// An action that can be undone. /// An action that can be undone.
abstract class UndoableAction extends Action { abstract class UndoableAction<T extends Intent> extends Action<T> {
/// A const constructor to [UndoableAction].
///
/// The [intentKey] parameter must not be null.
UndoableAction(LocalKey intentKey) : super(intentKey);
/// The node supplied when this command was invoked.
FocusNode get invocationNode => _invocationNode;
FocusNode _invocationNode;
@protected
set invocationNode(FocusNode value) => _invocationNode = value;
/// The [Intent] this action was originally invoked with. /// The [Intent] this action was originally invoked with.
Intent get invocationIntent => _invocationTag; Intent get invocationIntent => _invocationTag;
Intent _invocationTag; Intent _invocationTag;
...@@ -216,110 +233,63 @@ abstract class UndoableAction extends Action { ...@@ -216,110 +233,63 @@ abstract class UndoableAction extends Action {
@protected @protected
set invocationIntent(Intent value) => _invocationTag = value; set invocationIntent(Intent value) => _invocationTag = value;
/// Returns true if the data model can be returned to the state it was in
/// previous to this action being executed.
///
/// Default implementation returns true.
bool get undoable => true;
/// Reverts the data model to the state before this command executed.
@mustCallSuper
void undo();
@override @override
@mustCallSuper @mustCallSuper
void invoke(FocusNode node, Intent intent) { void invoke(T intent) {
invocationNode = node;
invocationIntent = intent; invocationIntent = intent;
} }
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<FocusNode>('invocationNode', invocationNode));
}
} }
class UndoableFocusActionBase extends UndoableAction { class UndoableFocusActionBase<T extends Intent> extends UndoableAction<T> {
UndoableFocusActionBase(LocalKey name) : super(name);
FocusNode _previousFocus;
@override
void invoke(FocusNode node, Intent intent) {
super.invoke(node, intent);
_previousFocus = primaryFocus;
node.requestFocus();
}
@override @override
void undo() { @mustCallSuper
if (_previousFocus == null) { Memento invoke(T intent) {
primaryFocus?.unfocus(); super.invoke(intent);
return; final FocusNode previousFocus = primaryFocus;
} return Memento(name: previousFocus.debugLabel, undo: () {
if (_previousFocus is FocusScopeNode) { previousFocus.requestFocus();
// The only way a scope can be the _previousFocus is if there was no }, redo: () {
// focusedChild for the scope when we invoked this action, so we need to return invoke(intent);
// return to that state. });
// Unfocus the current node to remove it from the focused child list of
// the scope.
primaryFocus?.unfocus();
// and then let the scope node be focused...
}
_previousFocus.requestFocus();
_previousFocus = null;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<FocusNode>('previous', _previousFocus));
} }
} }
class UndoableRequestFocusAction extends UndoableFocusActionBase { class UndoableRequestFocusAction extends UndoableFocusActionBase<RequestFocusIntent> {
UndoableRequestFocusAction() : super(RequestFocusAction.key);
@override @override
void invoke(FocusNode node, Intent intent) { Memento invoke(RequestFocusIntent intent) {
super.invoke(node, intent); final Memento memento = super.invoke(intent);
node.requestFocus(); intent.focusNode.requestFocus();
return memento;
} }
} }
/// Actions for manipulating focus. /// Actions for manipulating focus.
class UndoableNextFocusAction extends UndoableFocusActionBase { class UndoableNextFocusAction extends UndoableFocusActionBase<NextFocusIntent> {
UndoableNextFocusAction() : super(NextFocusAction.key);
@override @override
void invoke(FocusNode node, Intent intent) { Memento invoke(NextFocusIntent intent) {
super.invoke(node, intent); final Memento memento = super.invoke(intent);
node.nextFocus(); primaryFocus.nextFocus();
return memento;
} }
} }
class UndoablePreviousFocusAction extends UndoableFocusActionBase { class UndoablePreviousFocusAction extends UndoableFocusActionBase<PreviousFocusIntent> {
UndoablePreviousFocusAction() : super(PreviousFocusAction.key);
@override @override
void invoke(FocusNode node, Intent intent) { Memento invoke(PreviousFocusIntent intent) {
super.invoke(node, intent); final Memento memento = super.invoke(intent);
node.previousFocus(); primaryFocus.previousFocus();
return memento;
} }
} }
class UndoableDirectionalFocusAction extends UndoableFocusActionBase { class UndoableDirectionalFocusAction extends UndoableFocusActionBase<DirectionalFocusIntent> {
UndoableDirectionalFocusAction() : super(DirectionalFocusAction.key);
TraversalDirection direction; TraversalDirection direction;
@override @override
void invoke(FocusNode node, DirectionalFocusIntent intent) { Memento invoke(DirectionalFocusIntent intent) {
super.invoke(node, intent); final Memento memento = super.invoke(intent);
final DirectionalFocusIntent args = intent; primaryFocus.focusInDirection(intent.direction);
node.focusInDirection(args.direction); return memento;
} }
} }
...@@ -335,6 +305,7 @@ class DemoButton extends StatefulWidget { ...@@ -335,6 +305,7 @@ class DemoButton extends StatefulWidget {
class _DemoButtonState extends State<DemoButton> { class _DemoButtonState extends State<DemoButton> {
FocusNode _focusNode; FocusNode _focusNode;
final GlobalKey _nameKey = GlobalKey();
@override @override
void initState() { void initState() {
...@@ -345,7 +316,7 @@ class _DemoButtonState extends State<DemoButton> { ...@@ -345,7 +316,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(RequestFocusAction.key), focusNode: _focusNode); Actions.invoke(_nameKey.currentContext, RequestFocusIntent(_focusNode));
}); });
} }
...@@ -362,7 +333,7 @@ class _DemoButtonState extends State<DemoButton> { ...@@ -362,7 +333,7 @@ class _DemoButtonState extends State<DemoButton> {
focusColor: Colors.red, focusColor: Colors.red,
hoverColor: Colors.blue, hoverColor: Colors.blue,
onPressed: () => _handleOnPressed(), onPressed: () => _handleOnPressed(),
child: Text(widget.name), child: Text(widget.name, key: _nameKey),
); );
} }
} }
...@@ -370,6 +341,8 @@ class _DemoButtonState extends State<DemoButton> { ...@@ -370,6 +341,8 @@ class _DemoButtonState extends State<DemoButton> {
class FocusDemo extends StatefulWidget { class FocusDemo extends StatefulWidget {
const FocusDemo({Key key}) : super(key: key); const FocusDemo({Key key}) : super(key: key);
static GlobalKey appKey = GlobalKey();
@override @override
_FocusDemoState createState() => _FocusDemoState(); _FocusDemoState createState() => _FocusDemoState();
} }
...@@ -415,22 +388,23 @@ class _FocusDemoState extends State<FocusDemo> { ...@@ -415,22 +388,23 @@ class _FocusDemoState extends State<FocusDemo> {
final TextTheme textTheme = Theme.of(context).textTheme; final TextTheme textTheme = Theme.of(context).textTheme;
return Actions( return Actions(
dispatcher: dispatcher, dispatcher: dispatcher,
actions: <LocalKey, ActionFactory>{ actions: <Type, Action<Intent>>{
RequestFocusAction.key: () => UndoableRequestFocusAction(), RequestFocusIntent: UndoableRequestFocusAction(),
NextFocusAction.key: () => UndoableNextFocusAction(), NextFocusIntent: UndoableNextFocusAction(),
PreviousFocusAction.key: () => UndoablePreviousFocusAction(), PreviousFocusIntent: UndoablePreviousFocusAction(),
DirectionalFocusAction.key: () => UndoableDirectionalFocusAction(), DirectionalFocusIntent: UndoableDirectionalFocusAction(),
kUndoActionKey: () => kUndoAction, UndoIntent: UndoAction(),
kRedoActionKey: () => kRedoAction, RedoIntent: RedoAction(),
}, },
child: FocusTraversalGroup( child: FocusTraversalGroup(
policy: ReadingOrderTraversalPolicy(), policy: ReadingOrderTraversalPolicy(),
child: Shortcuts( child: Shortcuts(
shortcuts: <LogicalKeySet, Intent>{ shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.keyZ): kRedoIntent, LogicalKeySet(Platform.isMacOS ? LogicalKeyboardKey.meta : LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.keyZ): const RedoIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyZ): kUndoIntent, LogicalKeySet(Platform.isMacOS ? LogicalKeyboardKey.meta : LogicalKeyboardKey.control, LogicalKeyboardKey.keyZ): const UndoIntent(),
}, },
child: FocusScope( child: FocusScope(
key: FocusDemo.appKey,
debugLabel: 'Scope', debugLabel: 'Scope',
autofocus: true, autofocus: true,
child: DefaultTextStyle( child: DefaultTextStyle(
...@@ -477,7 +451,7 @@ class _FocusDemoState extends State<FocusDemo> { ...@@ -477,7 +451,7 @@ class _FocusDemoState extends State<FocusDemo> {
child: const Text('UNDO'), child: const Text('UNDO'),
onPressed: canUndo onPressed: canUndo
? () { ? () {
Actions.invoke(context, kUndoIntent); Actions.invoke(context, const UndoIntent());
} }
: null, : null,
), ),
...@@ -488,7 +462,7 @@ class _FocusDemoState extends State<FocusDemo> { ...@@ -488,7 +462,7 @@ class _FocusDemoState extends State<FocusDemo> {
child: const Text('REDO'), child: const Text('REDO'),
onPressed: canRedo onPressed: canRedo
? () { ? () {
Actions.invoke(context, kRedoIntent); Actions.invoke(context, const RedoIntent());
} }
: null, : null,
), ),
......
...@@ -213,7 +213,7 @@ class CupertinoApp extends StatefulWidget { ...@@ -213,7 +213,7 @@ class CupertinoApp extends StatefulWidget {
/// return WidgetsApp( /// return WidgetsApp(
/// shortcuts: <LogicalKeySet, Intent>{ /// shortcuts: <LogicalKeySet, Intent>{
/// ... WidgetsApp.defaultShortcuts, /// ... WidgetsApp.defaultShortcuts,
/// LogicalKeySet(LogicalKeyboardKey.select): const Intent(ActivateAction.key), /// LogicalKeySet(LogicalKeyboardKey.select): const ActivateIntent(),
/// }, /// },
/// color: const Color(0xFFFF0000), /// color: const Color(0xFFFF0000),
/// builder: (BuildContext context, Widget child) { /// builder: (BuildContext context, Widget child) {
...@@ -239,12 +239,12 @@ class CupertinoApp extends StatefulWidget { ...@@ -239,12 +239,12 @@ class CupertinoApp extends StatefulWidget {
/// ```dart /// ```dart
/// Widget build(BuildContext context) { /// Widget build(BuildContext context) {
/// return WidgetsApp( /// return WidgetsApp(
/// actions: <LocalKey, ActionFactory>{ /// actions: <Type, Action<Intent>>{
/// ... WidgetsApp.defaultActions, /// ... WidgetsApp.defaultActions,
/// ActivateAction.key: () => CallbackAction( /// ActivateAction: CallbackAction(
/// ActivateAction.key, /// onInvoke: (Intent intent) {
/// onInvoke: (FocusNode focusNode, Intent intent) {
/// // Do something here... /// // Do something here...
/// return null;
/// }, /// },
/// ), /// ),
/// }, /// },
...@@ -257,7 +257,7 @@ class CupertinoApp extends StatefulWidget { ...@@ -257,7 +257,7 @@ class CupertinoApp extends StatefulWidget {
/// ``` /// ```
/// {@end-tool} /// {@end-tool}
/// {@macro flutter.widgets.widgetsApp.actions.seeAlso} /// {@macro flutter.widgets.widgetsApp.actions.seeAlso}
final Map<LocalKey, ActionFactory> actions; final Map<Type, Action<Intent>> actions;
@override @override
_CupertinoAppState createState() => _CupertinoAppState(); _CupertinoAppState createState() => _CupertinoAppState();
......
...@@ -183,9 +183,9 @@ class ChangeNotifier implements Listenable { ...@@ -183,9 +183,9 @@ class ChangeNotifier implements Listenable {
/// Call all the registered listeners. /// Call all the registered listeners.
/// ///
/// Call this method whenever the object changes, to notify any clients the /// Call this method whenever the object changes, to notify any clients the
/// object may have. Listeners that are added during this iteration will not /// object may have changed. Listeners that are added during this iteration
/// be visited. Listeners that are removed during this iteration will not be /// will not be visited. Listeners that are removed during this iteration will
/// visited after they are removed. /// not be visited after they are removed.
/// ///
/// Exceptions thrown by listeners will be caught and reported using /// Exceptions thrown by listeners will be caught and reported using
/// [FlutterError.reportError]. /// [FlutterError.reportError].
......
...@@ -476,7 +476,7 @@ class MaterialApp extends StatefulWidget { ...@@ -476,7 +476,7 @@ class MaterialApp extends StatefulWidget {
/// return WidgetsApp( /// return WidgetsApp(
/// shortcuts: <LogicalKeySet, Intent>{ /// shortcuts: <LogicalKeySet, Intent>{
/// ... WidgetsApp.defaultShortcuts, /// ... WidgetsApp.defaultShortcuts,
/// LogicalKeySet(LogicalKeyboardKey.select): const Intent(ActivateAction.key), /// LogicalKeySet(LogicalKeyboardKey.select): const ActivateIntent(),
/// }, /// },
/// color: const Color(0xFFFF0000), /// color: const Color(0xFFFF0000),
/// builder: (BuildContext context, Widget child) { /// builder: (BuildContext context, Widget child) {
...@@ -502,12 +502,12 @@ class MaterialApp extends StatefulWidget { ...@@ -502,12 +502,12 @@ class MaterialApp extends StatefulWidget {
/// ```dart /// ```dart
/// Widget build(BuildContext context) { /// Widget build(BuildContext context) {
/// return WidgetsApp( /// return WidgetsApp(
/// actions: <LocalKey, ActionFactory>{ /// actions: <Type, Action<Intent>>{
/// ... WidgetsApp.defaultActions, /// ... WidgetsApp.defaultActions,
/// ActivateAction.key: () => CallbackAction( /// ActivateAction: CallbackAction(
/// ActivateAction.key, /// onInvoke: (Intent intent) {
/// onInvoke: (FocusNode focusNode, Intent intent) {
/// // Do something here... /// // Do something here...
/// return null;
/// }, /// },
/// ), /// ),
/// }, /// },
...@@ -520,7 +520,7 @@ class MaterialApp extends StatefulWidget { ...@@ -520,7 +520,7 @@ class MaterialApp extends StatefulWidget {
/// ``` /// ```
/// {@end-tool} /// {@end-tool}
/// {@macro flutter.widgets.widgetsApp.actions.seeAlso} /// {@macro flutter.widgets.widgetsApp.actions.seeAlso}
final Map<LocalKey, ActionFactory> actions; final Map<Type, Action<Intent>> actions;
/// Turns on a [GridPaper] overlay that paints a baseline grid /// Turns on a [GridPaper] overlay that paints a baseline grid
/// Material apps. /// Material apps.
......
...@@ -169,17 +169,17 @@ class Checkbox extends StatefulWidget { ...@@ -169,17 +169,17 @@ class Checkbox extends StatefulWidget {
class _CheckboxState extends State<Checkbox> with TickerProviderStateMixin { class _CheckboxState extends State<Checkbox> with TickerProviderStateMixin {
bool get enabled => widget.onChanged != null; bool get enabled => widget.onChanged != null;
Map<LocalKey, ActionFactory> _actionMap; Map<Type, Action<Intent>> _actionMap;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_actionMap = <LocalKey, ActionFactory>{ _actionMap = <Type, Action<Intent>>{
ActivateAction.key: _createAction, ActivateIntent: CallbackAction<ActivateIntent>(onInvoke: _actionHandler),
}; };
} }
void _actionHandler(FocusNode node, Intent intent){ void _actionHandler(ActivateIntent intent) {
if (widget.onChanged != null) { if (widget.onChanged != null) {
switch (widget.value) { switch (widget.value) {
case false: case false:
...@@ -193,17 +193,10 @@ class _CheckboxState extends State<Checkbox> with TickerProviderStateMixin { ...@@ -193,17 +193,10 @@ class _CheckboxState extends State<Checkbox> with TickerProviderStateMixin {
break; break;
} }
} }
final RenderObject renderObject = node.context.findRenderObject(); final RenderObject renderObject = context.findRenderObject();
renderObject.sendSemanticsEvent(const TapSemanticEvent()); renderObject.sendSemanticsEvent(const TapSemanticEvent());
} }
Action _createAction() {
return CallbackAction(
ActivateAction.key,
onInvoke: _actionHandler,
);
}
bool _focused = false; bool _focused = false;
void _handleFocusHighlightChanged(bool focused) { void _handleFocusHighlightChanged(bool focused) {
if (focused != _focused) { if (focused != _focused) {
......
...@@ -158,7 +158,7 @@ class _DropdownMenuItemButtonState<T> extends State<_DropdownMenuItemButton<T>> ...@@ -158,7 +158,7 @@ class _DropdownMenuItemButtonState<T> extends State<_DropdownMenuItemButton<T>>
} }
static final Map<LogicalKeySet, Intent> _webShortcuts =<LogicalKeySet, Intent>{ static final Map<LogicalKeySet, Intent> _webShortcuts =<LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.enter): const Intent(ActivateAction.key), LogicalKeySet(LogicalKeyboardKey.enter): const ActivateIntent(),
}; };
@override @override
...@@ -1080,7 +1080,7 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi ...@@ -1080,7 +1080,7 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi
FocusNode _internalNode; FocusNode _internalNode;
FocusNode get focusNode => widget.focusNode ?? _internalNode; FocusNode get focusNode => widget.focusNode ?? _internalNode;
bool _hasPrimaryFocus = false; bool _hasPrimaryFocus = false;
Map<LocalKey, ActionFactory> _actionMap; Map<Type, Action<Intent>> _actionMap;
FocusHighlightMode _focusHighlightMode; FocusHighlightMode _focusHighlightMode;
// Only used if needed to create _internalNode. // Only used if needed to create _internalNode.
...@@ -1095,8 +1095,10 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi ...@@ -1095,8 +1095,10 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi
if (widget.focusNode == null) { if (widget.focusNode == null) {
_internalNode ??= _createFocusNode(); _internalNode ??= _createFocusNode();
} }
_actionMap = <LocalKey, ActionFactory>{ _actionMap = <Type, Action<Intent>>{
ActivateAction.key: _createAction, ActivateIntent: CallbackAction<ActivateIntent>(
onInvoke: (ActivateIntent intent) => _handleTap(),
),
}; };
focusNode.addListener(_handleFocusChanged); focusNode.addListener(_handleFocusChanged);
final FocusManager focusManager = WidgetsBinding.instance.focusManager; final FocusManager focusManager = WidgetsBinding.instance.focusManager;
...@@ -1225,15 +1227,6 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi ...@@ -1225,15 +1227,6 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi
} }
} }
Action _createAction() {
return CallbackAction(
ActivateAction.key,
onInvoke: (FocusNode node, Intent intent) {
_handleTap();
},
);
}
// When isDense is true, reduce the height of this button from _kMenuItemHeight to // When isDense is true, reduce the height of this button from _kMenuItemHeight to
// _kDenseButtonHeight, but don't make it smaller than the text that it contains. // _kDenseButtonHeight, but don't make it smaller than the text that it contains.
// Similarly, we don't reduce the height of the button so much that its icon // Similarly, we don't reduce the height of the button so much that its icon
......
...@@ -559,27 +559,20 @@ class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKe ...@@ -559,27 +559,20 @@ class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKe
InteractiveInkFeature _currentSplash; InteractiveInkFeature _currentSplash;
bool _hovering = false; bool _hovering = false;
final Map<_HighlightType, InkHighlight> _highlights = <_HighlightType, InkHighlight>{}; final Map<_HighlightType, InkHighlight> _highlights = <_HighlightType, InkHighlight>{};
Map<LocalKey, ActionFactory> _actionMap; Map<Type, Action<Intent>> _actionMap;
bool get highlightsExist => _highlights.values.where((InkHighlight highlight) => highlight != null).isNotEmpty; bool get highlightsExist => _highlights.values.where((InkHighlight highlight) => highlight != null).isNotEmpty;
void _handleAction(FocusNode node, Intent intent) { void _handleAction(ActivateIntent intent) {
_startSplash(context: node.context); _startSplash(context: context);
_handleTap(node.context); _handleTap(context);
}
Action _createAction() {
return CallbackAction(
ActivateAction.key,
onInvoke: _handleAction,
);
} }
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_actionMap = <LocalKey, ActionFactory>{ _actionMap = <Type, Action<Intent>>{
ActivateAction.key: _createAction, ActivateIntent: CallbackAction<ActivateIntent>(onInvoke: _handleAction),
}; };
FocusManager.instance.addHighlightModeListener(_handleFocusHighlightModeChange); FocusManager.instance.addHighlightModeListener(_handleFocusHighlightModeChange);
} }
......
...@@ -262,31 +262,26 @@ class Radio<T> extends StatefulWidget { ...@@ -262,31 +262,26 @@ class Radio<T> extends StatefulWidget {
class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin { class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin {
bool get enabled => widget.onChanged != null; bool get enabled => widget.onChanged != null;
Map<LocalKey, ActionFactory> _actionMap; Map<Type, Action<Intent>> _actionMap;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_actionMap = <LocalKey, ActionFactory>{ _actionMap = <Type, Action<Intent>>{
ActivateAction.key: _createAction, ActivateIntent: CallbackAction<ActivateIntent>(
onInvoke: _actionHandler,
),
}; };
} }
void _actionHandler(FocusNode node, Intent intent) { void _actionHandler(ActivateIntent intent) {
if (widget.onChanged != null) { if (widget.onChanged != null) {
widget.onChanged(widget.value); widget.onChanged(widget.value);
} }
final RenderObject renderObject = node.context.findRenderObject(); final RenderObject renderObject = context.findRenderObject();
renderObject.sendSemanticsEvent(const TapSemanticEvent()); renderObject.sendSemanticsEvent(const TapSemanticEvent());
} }
Action _createAction() {
return CallbackAction(
ActivateAction.key,
onInvoke: _actionHandler,
);
}
bool _focused = false; bool _focused = false;
void _handleHighlightChanged(bool focused) { void _handleHighlightChanged(bool focused) {
if (_focused != focused) { if (_focused != focused) {
......
...@@ -230,31 +230,24 @@ class Switch extends StatefulWidget { ...@@ -230,31 +230,24 @@ class Switch extends StatefulWidget {
} }
class _SwitchState extends State<Switch> with TickerProviderStateMixin { class _SwitchState extends State<Switch> with TickerProviderStateMixin {
Map<LocalKey, ActionFactory> _actionMap; Map<Type, Action<Intent>> _actionMap;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_actionMap = <LocalKey, ActionFactory>{ _actionMap = <Type, Action<Intent>>{
ActivateAction.key: _createAction, ActivateIntent: CallbackAction<ActivateIntent>(onInvoke: _actionHandler),
}; };
} }
void _actionHandler(FocusNode node, Intent intent){ void _actionHandler(ActivateIntent intent) {
if (widget.onChanged != null) { if (widget.onChanged != null) {
widget.onChanged(!widget.value); widget.onChanged(!widget.value);
} }
final RenderObject renderObject = node.context.findRenderObject(); final RenderObject renderObject = context.findRenderObject();
renderObject.sendSemanticsEvent(const TapSemanticEvent()); renderObject.sendSemanticsEvent(const TapSemanticEvent());
} }
Action _createAction() {
return CallbackAction(
ActivateAction.key,
onInvoke: _actionHandler,
);
}
bool _focused = false; bool _focused = false;
void _handleFocusHighlightChanged(bool focused) { void _handleFocusHighlightChanged(bool focused) {
if (focused != _focused) { if (focused != _focused) {
......
...@@ -78,7 +78,7 @@ export 'package:flutter/services.dart' show SmartQuotesType, SmartDashesType; ...@@ -78,7 +78,7 @@ export 'package:flutter/services.dart' show SmartQuotesType, SmartDashesType;
/// shortcuts: <LogicalKeySet, Intent>{ /// shortcuts: <LogicalKeySet, Intent>{
/// // Pressing enter on the field will now move to the next field. /// // Pressing enter on the field will now move to the next field.
/// LogicalKeySet(LogicalKeyboardKey.enter): /// LogicalKeySet(LogicalKeyboardKey.enter):
/// Intent(NextFocusAction.key), /// NextFocusIntent(),
/// }, /// },
/// child: FocusTraversalGroup( /// child: FocusTraversalGroup(
/// child: Form( /// child: Form(
......
...@@ -12,48 +12,44 @@ import 'focus_scope.dart'; ...@@ -12,48 +12,44 @@ import 'focus_scope.dart';
import 'framework.dart'; import 'framework.dart';
import 'shortcuts.dart'; import 'shortcuts.dart';
/// Creates actions for use in defining shortcuts. // BuildContext/Element doesn't have a parent accessor, but it can be
/// // simulated with visitAncestorElements. _getParent is needed because
/// Used by clients of [ShortcutMap] to define shortcut maps. // context.getElementForInheritedWidgetOfExactType will return itself if it
typedef ActionFactory = Action Function(); // happens to be of the correct type. getParent should be O(1), since we
// always return false at the first ancestor.
BuildContext _getParent(BuildContext context) {
BuildContext parent;
context.visitAncestorElements((Element ancestor) {
parent = ancestor;
return false;
});
return parent;
}
/// A class representing a particular configuration of an action. /// A class representing a particular configuration of an action.
/// ///
/// This class is what a key map in a [ShortcutMap] has as values, and is used /// This class is what a key map in a [ShortcutMap] has as values, and is used
/// by an [ActionDispatcher] to look up an action and invoke it, giving it this /// by an [ActionDispatcher] to look up an action and invoke it, giving it this
/// object to extract configuration information from. /// object to extract configuration information from.
/// @immutable
/// If this intent returns false from [isEnabled], then its associated action will
/// not be invoked if requested.
class Intent with Diagnosticable { class Intent with Diagnosticable {
/// A const constructor for an [Intent]. /// A const constructor for an [Intent].
/// const Intent();
/// The [key] argument must not be null.
const Intent(this.key) : assert(key != null);
/// An intent that can't be mapped to an action. /// An intent that can't be mapped to an action.
/// ///
/// This Intent is mapped to an action in the [WidgetsApp] that does nothing, /// This Intent is mapped to an action in the [WidgetsApp] that does nothing,
/// so that it can be bound to a key in a [Shortcuts] widget in order to /// so that it can be bound to a key in a [Shortcuts] widget in order to
/// disable a key binding made above it in the hierarchy. /// disable a key binding made above it in the hierarchy.
static const Intent doNothing = Intent(DoNothingAction.key); static const DoNothingIntent doNothing = DoNothingIntent._();
/// The key for the action this intent is associated with.
final LocalKey key;
/// Returns true if the associated action is able to be executed in the
/// given `context`.
///
/// Returns true by default.
bool isEnabled(BuildContext context) => true;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<LocalKey>('key', key));
}
} }
/// The kind of callback that an [Action] uses to notify of changes to the
/// action's state.
///
/// To register an action listener, call [Action.addActionListener].
typedef ActionListenerCallback = void Function(Action<Intent> action);
/// Base class for actions. /// Base class for actions.
/// ///
/// As the name implies, an [Action] is an action or command to be performed. /// As the name implies, an [Action] is an action or command to be performed.
...@@ -71,46 +67,266 @@ class Intent with Diagnosticable { ...@@ -71,46 +67,266 @@ class Intent with Diagnosticable {
/// up key combinations in order to invoke actions. /// up key combinations in order to invoke actions.
/// * [Actions], which is a widget that defines a map of [Intent] to [Action] /// * [Actions], which is a widget that defines a map of [Intent] to [Action]
/// and allows redefining of actions for its descendants. /// and allows redefining of actions for its descendants.
/// * [ActionDispatcher], a class that takes an [Action] and invokes it using a /// * [ActionDispatcher], a class that takes an [Action] and invokes it, passing
/// [FocusNode] for context. /// a given [Intent].
abstract class Action with Diagnosticable { abstract class Action<T extends Intent> with Diagnosticable {
/// A const constructor for an [Action]. final ObserverList<ActionListenerCallback> _listeners = ObserverList<ActionListenerCallback>();
///
/// The [intentKey] parameter must not be null. /// Gets the type of intent this action responds to.
const Action(this.intentKey) : assert(intentKey != null); Type get intentType => T;
/// The unique key for this action. /// Returns true if the action is enabled and is ready to be invoked.
/// ///
/// This key will be used to map to this action in an [ActionDispatcher]. /// This will be called by the [ActionDispatcher] before attempting to invoke
final LocalKey intentKey; /// the action.
///
/// If the enabled state changes, overriding subclasses must call
/// [notifyActionListeners] to notify any listeners of the change.
bool get enabled => true;
/// Called when the action is to be performed. /// Called when the action is to be performed.
/// ///
/// This is called by the [ActionDispatcher] when an action is accepted by a /// This is called by the [ActionDispatcher] when an action is invoked via
/// [FocusNode] by returning true from its `onAction` callback, or when an /// [Actions.invoke], or when an action is invoked using
/// action is invoked using [ActionDispatcher.invokeAction]. /// [ActionDispatcher.invokeAction] directly.
/// ///
/// This method is only meant to be invoked by an [ActionDispatcher], or by /// This method is only meant to be invoked by an [ActionDispatcher], or by
/// subclasses. /// its subclasses, and only when [enabled] is true.
///
/// When overriding this method, the returned value can be any Object, but
/// changing the return type of the override to match the type of the returned
/// value provides more type safety.
///
/// For instance, if your override of `invoke` returns an `int`, then define
/// it like so:
/// ///
/// Actions invoked directly with [ActionDispatcher.invokeAction] may receive a /// ```dart
/// null `node`. If the information available from a focus node is /// class IncrementIntent extends Intent {
/// needed in the action, use [ActionDispatcher.invokeFocusedAction] instead. /// const IncrementIntent({this.index});
///
/// final int index;
/// }
///
/// class MyIncrementAction extends Action<IncrementIntent> {
/// @override
/// int invoke(IncrementIntent intent) {
/// return intent.index + 1;
/// }
/// }
/// ```
@protected @protected
void invoke(FocusNode node, covariant Intent intent); Object invoke(covariant T intent);
/// Register a callback to listen for changes to the state of this action.
///
/// If you call this, you must call [removeActionListener] a matching number
/// of times, or memory leaks will occur. To help manage this and avoid memory
/// leaks, use of the [ActionListener] widget to register and unregister your
/// listener appropriately is highly recommended.
///
/// {@template flutter.widgets.actions.multipleAdds}
/// If a listener had been added twice, and is removed once during an
/// iteration (i.e. in response to a notification), it will still be called
/// again. If, on the other hand, it is removed as many times as it was
/// registered, then it will no longer be called. This odd behavior is the
/// result of the [Action] not being able to determine which listener
/// is being removed, since they are identical, and therefore conservatively
/// still calling all the listeners when it knows that any are still
/// registered.
///
/// This surprising behavior can be unexpectedly observed when registering a
/// listener on two separate objects which are both forwarding all
/// registrations to a common upstream object.
/// {@endtemplate}
@mustCallSuper
void addActionListener(ActionListenerCallback listener) => _listeners.add(listener);
/// Remove a previously registered closure from the list of closures that are
/// notified when the object changes.
///
/// If the given listener is not registered, the call is ignored.
///
/// If you call [addActionListener], you must call this method a matching
/// number of times, or memory leaks will occur. To help manage this and avoid
/// memory leaks, use of the [ActionListener] widget to register and
/// unregister your listener appropriately is highly recommended.
///
/// {@macro flutter.widgets.actions.multipleAdds}
@mustCallSuper
void removeActionListener(ActionListenerCallback listener) => _listeners.remove(listener);
/// Call all the registered listeners.
///
/// Subclasses should call this method whenever the object changes, to notify
/// any clients the object may have changed. Listeners that are added during this
/// iteration will not be visited. Listeners that are removed during this
/// iteration will not be visited after they are removed.
///
/// Exceptions thrown by listeners will be caught and reported using
/// [FlutterError.reportError].
///
/// Surprising behavior can result when reentrantly removing a listener (i.e.
/// in response to a notification) that has been registered multiple times.
/// See the discussion at [removeActionListener].
@protected
@visibleForTesting
void notifyActionListeners() {
if (_listeners.isEmpty) {
return;
}
// Make a local copy so that a listener can unregister while the list is
// being iterated over.
final List<ActionListenerCallback> localListeners = List<ActionListenerCallback>.from(_listeners);
for (final ActionListenerCallback listener in localListeners) {
InformationCollector collector;
assert(() {
collector = () sync* {
yield DiagnosticsProperty<Action<T>>(
'The $runtimeType sending notification was',
this,
style: DiagnosticsTreeStyle.errorProperty,
);
};
return true;
}());
try {
if (_listeners.contains(listener)) {
listener(this);
}
} catch (exception, stack) {
FlutterError.reportError(FlutterErrorDetails(
exception: exception,
stack: stack,
library: 'widgets library',
context: ErrorDescription('while dispatching notifications for $runtimeType'),
informationCollector: collector,
));
}
}
}
}
/// A helper widget for making sure that listeners on an action are removed properly.
///
/// Listeners on the [Action] class must have their listener callbacks removed
/// with [Action.removeActionListener] when the listener is disposed of. This widget
/// helps with that, by providing a lifetime for the connection between the
/// [listener] and the [Action], and by handling the adding and removing of
/// the [listener] at the right points in the widget lifecycle.
///
/// If you listen to an [Action] widget in a widget hierarchy, you should use
/// this widget. If you are using an [Action] outside of a widget context, then
/// you must call removeListener yourself.
@immutable
class ActionListener extends StatefulWidget {
/// Create a const [ActionListener].
///
/// The [listener], [action], and [child] arguments must not be null.
const ActionListener({
Key key,
@required this.listener,
@required this.action,
@required this.child,
}) : assert(listener != null),
assert(action != null),
assert(child != null),
super(key: key);
/// The [ActionListenerCallback] callback to register with the [action].
///
/// Must not be null.
final ActionListenerCallback listener;
/// The [Action] that the callback will be registered with.
///
/// Must not be null.
final Action<Intent> action;
/// {@macro flutter.widgets.child}
final Widget child;
@override @override
void debugFillProperties(DiagnosticPropertiesBuilder properties) { _ActionListenerState createState() => _ActionListenerState();
super.debugFillProperties(properties); }
properties.add(DiagnosticsProperty<LocalKey>('intentKey', intentKey));
class _ActionListenerState extends State<ActionListener> {
@override
void initState() {
super.initState();
widget.action.addActionListener(widget.listener);
} }
@override
void didUpdateWidget(ActionListener oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.action == widget.action && oldWidget.listener == widget.listener) {
return;
}
oldWidget.action.removeActionListener(oldWidget.listener);
widget.action.addActionListener(widget.listener);
}
@override
void dispose() {
widget.action.removeActionListener(widget.listener);
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
}
/// An abstract [Action] subclass that adds an optional [BuildContext] to the
/// [invoke] method to be able to provide context to actions.
///
/// [ActionDispatcher.invokeAction] checks to see if the action it is invoking
/// is a [ContextAction], and if it is, supplies it with a context.
abstract class ContextAction<T extends Intent> extends Action<T> {
/// Called when the action is to be performed.
///
/// This is called by the [ActionDispatcher] when an action is invoked via
/// [Actions.invoke], or when an action is invoked using
/// [ActionDispatcher.invokeAction] directly.
///
/// This method is only meant to be invoked by an [ActionDispatcher], or by
/// its subclasses, and only when [enabled] is true.
///
/// The optional `context` parameter is the context of the invocation of the
/// action, and in the case of an action invoked by a [ShortcutsManager], via
/// a [Shortcuts] widget, will be the context of the [Shortcuts] widget.
///
/// When overriding this method, the returned value can be any Object, but
/// changing the return type of the override to match the type of the returned
/// value provides more type safety.
///
/// For instance, if your override of `invoke` returns an `int`, then define
/// it like so:
///
/// ```dart
/// class IncrementIntent extends Intent {
/// const IncrementIntent({this.index});
///
/// final int index;
/// }
///
/// class MyIncrementAction extends ContextAction<IncrementIntent> {
/// @override
/// int invoke(IncrementIntent intent, [BuildContext context]) {
/// return intent.index + 1;
/// }
/// }
/// ```
@protected
@override
Object invoke(covariant T intent, [BuildContext context]);
} }
/// The signature of a callback accepted by [CallbackAction]. /// The signature of a callback accepted by [CallbackAction].
typedef OnInvokeCallback = void Function(FocusNode node, Intent tag); typedef OnInvokeCallback<T extends Intent> = Object Function(T intent);
/// An [Action] that takes a callback in order to configure it without having to /// An [Action] that takes a callback in order to configure it without having to
/// subclass it. /// create an explicit [Action] subclass just to call a callback.
/// ///
/// See also: /// See also:
/// ///
...@@ -120,48 +336,57 @@ typedef OnInvokeCallback = void Function(FocusNode node, Intent tag); ...@@ -120,48 +336,57 @@ typedef OnInvokeCallback = void Function(FocusNode node, Intent tag);
/// and allows redefining of actions for its descendants. /// and allows redefining of actions for its descendants.
/// * [ActionDispatcher], a class that takes an [Action] and invokes it using a /// * [ActionDispatcher], a class that takes an [Action] and invokes it using a
/// [FocusNode] for context. /// [FocusNode] for context.
class CallbackAction extends Action { class CallbackAction<T extends Intent> extends Action<T> {
/// A const constructor for an [Action]. /// A constructor for a [CallbackAction].
/// ///
/// The `intentKey` and [onInvoke] parameters must not be null. /// The `intentKey` and [onInvoke] parameters must not be null.
/// The [onInvoke] parameter is required. /// The [onInvoke] parameter is required.
const CallbackAction(LocalKey intentKey, {@required this.onInvoke}) CallbackAction({@required this.onInvoke}) : assert(onInvoke != null);
: assert(onInvoke != null),
super(intentKey);
/// The callback to be called when invoked. /// The callback to be called when invoked.
/// ///
/// Must not be null. /// Must not be null.
@protected @protected
final OnInvokeCallback onInvoke; final OnInvokeCallback<T> onInvoke;
@override @override
void invoke(FocusNode node, Intent intent) => onInvoke.call(node, intent); Object invoke(covariant T intent) => onInvoke(intent);
} }
/// An action manager that simply invokes the actions given to it. /// An action dispatcher that simply invokes the actions given to it.
///
/// See also:
///
/// - [ShortcutManager], that uses this class to invoke actions.
/// - [Shortcuts] widget, which defines key mappings to [Intent]s.
/// - [Actions] widget, which defines a mapping between a in [Intent] type and
/// an [Action].
class ActionDispatcher with Diagnosticable { class ActionDispatcher with Diagnosticable {
/// Const constructor so that subclasses can be const. /// Const constructor so that subclasses can be immutable.
const ActionDispatcher(); const ActionDispatcher();
/// Invokes the given action, optionally without regard for the currently /// Invokes the given `action`, passing it the given `intent`.
/// focused node in the focus tree.
///
/// Actions invoked will receive the given `focusNode`, or the
/// [FocusManager.primaryFocus] if the given `focusNode` is null.
/// ///
/// The `action` and `intent` arguments must not be null. /// The action will be invoked with the given `context`, if given, but only if
/// the action is a [ContextAction] subclass. If no `context` is given, and
/// the action is a [ContextAction], then the context from the [primaryFocus]
/// is used.
/// ///
/// Returns true if the action was successfully invoked. /// Returns the object returned from [Action.invoke] if the action was
bool invokeAction(Action action, Intent intent, {FocusNode focusNode}) { /// successfully invoked, and null if the action is not enabled. May also
/// return null if [Action.invoke] returns null.
Object invokeAction(covariant Action<Intent> action, covariant Intent intent, [BuildContext context]) {
assert(action != null); assert(action != null);
assert(intent != null); assert(intent != null);
focusNode ??= primaryFocus; context ??= primaryFocus?.context;
if (action != null && intent.isEnabled(focusNode.context)) { if (action.enabled) {
action.invoke(focusNode, intent); if (action is ContextAction) {
return true; return action.invoke(intent, context);
} else {
return action.invoke(intent);
}
} }
return false; return null;
} }
} }
...@@ -179,7 +404,7 @@ class ActionDispatcher with Diagnosticable { ...@@ -179,7 +404,7 @@ class ActionDispatcher with Diagnosticable {
/// * [Intent], a class that holds a unique [LocalKey] identifying an action, /// * [Intent], a class that holds a unique [LocalKey] identifying an action,
/// as well as configuration information for running the [Action]. /// as well as configuration information for running the [Action].
/// * [Shortcuts], a widget used to bind key combinations to [Intent]s. /// * [Shortcuts], a widget used to bind key combinations to [Intent]s.
class Actions extends InheritedWidget { class Actions extends StatefulWidget {
/// Creates an [Actions] widget. /// Creates an [Actions] widget.
/// ///
/// The [child], [actions], and [dispatcher] arguments must not be null. /// The [child], [actions], and [dispatcher] arguments must not be null.
...@@ -187,9 +412,10 @@ class Actions extends InheritedWidget { ...@@ -187,9 +412,10 @@ class Actions extends InheritedWidget {
Key key, Key key,
this.dispatcher, this.dispatcher,
@required this.actions, @required this.actions,
@required Widget child, @required this.child,
}) : assert(actions != null), }) : assert(actions != null),
super(key: key, child: child); assert(child != null),
super(key: key);
/// The [ActionDispatcher] object that invokes actions. /// The [ActionDispatcher] object that invokes actions.
/// ///
...@@ -202,40 +428,108 @@ class Actions extends InheritedWidget { ...@@ -202,40 +428,108 @@ class Actions extends InheritedWidget {
final ActionDispatcher dispatcher; final ActionDispatcher dispatcher;
/// {@template flutter.widgets.actions.actions} /// {@template flutter.widgets.actions.actions}
/// A map of [Intent] keys to [ActionFactory] factory methods that defines /// A map of [Intent] keys to [Action<Intent>] objects that defines which
/// which actions this widget knows about. /// actions this widget knows about.
/// ///
/// For performance reasons, it is recommended that a pre-built map is /// For performance reasons, it is recommended that a pre-built map is
/// passed in here (e.g. a final variable from your widget class) instead of /// passed in here (e.g. a final variable from your widget class) instead of
/// defining it inline in the build function. /// defining it inline in the build function.
/// {@endtemplate} /// {@endtemplate}
final Map<LocalKey, ActionFactory> actions; final Map<Type, Action<Intent>> actions;
/// {@macro flutter.widgets.child}
final Widget child;
// Visits the Actions widget ancestors of the given element using
// getElementForInheritedWidgetOfExactType. Returns true if the visitor found
// what it was looking for.
static bool _visitActionsAncestors(BuildContext context, bool visitor(InheritedElement element)) {
InheritedElement actionsElement = context.getElementForInheritedWidgetOfExactType<_ActionsMarker>();
while (actionsElement != null) {
if (visitor(actionsElement) == true) {
break;
}
// _getParent is needed here because
// context.getElementForInheritedWidgetOfExactType will return itself if it
// happens to be of the correct type.
final BuildContext parent = _getParent(actionsElement);
actionsElement = parent.getElementForInheritedWidgetOfExactType<_ActionsMarker>();
}
return actionsElement != null;
}
// Finds the nearest valid ActionDispatcher, or creates a new one if it // Finds the nearest valid ActionDispatcher, or creates a new one if it
// doesn't find one. // doesn't find one.
static ActionDispatcher _findDispatcher(Element element) { static ActionDispatcher _findDispatcher(BuildContext context) {
assert(element.widget is Actions); ActionDispatcher dispatcher;
final Actions actions = element.widget as Actions; _visitActionsAncestors(context, (InheritedElement element) {
ActionDispatcher dispatcher = actions.dispatcher; final ActionDispatcher found = (element.widget as _ActionsMarker).dispatcher;
if (dispatcher == null) { if (found != null) {
bool visitAncestorElement(Element visitedElement) { dispatcher = found;
if (visitedElement.widget is! Actions) { return true;
// Continue visiting.
return true;
}
final Actions actions = visitedElement.widget as Actions;
if (actions.dispatcher == null) {
// Continue visiting.
return true;
}
dispatcher = actions.dispatcher;
// Stop visiting.
return false;
} }
return false;
});
return dispatcher ?? const ActionDispatcher();
}
element.visitAncestorElements(visitAncestorElement); /// Returns a [VoidCallback] handler that invokes the bound action for the
/// given `intent` if the action is enabled, and returns null if the action is
/// not enabled.
///
/// This is intended to be used in widgets which have something similar to an
/// `onTap` handler, which takes a `VoidCallback`, and can be set to the
/// result of calling this function.
///
/// Creates a dependency on the [Actions] widget that maps the bound action so
/// that if the actions change, the context will be rebuilt and find the
/// updated action.
static VoidCallback handler<T extends Intent>(BuildContext context, T intent, {bool nullOk = false}) {
final Action<T> action = Actions.find<T>(context, nullOk: nullOk);
if (action != null && action.enabled) {
return () {
Actions.of(context).invokeAction(action, intent, context);
};
} }
return dispatcher ?? const ActionDispatcher(); return null;
}
/// Finds the [Action] bound to the given intent type `T` in the given `context`.
///
/// Creates a dependency on the [Actions] widget that maps the bound action so
/// that if the actions change, the context will be rebuilt and find the
/// updated action.
static Action<T> find<T extends Intent>(BuildContext context, {bool nullOk = false}) {
Action<T> action;
_visitActionsAncestors(context, (InheritedElement element) {
final _ActionsMarker actions = element.widget as _ActionsMarker;
final Action<T> result = actions.actions[T] as Action<T>;
if (result != null) {
context.dependOnInheritedElement(element);
action = result;
return true;
}
return false;
});
assert(() {
if (nullOk) {
return true;
}
if (action == null) {
throw FlutterError('Unable to find an action for a $T in an $Actions widget '
'in the given context.\n'
"$Actions.find() was called on a context that doesn\'t contain an "
'$Actions widget with a mapping for the given intent type.\n'
'The context used was:\n'
' $context\n'
'The intent type requested was:\n'
' $T');
}
return true;
}());
return action;
} }
/// Returns the [ActionDispatcher] associated with the [Actions] widget that /// Returns the [ActionDispatcher] associated with the [Actions] widget that
...@@ -249,14 +543,13 @@ class Actions extends InheritedWidget { ...@@ -249,14 +543,13 @@ class Actions extends InheritedWidget {
/// The `context` argument must not be null. /// The `context` argument must not be null.
static ActionDispatcher of(BuildContext context, {bool nullOk = false}) { static ActionDispatcher of(BuildContext context, {bool nullOk = false}) {
assert(context != null); assert(context != null);
final InheritedElement inheritedElement = context.getElementForInheritedWidgetOfExactType<Actions>(); final _ActionsMarker marker = context.dependOnInheritedWidgetOfExactType<_ActionsMarker>();
final Actions inherited = context.dependOnInheritedElement(inheritedElement) as Actions;
assert(() { assert(() {
if (nullOk) { if (nullOk) {
return true; return true;
} }
if (inherited == null) { if (marker == null) {
throw FlutterError('Unable to find an $Actions widget in the context.\n' throw FlutterError('Unable to find an $Actions widget in the given context.\n'
'$Actions.of() was called with a context that does not contain an ' '$Actions.of() was called with a context that does not contain an '
'$Actions widget.\n' '$Actions widget.\n'
'No $Actions ancestor could be found starting from the context that ' 'No $Actions ancestor could be found starting from the context that '
...@@ -267,7 +560,7 @@ class Actions extends InheritedWidget { ...@@ -267,7 +560,7 @@ class Actions extends InheritedWidget {
} }
return true; return true;
}()); }());
return inherited?.dispatcher ?? _findDispatcher(inheritedElement); return marker?.dispatcher ?? _findDispatcher(context);
} }
/// Invokes the action associated with the given [Intent] using the /// Invokes the action associated with the given [Intent] using the
...@@ -276,88 +569,173 @@ class Actions extends InheritedWidget { ...@@ -276,88 +569,173 @@ class Actions extends InheritedWidget {
/// The `context`, `intent` and `nullOk` arguments must not be null. /// The `context`, `intent` and `nullOk` arguments must not be null.
/// ///
/// If the given `intent` isn't found in the first [Actions.actions] map, then /// If the given `intent` isn't found in the first [Actions.actions] map, then
/// it will move up to the next [Actions] widget in the hierarchy until it /// it will look to the next [Actions] widget in the hierarchy until it
/// reaches the root. /// reaches the root.
/// ///
/// Will throw if no ambient [Actions] widget is found, or if the given /// Will throw if no ambient [Actions] widget is found, or if the given
/// `intent` doesn't map to an action in any of the [Actions.actions] maps /// `intent` doesn't map to an action in any of the [Actions.actions] maps
/// that are found. /// that are found.
/// ///
/// Returns true if an action was successfully invoked.
///
/// Setting `nullOk` to true means that if no ambient [Actions] widget is /// Setting `nullOk` to true means that if no ambient [Actions] widget is
/// found, then this method will return false instead of throwing. /// found, then this method will return false instead of throwing.
static bool invoke( static Object invoke<T extends Intent>(
BuildContext context, BuildContext context,
Intent intent, { T intent, {
FocusNode focusNode,
bool nullOk = false, bool nullOk = false,
}) { }) {
assert(context != null);
assert(intent != null); assert(intent != null);
Element actionsElement; assert(nullOk != null);
Action action; assert(context != null);
Action<T> action;
InheritedElement actionElement;
bool visitAncestorElement(Element element) { _visitActionsAncestors(context, (InheritedElement element) {
if (element.widget is! Actions) { final _ActionsMarker actions = element.widget as _ActionsMarker;
// Continue visiting. final Action<T> result = actions.actions[intent.runtimeType] as Action<T>;
if (result != null) {
action = result;
actionElement = element;
return true; return true;
} }
// Below when we invoke the action, we need to use the dispatcher from the return false;
// Actions widget where we found the action, in case they need to match. });
actionsElement = element;
final Actions actions = element.widget as Actions;
action = actions.actions[intent.key]?.call();
// Keep looking if we failed to find and create an action.
return action == null;
}
context.visitAncestorElements(visitAncestorElement);
assert(() { assert(() {
if (nullOk) { if (nullOk) {
return true; return true;
} }
if (actionsElement == null) {
throw FlutterError('Unable to find a $Actions widget in the context.\n'
'$Actions.invoke() was called with a context that does not contain an '
'$Actions widget.\n'
'No $Actions ancestor could be found starting from the context that '
'was passed to $Actions.invoke(). This can happen if the context comes '
'from a widget above those widgets.\n'
'The context used was:\n'
' $context');
}
if (action == null) { if (action == null) {
throw FlutterError('Unable to find an action for an intent in the $Actions widget in the context.\n' throw FlutterError('Unable to find an action for an Intent with type '
"$Actions.invoke() was called on an $Actions widget that doesn't " '${intent.runtimeType} in an $Actions widget in the given context.\n'
'contain a mapping for the given intent.\n' '$Actions.invoke() was unable to find an $Actions widget that '
"contained a mapping for the given intent, or the intent type isn't the "
'same as the type argument to invoke (which is $T - try supplying a '
'type argument to invoke if one was not given)\n'
'The context used was:\n' 'The context used was:\n'
' $context\n' ' $context\n'
'The intent requested was:\n' 'The intent type requested was:\n'
' $intent'); ' ${intent.runtimeType}');
} }
return true; return true;
}()); }());
if (action == null) { // Invoke the action we found using the relevant dispatcher from the Actions
// Will only get here if nullOk is true. // Element we found.
return false; return actionElement != null ? _findDispatcher(actionElement).invokeAction(action, intent, context) != null : null;
}
// Invoke the action we found using the dispatcher from the Actions Element
// we found, using the given focus node.
return _findDispatcher(actionsElement).invokeAction(action, intent, focusNode: focusNode);
} }
@override @override
bool updateShouldNotify(Actions oldWidget) { State<Actions> createState() => _ActionsState();
return oldWidget.dispatcher != dispatcher || !mapEquals<LocalKey, ActionFactory>(oldWidget.actions, actions);
}
@override @override
void debugFillProperties(DiagnosticPropertiesBuilder properties) { void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties); super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<ActionDispatcher>('dispatcher', dispatcher)); properties.add(DiagnosticsProperty<ActionDispatcher>('dispatcher', dispatcher));
properties.add(DiagnosticsProperty<Map<LocalKey, ActionFactory>>('actions', actions)); properties.add(DiagnosticsProperty<Map<Type, Action<Intent>>>('actions', actions));
}
}
class _ActionsState extends State<Actions> {
// Keeps the last-known enabled state of each action in the action map in
// order to be able to appropriately notify dependents that the state has
// changed.
Map<Action<Intent>, bool> enabledState = <Action<Intent>, bool>{};
// Used to tell the marker to rebuild when the enabled state of an action in
// the map changes.
Object rebuildKey = Object();
@override
void initState() {
super.initState();
_updateActionListeners();
}
void _handleActionChanged(Action<Intent> action) {
assert(enabledState.containsKey(action));
final bool actionEnabled = action.enabled;
if (enabledState[action] == null || enabledState[action] != actionEnabled) {
setState(() {
enabledState[action] = actionEnabled;
// Generate a new key so that the marker notifies dependents.
rebuildKey = Object();
});
}
}
void _updateActionListeners() {
final Map<Action<Intent>, bool> newState = <Action<Intent>, bool>{};
final Set<Action<Intent>> foundActions = <Action<Intent>>{};
for (final Action<Intent> action in widget.actions.values) {
if (enabledState.containsKey(action)) {
// Already subscribed to this action, just copy over the current enabled state.
newState[action] = enabledState[action];
foundActions.add(action);
} else {
// New action to subscribe to.
// Don't set the new state to action.enabled, since that can cause
// problems when the enabled accessor looks up other widgets (which may
// have already been removed from the tree).
newState[action] = null;
action.addActionListener(_handleActionChanged);
}
}
// Unregister from any actions in the previous enabledState map that aren't
// going to be transferred to the new one.
for (final Action<Intent> action in enabledState.keys) {
if (!foundActions.contains(action)) {
action.removeActionListener(_handleActionChanged);
}
}
enabledState = newState;
}
@override
void didUpdateWidget(Actions oldWidget) {
super.didUpdateWidget(oldWidget);
_updateActionListeners();
}
@override
void dispose() {
super.dispose();
for (final Action<Intent> action in enabledState.keys) {
action.removeActionListener(_handleActionChanged);
}
enabledState = null;
}
@override
Widget build(BuildContext context) {
return _ActionsMarker(
actions: widget.actions,
dispatcher: widget.dispatcher,
rebuildKey: rebuildKey,
child: widget.child,
);
}
}
// An inherited widget used by Actions widget for fast lookup of the Actions
// widget information.
class _ActionsMarker extends InheritedWidget {
const _ActionsMarker({
@required this.dispatcher,
@required this.actions,
@required this.rebuildKey,
Key key,
@required Widget child,
}) : assert(child != null),
assert(actions != null),
super(key: key, child: child);
final ActionDispatcher dispatcher;
final Map<Type, Action<Intent>> actions;
final Object rebuildKey;
@override
bool updateShouldNotify(_ActionsMarker oldWidget) {
return rebuildKey != oldWidget.rebuildKey
|| oldWidget.dispatcher != dispatcher
|| !mapEquals<Type, Action<Intent>>(oldWidget.actions, actions);
} }
} }
...@@ -402,22 +780,19 @@ class Actions extends InheritedWidget { ...@@ -402,22 +780,19 @@ class Actions extends InheritedWidget {
/// bool _focused = false; /// bool _focused = false;
/// bool _hovering = false; /// bool _hovering = false;
/// bool _on = false; /// bool _on = false;
/// Map<LocalKey, ActionFactory> _actionMap; /// Map<Type, Action<Intent>> _actionMap;
/// Map<LogicalKeySet, Intent> _shortcutMap; /// Map<LogicalKeySet, Intent> _shortcutMap;
/// ///
/// @override /// @override
/// void initState() { /// void initState() {
/// super.initState(); /// super.initState();
/// _actionMap = <LocalKey, ActionFactory>{ /// _actionMap = <Type, Action<Intent>>{
/// ActivateAction.key: () { /// ActivateIntent: CallbackAction(
/// return CallbackAction( /// onInvoke: (Intent intent) => _toggleState(),
/// ActivateAction.key, /// ),
/// onInvoke: (FocusNode node, Intent intent) => _toggleState(),
/// );
/// },
/// }; /// };
/// _shortcutMap = <LogicalKeySet, Intent>{ /// _shortcutMap = <LogicalKeySet, Intent>{
/// LogicalKeySet(LogicalKeyboardKey.keyX): Intent(ActivateAction.key), /// LogicalKeySet(LogicalKeyboardKey.keyX): const ActivateIntent(),
/// }; /// };
/// } /// }
/// ///
...@@ -546,7 +921,7 @@ class FocusableActionDetector extends StatefulWidget { ...@@ -546,7 +921,7 @@ class FocusableActionDetector extends StatefulWidget {
final bool autofocus; final bool autofocus;
/// {@macro flutter.widgets.actions.actions} /// {@macro flutter.widgets.actions.actions}
final Map<LocalKey, ActionFactory> actions; final Map<Type, Action<Intent>> actions;
/// {@macro flutter.widgets.shortcuts.shortcuts} /// {@macro flutter.widgets.shortcuts.shortcuts}
final Map<LogicalKeySet, Intent> shortcuts; final Map<LogicalKeySet, Intent> shortcuts;
...@@ -656,6 +1031,7 @@ class _FocusableActionDetectorState extends State<FocusableActionDetector> { ...@@ -656,6 +1031,7 @@ class _FocusableActionDetectorState extends State<FocusableActionDetector> {
bool shouldShowHoverHighlight(FocusableActionDetector target) { bool shouldShowHoverHighlight(FocusableActionDetector target) {
return _hovering && target.enabled && _canShowHighlight; return _hovering && target.enabled && _canShowHighlight;
} }
bool shouldShowFocusHighlight(FocusableActionDetector target) { bool shouldShowFocusHighlight(FocusableActionDetector target) {
return _focused && target.enabled && _canShowHighlight; return _focused && target.enabled && _canShowHighlight;
} }
...@@ -664,14 +1040,17 @@ class _FocusableActionDetectorState extends State<FocusableActionDetector> { ...@@ -664,14 +1040,17 @@ class _FocusableActionDetectorState extends State<FocusableActionDetector> {
final FocusableActionDetector oldTarget = oldWidget ?? widget; final FocusableActionDetector oldTarget = oldWidget ?? widget;
final bool didShowHoverHighlight = shouldShowHoverHighlight(oldTarget); final bool didShowHoverHighlight = shouldShowHoverHighlight(oldTarget);
final bool didShowFocusHighlight = shouldShowFocusHighlight(oldTarget); final bool didShowFocusHighlight = shouldShowFocusHighlight(oldTarget);
if (task != null) if (task != null) {
task(); task();
}
final bool doShowHoverHighlight = shouldShowHoverHighlight(widget); final bool doShowHoverHighlight = shouldShowHoverHighlight(widget);
final bool doShowFocusHighlight = shouldShowFocusHighlight(widget); final bool doShowFocusHighlight = shouldShowFocusHighlight(widget);
if (didShowFocusHighlight != doShowFocusHighlight) if (didShowFocusHighlight != doShowFocusHighlight) {
widget.onShowFocusHighlight?.call(doShowFocusHighlight); widget.onShowFocusHighlight?.call(doShowFocusHighlight);
if (didShowHoverHighlight != doShowHoverHighlight) }
if (didShowHoverHighlight != doShowHoverHighlight) {
widget.onShowHoverHighlight?.call(doShowHoverHighlight); widget.onShowHoverHighlight?.call(doShowHoverHighlight);
}
} }
@override @override
...@@ -707,44 +1086,54 @@ class _FocusableActionDetectorState extends State<FocusableActionDetector> { ...@@ -707,44 +1086,54 @@ class _FocusableActionDetectorState extends State<FocusableActionDetector> {
} }
} }
/// An [Action], that, as the name implies, does nothing. /// An [Intent], that, as the name implies, is bound to a [DoNothingAction].
///
/// Attaching a [DoNothingIntent] to a [Shortcuts] mapping is one way to disable
/// a keyboard shortcut defined by a widget higher in the widget hierarchy.
/// ///
/// This action is bound to the [Intent.doNothing] intent inside of /// This intent cannot be subclassed.
/// [WidgetsApp.build] so that a [Shortcuts] widget can bind a key to it to class DoNothingIntent extends Intent {
/// override another shortcut binding defined above it in the hierarchy. /// Creates a const [DoNothingIntent].
class DoNothingAction extends Action { factory DoNothingIntent() => const DoNothingIntent._();
/// Const constructor for [DoNothingAction].
const DoNothingAction() : super(key);
/// The Key used for the [DoNothingIntent] intent, and registered at the top // Make DoNothingIntent constructor private so it can't be subclassed.
/// level actions in [WidgetsApp.build]. const DoNothingIntent._();
static const LocalKey key = ValueKey<Type>(DoNothingAction); }
/// An [Action], that, as the name implies, does nothing.
///
/// Attaching a [DoNothingAction] to an [Actions] mapping is one way to disable
/// an action defined by a widget higher in the widget hierarchy.
///
/// This action can be bound to any intent.
///
/// See also:
/// - [DoNothingIntent], which is an intent that can be bound to a keystroke in
/// a [Shortcuts] widget to do nothing.
class DoNothingAction extends Action<Intent> {
@override @override
void invoke(FocusNode node, Intent intent) { } void invoke(Intent intent) {}
}
/// An intent that activates the currently focused control.
class ActivateIntent extends Intent {
/// Creates a const [ActivateIntent] so subclasses can be const.
const ActivateIntent();
} }
/// An action that invokes the currently focused control. /// An action that activates the currently focused control.
/// ///
/// This is an abstract class that serves as a base class for actions that /// This is an abstract class that serves as a base class for actions that
/// activate a control. By default, is bound to [LogicalKeyboardKey.enter] in /// activate a control. By default, is bound to [LogicalKeyboardKey.enter],
/// the default keyboard map in [WidgetsApp]. /// [LogicalKeyboardKey.gameButtonA], and [LogicalKeyboardKey.space] in the
abstract class ActivateAction extends Action { /// default keyboard map in [WidgetsApp].
/// Creates a [ActivateAction] with a fixed [key]; abstract class ActivateAction extends Action<ActivateIntent> {}
const ActivateAction() : super(key);
/// An intent that selects the currently focused control.
/// The [LocalKey] that uniquely identifies this action. class SelectIntent extends Intent {}
static const LocalKey key = ValueKey<Type>(ActivateAction);
}
/// An action that selects the currently focused control. /// An action that selects the currently focused control.
/// ///
/// This is an abstract class that serves as a base class for actions that /// This is an abstract class that serves as a base class for actions that
/// select something. It is not bound to any key by default. /// select something. It is not bound to any key by default.
abstract class SelectAction extends Action { abstract class SelectAction extends Action<SelectIntent> {}
/// Creates a [SelectAction] with a fixed [key];
const SelectAction() : super(key);
/// The [LocalKey] that uniquely identifies this action.
static const LocalKey key = ValueKey<Type>(SelectAction);
}
...@@ -743,7 +743,7 @@ class WidgetsApp extends StatefulWidget { ...@@ -743,7 +743,7 @@ class WidgetsApp extends StatefulWidget {
/// return WidgetsApp( /// return WidgetsApp(
/// shortcuts: <LogicalKeySet, Intent>{ /// shortcuts: <LogicalKeySet, Intent>{
/// ... WidgetsApp.defaultShortcuts, /// ... WidgetsApp.defaultShortcuts,
/// LogicalKeySet(LogicalKeyboardKey.select): const Intent(ActivateAction.key), /// LogicalKeySet(LogicalKeyboardKey.select): const ActivateIntent(),
/// }, /// },
/// color: const Color(0xFFFF0000), /// color: const Color(0xFFFF0000),
/// builder: (BuildContext context, Widget child) { /// builder: (BuildContext context, Widget child) {
...@@ -790,12 +790,12 @@ class WidgetsApp extends StatefulWidget { ...@@ -790,12 +790,12 @@ class WidgetsApp extends StatefulWidget {
/// ```dart /// ```dart
/// Widget build(BuildContext context) { /// Widget build(BuildContext context) {
/// return WidgetsApp( /// return WidgetsApp(
/// actions: <LocalKey, ActionFactory>{ /// actions: <Type, Action<Intent>>{
/// ... WidgetsApp.defaultActions, /// ... WidgetsApp.defaultActions,
/// ActivateAction.key: () => CallbackAction( /// ActivateAction: CallbackAction(
/// ActivateAction.key, /// onInvoke: (Intent intent) {
/// onInvoke: (FocusNode focusNode, Intent intent) {
/// // Do something here... /// // Do something here...
/// return null;
/// }, /// },
/// ), /// ),
/// }, /// },
...@@ -818,7 +818,7 @@ class WidgetsApp extends StatefulWidget { ...@@ -818,7 +818,7 @@ class WidgetsApp extends StatefulWidget {
/// * The [Intent] and [Action] classes, which allow definition of new /// * The [Intent] and [Action] classes, which allow definition of new
/// actions. /// actions.
/// {@endtemplate} /// {@endtemplate}
final Map<LocalKey, ActionFactory> actions; final Map<Type, Action<Intent>> actions;
/// If true, forces the performance overlay to be visible in all instances. /// If true, forces the performance overlay to be visible in all instances.
/// ///
...@@ -845,13 +845,13 @@ class WidgetsApp extends StatefulWidget { ...@@ -845,13 +845,13 @@ class WidgetsApp extends StatefulWidget {
static final Map<LogicalKeySet, Intent> _defaultShortcuts = <LogicalKeySet, Intent>{ static final Map<LogicalKeySet, Intent> _defaultShortcuts = <LogicalKeySet, Intent>{
// Activation // Activation
LogicalKeySet(LogicalKeyboardKey.enter): const Intent(ActivateAction.key), LogicalKeySet(LogicalKeyboardKey.enter): const ActivateIntent(),
LogicalKeySet(LogicalKeyboardKey.space): const Intent(ActivateAction.key), LogicalKeySet(LogicalKeyboardKey.space): const ActivateIntent(),
LogicalKeySet(LogicalKeyboardKey.gameButtonA): const Intent(ActivateAction.key), LogicalKeySet(LogicalKeyboardKey.gameButtonA): const ActivateIntent(),
// Keyboard traversal. // Keyboard traversal.
LogicalKeySet(LogicalKeyboardKey.tab): const Intent(NextFocusAction.key), LogicalKeySet(LogicalKeyboardKey.tab): const NextFocusIntent(),
LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.tab): const Intent(PreviousFocusAction.key), LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.tab): const PreviousFocusIntent(),
LogicalKeySet(LogicalKeyboardKey.arrowLeft): const DirectionalFocusIntent(TraversalDirection.left), LogicalKeySet(LogicalKeyboardKey.arrowLeft): const DirectionalFocusIntent(TraversalDirection.left),
LogicalKeySet(LogicalKeyboardKey.arrowRight): const DirectionalFocusIntent(TraversalDirection.right), LogicalKeySet(LogicalKeyboardKey.arrowRight): const DirectionalFocusIntent(TraversalDirection.right),
LogicalKeySet(LogicalKeyboardKey.arrowDown): const DirectionalFocusIntent(TraversalDirection.down), LogicalKeySet(LogicalKeyboardKey.arrowDown): const DirectionalFocusIntent(TraversalDirection.down),
...@@ -869,11 +869,11 @@ class WidgetsApp extends StatefulWidget { ...@@ -869,11 +869,11 @@ class WidgetsApp extends StatefulWidget {
// Default shortcuts for the web platform. // Default shortcuts for the web platform.
static final Map<LogicalKeySet, Intent> _defaultWebShortcuts = <LogicalKeySet, Intent>{ static final Map<LogicalKeySet, Intent> _defaultWebShortcuts = <LogicalKeySet, Intent>{
// Activation // Activation
LogicalKeySet(LogicalKeyboardKey.space): const Intent(ActivateAction.key), LogicalKeySet(LogicalKeyboardKey.space): const ActivateIntent(),
// Keyboard traversal. // Keyboard traversal.
LogicalKeySet(LogicalKeyboardKey.tab): const Intent(NextFocusAction.key), LogicalKeySet(LogicalKeyboardKey.tab): const NextFocusIntent(),
LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.tab): const Intent(PreviousFocusAction.key), LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.tab): const PreviousFocusIntent(),
// Scrolling // Scrolling
LogicalKeySet(LogicalKeyboardKey.arrowUp): const ScrollIntent(direction: AxisDirection.up), LogicalKeySet(LogicalKeyboardKey.arrowUp): const ScrollIntent(direction: AxisDirection.up),
...@@ -887,12 +887,12 @@ class WidgetsApp extends StatefulWidget { ...@@ -887,12 +887,12 @@ class WidgetsApp extends StatefulWidget {
// Default shortcuts for the macOS platform. // Default shortcuts for the macOS platform.
static final Map<LogicalKeySet, Intent> _defaultMacOsShortcuts = <LogicalKeySet, Intent>{ static final Map<LogicalKeySet, Intent> _defaultMacOsShortcuts = <LogicalKeySet, Intent>{
// Activation // Activation
LogicalKeySet(LogicalKeyboardKey.enter): const Intent(ActivateAction.key), LogicalKeySet(LogicalKeyboardKey.enter): const ActivateIntent(),
LogicalKeySet(LogicalKeyboardKey.space): const Intent(ActivateAction.key), LogicalKeySet(LogicalKeyboardKey.space): const ActivateIntent(),
// Keyboard traversal // Keyboard traversal
LogicalKeySet(LogicalKeyboardKey.tab): const Intent(NextFocusAction.key), LogicalKeySet(LogicalKeyboardKey.tab): const NextFocusIntent(),
LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.tab): const Intent(PreviousFocusAction.key), LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.tab): const PreviousFocusIntent(),
LogicalKeySet(LogicalKeyboardKey.arrowLeft): const DirectionalFocusIntent(TraversalDirection.left), LogicalKeySet(LogicalKeyboardKey.arrowLeft): const DirectionalFocusIntent(TraversalDirection.left),
LogicalKeySet(LogicalKeyboardKey.arrowRight): const DirectionalFocusIntent(TraversalDirection.right), LogicalKeySet(LogicalKeyboardKey.arrowRight): const DirectionalFocusIntent(TraversalDirection.right),
LogicalKeySet(LogicalKeyboardKey.arrowDown): const DirectionalFocusIntent(TraversalDirection.down), LogicalKeySet(LogicalKeyboardKey.arrowDown): const DirectionalFocusIntent(TraversalDirection.down),
...@@ -932,13 +932,13 @@ class WidgetsApp extends StatefulWidget { ...@@ -932,13 +932,13 @@ class WidgetsApp extends StatefulWidget {
} }
/// The default value of [WidgetsApp.actions]. /// The default value of [WidgetsApp.actions].
static final Map<LocalKey, ActionFactory> defaultActions = <LocalKey, ActionFactory>{ static Map<Type, Action<Intent>> defaultActions = <Type, Action<Intent>>{
DoNothingAction.key: () => const DoNothingAction(), DoNothingIntent: DoNothingAction(),
RequestFocusAction.key: () => RequestFocusAction(), RequestFocusIntent: RequestFocusAction(),
NextFocusAction.key: () => NextFocusAction(), NextFocusIntent: NextFocusAction(),
PreviousFocusAction.key: () => PreviousFocusAction(), PreviousFocusIntent: PreviousFocusAction(),
DirectionalFocusAction.key: () => DirectionalFocusAction(), DirectionalFocusIntent: DirectionalFocusAction(),
ScrollAction.key: () => ScrollAction(), ScrollIntent: ScrollAction(),
}; };
@override @override
......
...@@ -92,7 +92,8 @@ enum TraversalDirection { ...@@ -92,7 +92,8 @@ enum TraversalDirection {
/// [FocusTraversalGroup] widget. /// [FocusTraversalGroup] widget.
/// ///
/// The focus traversal policy is what determines which widget is "next", /// The focus traversal policy is what determines which widget is "next",
/// "previous", or in a direction from the currently focused [FocusNode]. /// "previous", or in a direction from the widget associated with the currently
/// focused [FocusNode] (usually a [Focus] widget).
/// ///
/// One of the pre-defined subclasses may be used, or define a custom policy to /// One of the pre-defined subclasses may be used, or define a custom policy to
/// create a unique focus order. /// create a unique focus order.
...@@ -1713,88 +1714,94 @@ class _FocusTraversalGroupMarker extends InheritedWidget { ...@@ -1713,88 +1714,94 @@ class _FocusTraversalGroupMarker extends InheritedWidget {
bool updateShouldNotify(InheritedWidget oldWidget) => false; bool updateShouldNotify(InheritedWidget oldWidget) => false;
} }
// A base class for all of the default actions that request focus for a node. /// An intent for use with the [RequestFocusAction], which supplies the
class _RequestFocusActionBase extends Action { /// [FocusNode] that should be focused.
_RequestFocusActionBase(LocalKey name) : super(name); class RequestFocusIntent extends Intent {
/// A const constructor for a [RequestFocusIntent], so that subclasses may be
/// const.
const RequestFocusIntent(this.focusNode)
: assert(focusNode != null);
FocusNode _previousFocus; /// The [FocusNode] that is to be focused.
final FocusNode focusNode;
@override
void invoke(FocusNode node, Intent intent) {
_previousFocus = 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. /// An [Action] that requests the focus on the node it is given in its
/// [RequestFocusIntent].
/// ///
/// This action can be used to request focus for a particular node, by calling /// This action can be used to request focus for a particular node, by calling
/// [Action.invoke] like so: /// [Action.invoke] like so:
/// ///
/// ```dart /// ```dart
/// Actions.invoke(context, const Intent(RequestFocusAction.key), focusNode: _focusNode); /// Actions.invoke(context, const RequestFocusIntent(focusNode));
/// ``` /// ```
/// ///
/// Where the `_focusNode` is the node for which the focus will be requested. /// Where the `focusNode` is the node for which the focus will be requested.
/// ///
/// The difference between requesting focus in this way versus calling /// The difference between requesting focus in this way versus calling
/// [_focusNode.requestFocus] directly is that it will use the [Action] /// [FocusNode.requestFocus] directly is that it will use the [Action]
/// registered in the nearest [Actions] widget associated with [key] to make the /// registered in the nearest [Actions] widget associated with
/// request, rather than just requesting focus directly. This allows the action /// [RequestFocusIntent] to make the request, rather than just requesting focus
/// to have additional side effects, like logging, or undo and redo /// directly. This allows the action to have additional side effects, like
/// functionality. /// logging, or undo and redo functionality.
/// ///
/// However, this [RequestFocusAction] is the default action associated with the /// This [RequestFocusAction] class is the default action associated with the
/// [key] in the [WidgetsApp], and it simply requests focus and has no side /// [RequestFocusIntent] in the [WidgetsApp], and it simply requests focus. You
/// effects. /// can redefine the associated action with your own [Actions] widget.
class RequestFocusAction extends _RequestFocusActionBase { ///
/// Creates a [RequestFocusAction] with a fixed [key]. /// See [FocusTraversalPolicy] for more information about focus traversal.
RequestFocusAction() : super(key); class RequestFocusAction extends Action<RequestFocusIntent> {
/// The [LocalKey] that uniquely identifies this action to an [Intent].
static const LocalKey key = ValueKey<Type>(RequestFocusAction);
@override @override
void invoke(FocusNode node, Intent intent) => _focusAndEnsureVisible(node); void invoke(RequestFocusIntent intent) {
_focusAndEnsureVisible(intent.focusNode);
}
}
/// An [Intent] bound to [NextFocusAction], which moves the focus to the next
/// focusable node in the focus traversal order.
///
/// See [FocusTraversalPolicy] for more information about focus traversal.
class NextFocusIntent extends Intent {
/// Creates a const [NextFocusIntent] so subclasses can be const.
const NextFocusIntent();
} }
/// An [Action] that moves the focus to the next focusable node in the focus /// An [Action] that moves the focus to the next focusable node in the focus
/// order. /// order.
/// ///
/// This action is the default action registered for the [key], and by default /// This action is the default action registered for the [NextFocusIntent], and
/// is bound to the [LogicalKeyboardKey.tab] key in the [WidgetsApp]. /// by default is bound to the [LogicalKeyboardKey.tab] key in the [WidgetsApp].
class NextFocusAction extends _RequestFocusActionBase { ///
/// Creates a [NextFocusAction] with a fixed [key]; /// See [FocusTraversalPolicy] for more information about focus traversal.
NextFocusAction() : super(key); class NextFocusAction extends Action<NextFocusIntent> {
/// The [LocalKey] that uniquely identifies this action to an [Intent].
static const LocalKey key = ValueKey<Type>(NextFocusAction);
@override @override
void invoke(FocusNode node, Intent intent) => node.nextFocus(); void invoke(NextFocusIntent intent) {
primaryFocus.nextFocus();
}
}
/// An [Intent] bound to [PreviousFocusAction], which moves the focus to the
/// previous focusable node in the focus traversal order.
///
/// See [FocusTraversalPolicy] for more information about focus traversal.
class PreviousFocusIntent extends Intent {
/// Creates a const [PreviousFocusIntent] so subclasses can be const.
const PreviousFocusIntent();
} }
/// An [Action] that moves the focus to the previous focusable node in the focus /// An [Action] that moves the focus to the previous focusable node in the focus
/// order. /// order.
/// ///
/// This action is the default action registered for the [key], and by default /// This action is the default action registered for the [PreviousFocusIntent],
/// is bound to a combination of the [LogicalKeyboardKey.tab] key and the /// and by default is bound to a combination of the [LogicalKeyboardKey.tab] key
/// [LogicalKeyboardKey.shift] key in the [WidgetsApp]. /// and the [LogicalKeyboardKey.shift] key in the [WidgetsApp].
class PreviousFocusAction extends _RequestFocusActionBase { ///
/// Creates a [PreviousFocusAction] with a fixed [key]; /// See [FocusTraversalPolicy] for more information about focus traversal.
PreviousFocusAction() : super(key); class PreviousFocusAction extends Action<PreviousFocusIntent> {
/// The [LocalKey] that uniquely identifies this action to an [Intent].
static const LocalKey key = ValueKey<Type>(PreviousFocusAction);
@override @override
void invoke(FocusNode node, Intent intent) => node.previousFocus(); void invoke(PreviousFocusIntent intent) {
primaryFocus.previousFocus();
}
} }
/// An [Intent] that represents moving to the next focusable node in the given /// An [Intent] that represents moving to the next focusable node in the given
...@@ -1804,12 +1811,13 @@ class PreviousFocusAction extends _RequestFocusActionBase { ...@@ -1804,12 +1811,13 @@ class PreviousFocusAction extends _RequestFocusActionBase {
/// [LogicalKeyboardKey.arrowDown], [LogicalKeyboardKey.arrowLeft], and /// [LogicalKeyboardKey.arrowDown], [LogicalKeyboardKey.arrowLeft], and
/// [LogicalKeyboardKey.arrowRight] keys in the [WidgetsApp], with the /// [LogicalKeyboardKey.arrowRight] keys in the [WidgetsApp], with the
/// appropriate associated directions. /// appropriate associated directions.
///
/// See [FocusTraversalPolicy] for more information about focus traversal.
class DirectionalFocusIntent extends Intent { class DirectionalFocusIntent extends Intent {
/// Creates a [DirectionalFocusIntent] with a fixed [key], and the given /// Creates a [DirectionalFocusIntent] intending to move the focus in the
/// [direction]. /// given [direction].
const DirectionalFocusIntent(this.direction, {this.ignoreTextFields = true}) const DirectionalFocusIntent(this.direction, {this.ignoreTextFields = true})
: assert(ignoreTextFields != null), : assert(ignoreTextFields != null);
super(DirectionalFocusAction.key);
/// The direction in which to look for the next focusable node when the /// The direction in which to look for the next focusable node when the
/// associated [DirectionalFocusAction] is invoked. /// associated [DirectionalFocusAction] is invoked.
...@@ -1826,21 +1834,15 @@ class DirectionalFocusIntent extends Intent { ...@@ -1826,21 +1834,15 @@ class DirectionalFocusIntent extends Intent {
/// An [Action] that moves the focus to the focusable node in the direction /// An [Action] that moves the focus to the focusable node in the direction
/// configured by the associated [DirectionalFocusIntent.direction]. /// configured by the associated [DirectionalFocusIntent.direction].
/// ///
/// This is the [Action] associated with the [key] and bound by default to the /// This is the [Action] associated with [DirectionalFocusIntent] and bound by
/// [LogicalKeyboardKey.arrowUp], [LogicalKeyboardKey.arrowDown], /// default to the [LogicalKeyboardKey.arrowUp], [LogicalKeyboardKey.arrowDown],
/// [LogicalKeyboardKey.arrowLeft], and [LogicalKeyboardKey.arrowRight] keys in /// [LogicalKeyboardKey.arrowLeft], and [LogicalKeyboardKey.arrowRight] keys in
/// the [WidgetsApp], with the appropriate associated directions. /// the [WidgetsApp], with the appropriate associated directions.
class DirectionalFocusAction extends _RequestFocusActionBase { class DirectionalFocusAction extends Action<DirectionalFocusIntent> {
/// 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);
@override @override
void invoke(FocusNode node, DirectionalFocusIntent intent) { void invoke(DirectionalFocusIntent intent) {
if (!intent.ignoreTextFields || node.context.widget is! EditableText) { if (!intent.ignoreTextFields || primaryFocus.context.widget is! EditableText) {
node.focusInDirection(intent.direction); primaryFocus.focusInDirection(intent.direction);
} }
} }
} }
...@@ -132,7 +132,7 @@ abstract class GlobalKey<T extends State<StatefulWidget>> extends Key { ...@@ -132,7 +132,7 @@ abstract class GlobalKey<T extends State<StatefulWidget>> extends Key {
static final Map<GlobalKey, Element> _registry = <GlobalKey, Element>{}; static final Map<GlobalKey, Element> _registry = <GlobalKey, Element>{};
static final Set<Element> _debugIllFatedElements = HashSet<Element>(); static final Set<Element> _debugIllFatedElements = HashSet<Element>();
// This map keeps track which child reserve the global key with the parent. // This map keeps track which child reserves the global key with the parent.
// Parent, child -> global key. // Parent, child -> global key.
// This provides us a way to remove old reservation while parent rebuilds the // This provides us a way to remove old reservation while parent rebuilds the
// child in the same slot. // child in the same slot.
......
...@@ -889,8 +889,7 @@ class ScrollIntent extends Intent { ...@@ -889,8 +889,7 @@ class ScrollIntent extends Intent {
@required this.direction, @required this.direction,
this.type = ScrollIncrementType.line, this.type = ScrollIncrementType.line,
}) : assert(direction != null), }) : assert(direction != null),
assert(type != null), assert(type != null);
super(ScrollAction.key);
/// The direction in which to scroll the scrollable containing the focused /// The direction in which to scroll the scrollable containing the focused
/// widget. /// widget.
...@@ -898,11 +897,6 @@ class ScrollIntent extends Intent { ...@@ -898,11 +897,6 @@ class ScrollIntent extends Intent {
/// The type of scrolling that is intended. /// The type of scrolling that is intended.
final ScrollIncrementType type; final ScrollIncrementType type;
@override
bool isEnabled(BuildContext context) {
return Scrollable.of(context) != null;
}
} }
/// An [Action] that scrolls the [Scrollable] that encloses the current /// An [Action] that scrolls the [Scrollable] that encloses the current
...@@ -912,13 +906,16 @@ class ScrollIntent extends Intent { ...@@ -912,13 +906,16 @@ class ScrollIntent extends Intent {
/// for a [ScrollIntent.type] set to [ScrollIncrementType.page] is 80% of the /// for a [ScrollIntent.type] set to [ScrollIncrementType.page] is 80% of the
/// size of the scroll window, and for [ScrollIncrementType.line], 50 logical /// size of the scroll window, and for [ScrollIncrementType.line], 50 logical
/// pixels. /// pixels.
class ScrollAction extends Action { class ScrollAction extends Action<ScrollIntent> {
/// Creates a const [ScrollAction].
ScrollAction() : super(key);
/// The [LocalKey] that uniquely connects this action to a [ScrollIntent]. /// The [LocalKey] that uniquely connects this action to a [ScrollIntent].
static const LocalKey key = ValueKey<Type>(ScrollAction); static const LocalKey key = ValueKey<Type>(ScrollAction);
@override
bool get enabled {
final FocusNode focus = primaryFocus;
return focus != null && focus.context != null && Scrollable.of(focus.context) != null;
}
// Returns the scroll increment for a single scroll request, for use when // Returns the scroll increment for a single scroll request, for use when
// scrolling using a hardware keyboard. // scrolling using a hardware keyboard.
// //
...@@ -1013,8 +1010,8 @@ class ScrollAction extends Action { ...@@ -1013,8 +1010,8 @@ class ScrollAction extends Action {
} }
@override @override
void invoke(FocusNode node, ScrollIntent intent) { void invoke(ScrollIntent intent) {
final ScrollableState state = Scrollable.of(node.context); final ScrollableState state = Scrollable.of(primaryFocus.context);
assert(state != null, '$ScrollAction was invoked on a context that has no scrollable parent'); assert(state != null, '$ScrollAction was invoked on a context that has no scrollable parent');
assert(state.position.pixels != null, 'Scrollable must be laid out before it can be scrolled via a ScrollAction'); assert(state.position.pixels != null, 'Scrollable must be laid out before it can be scrolled via a ScrollAction');
assert(state.position.viewportDimension != null); assert(state.position.viewportDimension != null);
......
...@@ -286,6 +286,14 @@ class ShortcutManager extends ChangeNotifier with Diagnosticable { ...@@ -286,6 +286,14 @@ class ShortcutManager extends ChangeNotifier with Diagnosticable {
/// The optional `keysPressed` argument provides an override to keys that the /// The optional `keysPressed` argument provides an override to keys that the
/// [RawKeyboard] reports. If not specified, uses [RawKeyboard.keysPressed] /// [RawKeyboard] reports. If not specified, uses [RawKeyboard.keysPressed]
/// instead. /// instead.
///
/// If a key mapping is found, then the associated action will be invoked
/// using the [Intent] that the [LogicalKeySet] maps to, and the currently
/// focused widget's context (from [FocusManager.primaryFocus]).
///
/// The object returned is the result of [Action.invoke] being called on the
/// [Action] bound to the [Intent] that the key press maps to, or null, if the
/// key press didn't match any intent.
@protected @protected
bool handleKeypress( bool handleKeypress(
BuildContext context, BuildContext context,
...@@ -316,10 +324,9 @@ class ShortcutManager extends ChangeNotifier with Diagnosticable { ...@@ -316,10 +324,9 @@ class ShortcutManager extends ChangeNotifier with Diagnosticable {
} }
if (matchedIntent != null) { if (matchedIntent != null) {
final BuildContext primaryContext = primaryFocus?.context; final BuildContext primaryContext = primaryFocus?.context;
if (primaryContext == null) { assert (primaryContext != null);
return false; Actions.invoke(primaryContext, matchedIntent, nullOk: true);
} return true;
return Actions.invoke(primaryContext, matchedIntent, nullOk: true);
} }
return false; return false;
} }
......
...@@ -174,6 +174,7 @@ void main() { ...@@ -174,6 +174,7 @@ void main() {
' Focus\n' ' Focus\n'
' _FocusTraversalGroupMarker\n' ' _FocusTraversalGroupMarker\n'
' FocusTraversalGroup\n' ' FocusTraversalGroup\n'
' _ActionsMarker\n'
' Actions\n' ' Actions\n'
' _ShortcutsMarker\n' ' _ShortcutsMarker\n'
' Semantics\n' ' Semantics\n'
......
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
// 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/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
...@@ -259,11 +258,11 @@ void main() { ...@@ -259,11 +258,11 @@ void main() {
final BorderRadius borderRadius = BorderRadius.circular(6.0); final BorderRadius borderRadius = BorderRadius.circular(6.0);
final FocusNode focusNode = FocusNode(debugLabel: 'Test Node'); final FocusNode focusNode = FocusNode(debugLabel: 'Test Node');
Future<void> buildTest(LocalKey actionKey) async { Future<void> buildTest(Intent intent) async {
return await tester.pumpWidget( return await tester.pumpWidget(
Shortcuts( Shortcuts(
shortcuts: <LogicalKeySet, Intent>{ shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.space): Intent(actionKey), LogicalKeySet(LogicalKeyboardKey.space): intent,
}, },
child: Directionality( child: Directionality(
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
...@@ -289,7 +288,7 @@ void main() { ...@@ -289,7 +288,7 @@ void main() {
); );
} }
await buildTest(ActivateAction.key); await buildTest(const ActivateIntent());
focusNode.requestFocus(); focusNode.requestFocus();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
...@@ -322,7 +321,7 @@ void main() { ...@@ -322,7 +321,7 @@ void main() {
); );
} }
await buildTest(ActivateAction.key); await buildTest(const ActivateIntent());
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.sendKeyEvent(LogicalKeyboardKey.space); await tester.sendKeyEvent(LogicalKeyboardKey.space);
await tester.pump(); await tester.pump();
......
...@@ -323,7 +323,7 @@ void main() { ...@@ -323,7 +323,7 @@ void main() {
semantics.dispose(); semantics.dispose();
}); });
testWidgets("ink response doesn't focus when disabled", (WidgetTester tester) async { testWidgets("ink response doesn't focus when disabled", (WidgetTester tester) async {
WidgetsBinding.instance.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTouch; FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch;
final FocusNode focusNode = FocusNode(debugLabel: 'Ink Focus'); final FocusNode focusNode = FocusNode(debugLabel: 'Ink Focus');
final GlobalKey childKey = GlobalKey(); final GlobalKey childKey = GlobalKey();
await tester.pumpWidget( await tester.pumpWidget(
...@@ -359,7 +359,7 @@ void main() { ...@@ -359,7 +359,7 @@ void main() {
}); });
testWidgets("ink response doesn't hover when disabled", (WidgetTester tester) async { testWidgets("ink response doesn't hover when disabled", (WidgetTester tester) async {
WidgetsBinding.instance.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTouch; FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch;
final FocusNode focusNode = FocusNode(debugLabel: 'Ink Focus'); final FocusNode focusNode = FocusNode(debugLabel: 'Ink Focus');
final GlobalKey childKey = GlobalKey(); final GlobalKey childKey = GlobalKey();
bool hovering = false; bool hovering = false;
......
...@@ -47,8 +47,8 @@ void main() { ...@@ -47,8 +47,8 @@ void main() {
await tester.pumpWidget( await tester.pumpWidget(
Shortcuts( Shortcuts(
shortcuts: <LogicalKeySet, Intent>{ shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.enter): const Intent(ActivateAction.key), LogicalKeySet(LogicalKeyboardKey.enter): const ActivateIntent(),
LogicalKeySet(LogicalKeyboardKey.space): const Intent(ActivateAction.key), LogicalKeySet(LogicalKeyboardKey.space): const ActivateIntent(),
}, },
child: Directionality( child: Directionality(
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
......
...@@ -9,17 +9,51 @@ import 'package:flutter/services.dart'; ...@@ -9,17 +9,51 @@ import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
typedef PostInvokeCallback = void Function({Action action, Intent intent, FocusNode focusNode, ActionDispatcher dispatcher}); typedef PostInvokeCallback = void Function({Action<Intent> action, Intent intent, ActionDispatcher dispatcher});
class TestAction extends CallbackAction { class TestIntent extends Intent {
const TestAction({ const TestIntent();
}
class SecondTestIntent extends TestIntent {
const SecondTestIntent();
}
class ThirdTestIntent extends SecondTestIntent {
const ThirdTestIntent();
}
class TestAction extends CallbackAction<TestIntent> {
TestAction({
@required OnInvokeCallback onInvoke, @required OnInvokeCallback onInvoke,
}) : assert(onInvoke != null), }) : assert(onInvoke != null),
super(key, onInvoke: onInvoke); super(onInvoke: onInvoke);
static const LocalKey key = ValueKey<Type>(TestAction); @override
bool get enabled => _enabled;
bool _enabled = true;
set enabled(bool value) {
if (_enabled == value) {
return;
}
_enabled = value;
notifyActionListeners();
}
void _testInvoke(FocusNode node, Intent invocation) => invoke(node, invocation); @override
void addActionListener(ActionListenerCallback listener) {
super.addActionListener(listener);
listeners.add(listener);
}
@override
void removeActionListener(ActionListenerCallback listener) {
super.removeActionListener(listener);
listeners.remove(listener);
}
List<ActionListenerCallback> listeners = <ActionListenerCallback>[];
void _testInvoke(TestIntent intent) => invoke(intent);
} }
class TestDispatcher extends ActionDispatcher { class TestDispatcher extends ActionDispatcher {
...@@ -28,9 +62,9 @@ class TestDispatcher extends ActionDispatcher { ...@@ -28,9 +62,9 @@ class TestDispatcher extends ActionDispatcher {
final PostInvokeCallback postInvoke; final PostInvokeCallback postInvoke;
@override @override
bool invokeAction(Action action, Intent intent, {FocusNode focusNode}) { Object invokeAction(Action<Intent> action, Intent intent, [BuildContext context]) {
final bool result = super.invokeAction(action, intent, focusNode: focusNode); final Object result = super.invokeAction(action, intent, context);
postInvoke?.call(action: action, intent: intent, focusNode: focusNode, dispatcher: this); postInvoke?.call(action: action, intent: intent, dispatcher: this);
return result; return result;
} }
} }
...@@ -40,57 +74,48 @@ class TestDispatcher1 extends TestDispatcher { ...@@ -40,57 +74,48 @@ class TestDispatcher1 extends TestDispatcher {
} }
void main() { void main() {
test('Action passes parameters on when invoked.', () { testWidgets('CallbackAction passes correct intent when invoked.', (WidgetTester tester) async {
bool invoked = false; Intent passedIntent;
FocusNode passedNode; final TestAction action = TestAction(onInvoke: (Intent intent) {
final TestAction action = TestAction(onInvoke: (FocusNode node, Intent invocation) { passedIntent = intent;
invoked = true; return true;
passedNode = node;
}); });
final FocusNode testNode = FocusNode(debugLabel: 'Test Node'); const TestIntent intent = TestIntent();
action._testInvoke(testNode, null); action._testInvoke(intent);
expect(passedNode, equals(testNode)); expect(passedIntent, equals(intent));
expect(action.intentKey, equals(TestAction.key));
expect(invoked, isTrue);
}); });
group(ActionDispatcher, () { group(ActionDispatcher, () {
test('ActionDispatcher invokes actions when asked.', () { testWidgets('ActionDispatcher invokes actions when asked.', (WidgetTester tester) async {
await tester.pumpWidget(Container());
bool invoked = false; bool invoked = false;
FocusNode passedNode;
const ActionDispatcher dispatcher = ActionDispatcher(); const ActionDispatcher dispatcher = ActionDispatcher();
final FocusNode testNode = FocusNode(debugLabel: 'Test Node'); final Object result = dispatcher.invokeAction(
final bool result = dispatcher.invokeAction(
TestAction( TestAction(
onInvoke: (FocusNode node, Intent invocation) { onInvoke: (Intent intent) {
invoked = true; invoked = true;
passedNode = node; return invoked;
}, },
), ),
const Intent(TestAction.key), const TestIntent(),
focusNode: testNode,
); );
expect(passedNode, equals(testNode));
expect(result, isTrue); expect(result, isTrue);
expect(invoked, isTrue); expect(invoked, isTrue);
}); });
}); });
group(Actions, () { group(Actions, () {
Intent invokedIntent; Intent invokedIntent;
Action invokedAction; Action<Intent> invokedAction;
FocusNode invokedNode;
ActionDispatcher invokedDispatcher; ActionDispatcher invokedDispatcher;
void collect({Action action, Intent intent, FocusNode focusNode, ActionDispatcher dispatcher}) { void collect({Action<Intent> action, Intent intent, ActionDispatcher dispatcher}) {
invokedIntent = intent; invokedIntent = intent;
invokedAction = action; invokedAction = action;
invokedNode = focusNode;
invokedDispatcher = dispatcher; invokedDispatcher = dispatcher;
} }
void clear() { void clear() {
invokedIntent = null; invokedIntent = null;
invokedAction = null; invokedAction = null;
invokedNode = null;
invokedDispatcher = null; invokedDispatcher = null;
} }
...@@ -99,64 +124,55 @@ void main() { ...@@ -99,64 +124,55 @@ void main() {
testWidgets('Actions widget can invoke actions with default dispatcher', (WidgetTester tester) async { testWidgets('Actions widget can invoke actions with default dispatcher', (WidgetTester tester) async {
final GlobalKey containerKey = GlobalKey(); final GlobalKey containerKey = GlobalKey();
bool invoked = false; bool invoked = false;
FocusNode passedNode;
final FocusNode testNode = FocusNode(debugLabel: 'Test Node');
await tester.pumpWidget( await tester.pumpWidget(
Actions( Actions(
actions: <LocalKey, ActionFactory>{ actions: <Type, Action<Intent>>{
TestAction.key: () => TestAction( TestIntent: TestAction(
onInvoke: (FocusNode node, Intent invocation) { onInvoke: (Intent intent) {
invoked = true; invoked = true;
passedNode = node; return invoked;
}, },
), ),
}, },
child: Container(key: containerKey), child: Container(key: containerKey),
), ),
); );
await tester.pump(); await tester.pump();
final bool result = Actions.invoke( final Object result = Actions.invoke(
containerKey.currentContext, containerKey.currentContext,
const Intent(TestAction.key), const TestIntent(),
focusNode: testNode,
); );
expect(passedNode, equals(testNode));
expect(result, isTrue); expect(result, isTrue);
expect(invoked, isTrue); expect(invoked, isTrue);
}); });
testWidgets('Actions widget can invoke actions with custom dispatcher', (WidgetTester tester) async { testWidgets('Actions widget can invoke actions with custom dispatcher', (WidgetTester tester) async {
final GlobalKey containerKey = GlobalKey(); final GlobalKey containerKey = GlobalKey();
bool invoked = false; bool invoked = false;
const Intent intent = Intent(TestAction.key); const TestIntent intent = TestIntent();
FocusNode passedNode; final Action<Intent> testAction = TestAction(
final FocusNode testNode = FocusNode(debugLabel: 'Test Node'); onInvoke: (Intent intent) {
final Action testAction = TestAction(
onInvoke: (FocusNode node, Intent intent) {
invoked = true; invoked = true;
passedNode = node; return invoked;
}, },
); );
await tester.pumpWidget( await tester.pumpWidget(
Actions( Actions(
dispatcher: TestDispatcher(postInvoke: collect), dispatcher: TestDispatcher(postInvoke: collect),
actions: <LocalKey, ActionFactory>{ actions: <Type, Action<Intent>>{
TestAction.key: () => testAction, TestIntent: testAction,
}, },
child: Container(key: containerKey), child: Container(key: containerKey),
), ),
); );
await tester.pump(); await tester.pump();
final bool result = Actions.invoke( final Object result = Actions.invoke<TestIntent>(
containerKey.currentContext, containerKey.currentContext,
intent, intent,
focusNode: testNode,
); );
expect(passedNode, equals(testNode));
expect(invokedNode, equals(testNode));
expect(result, isTrue); expect(result, isTrue);
expect(invoked, isTrue); expect(invoked, isTrue);
expect(invokedIntent, equals(intent)); expect(invokedIntent, equals(intent));
...@@ -164,38 +180,33 @@ void main() { ...@@ -164,38 +180,33 @@ void main() {
testWidgets('Actions can invoke actions in ancestor dispatcher', (WidgetTester tester) async { testWidgets('Actions can invoke actions in ancestor dispatcher', (WidgetTester tester) async {
final GlobalKey containerKey = GlobalKey(); final GlobalKey containerKey = GlobalKey();
bool invoked = false; bool invoked = false;
const Intent intent = Intent(TestAction.key); const TestIntent intent = TestIntent();
FocusNode passedNode; final Action<Intent> testAction = TestAction(
final FocusNode testNode = FocusNode(debugLabel: 'Test Node'); onInvoke: (Intent intent) {
final Action testAction = TestAction(
onInvoke: (FocusNode node, Intent invocation) {
invoked = true; invoked = true;
passedNode = node; return invoked;
}, },
); );
await tester.pumpWidget( await tester.pumpWidget(
Actions( Actions(
dispatcher: TestDispatcher1(postInvoke: collect), dispatcher: TestDispatcher1(postInvoke: collect),
actions: <LocalKey, ActionFactory>{ actions: <Type, Action<Intent>>{
TestAction.key: () => testAction, TestIntent: testAction,
}, },
child: Actions( child: Actions(
dispatcher: TestDispatcher(postInvoke: collect), dispatcher: TestDispatcher(postInvoke: collect),
actions: const <LocalKey, ActionFactory>{}, actions: const <Type, Action<Intent>>{},
child: Container(key: containerKey), child: Container(key: containerKey),
), ),
), ),
); );
await tester.pump(); await tester.pump();
final bool result = Actions.invoke( final Object result = Actions.invoke<TestIntent>(
containerKey.currentContext, containerKey.currentContext,
intent, intent,
focusNode: testNode,
); );
expect(passedNode, equals(testNode));
expect(invokedNode, equals(testNode));
expect(result, isTrue); expect(result, isTrue);
expect(invoked, isTrue); expect(invoked, isTrue);
expect(invokedIntent, equals(intent)); expect(invokedIntent, equals(intent));
...@@ -205,37 +216,32 @@ void main() { ...@@ -205,37 +216,32 @@ void main() {
testWidgets("Actions can invoke actions in ancestor dispatcher if a lower one isn't specified", (WidgetTester tester) async { testWidgets("Actions can invoke actions in ancestor dispatcher if a lower one isn't specified", (WidgetTester tester) async {
final GlobalKey containerKey = GlobalKey(); final GlobalKey containerKey = GlobalKey();
bool invoked = false; bool invoked = false;
const Intent intent = Intent(TestAction.key); const TestIntent intent = TestIntent();
FocusNode passedNode; final Action<Intent> testAction = TestAction(
final FocusNode testNode = FocusNode(debugLabel: 'Test Node'); onInvoke: (Intent intent) {
final Action testAction = TestAction(
onInvoke: (FocusNode node, Intent invocation) {
invoked = true; invoked = true;
passedNode = node; return invoked;
}, },
); );
await tester.pumpWidget( await tester.pumpWidget(
Actions( Actions(
dispatcher: TestDispatcher1(postInvoke: collect), dispatcher: TestDispatcher1(postInvoke: collect),
actions: <LocalKey, ActionFactory>{ actions: <Type, Action<Intent>>{
TestAction.key: () => testAction, TestIntent: testAction,
}, },
child: Actions( child: Actions(
actions: const <LocalKey, ActionFactory>{}, actions: const <Type, Action<Intent>>{},
child: Container(key: containerKey), child: Container(key: containerKey),
), ),
), ),
); );
await tester.pump(); await tester.pump();
final bool result = Actions.invoke( final Object result = Actions.invoke<TestIntent>(
containerKey.currentContext, containerKey.currentContext,
intent, intent,
focusNode: testNode,
); );
expect(passedNode, equals(testNode));
expect(invokedNode, equals(testNode));
expect(result, isTrue); expect(result, isTrue);
expect(invoked, isTrue); expect(invoked, isTrue);
expect(invokedIntent, equals(intent)); expect(invokedIntent, equals(intent));
...@@ -249,7 +255,7 @@ void main() { ...@@ -249,7 +255,7 @@ void main() {
await tester.pumpWidget( await tester.pumpWidget(
Actions( Actions(
dispatcher: testDispatcher, dispatcher: testDispatcher,
actions: const <LocalKey, ActionFactory>{}, actions: const <Type, Action<Intent>>{},
child: Container(key: containerKey), child: Container(key: containerKey),
), ),
); );
...@@ -261,15 +267,64 @@ void main() { ...@@ -261,15 +267,64 @@ void main() {
); );
expect(dispatcher, equals(testDispatcher)); expect(dispatcher, equals(testDispatcher));
}); });
testWidgets('Action can be found with find', (WidgetTester tester) async {
final GlobalKey containerKey = GlobalKey();
final ActionDispatcher testDispatcher = TestDispatcher1(postInvoke: collect);
bool invoked = false;
final TestAction testAction = TestAction(
onInvoke: (Intent intent) {
invoked = true;
return invoked;
},
);
await tester.pumpWidget(
Actions(
dispatcher: testDispatcher,
actions: <Type, Action<Intent>>{
TestIntent: testAction,
},
child: Actions(
actions: const <Type, Action<Intent>>{},
child: Container(key: containerKey),
),
),
);
await tester.pump();
expect(Actions.find<TestIntent>(containerKey.currentContext), equals(testAction));
expect(() => Actions.find<DoNothingIntent>(containerKey.currentContext), throwsAssertionError);
expect(Actions.find<DoNothingIntent>(containerKey.currentContext, nullOk: true), isNull);
await tester.pumpWidget(
Actions(
dispatcher: testDispatcher,
actions: <Type, Action<Intent>>{
TestIntent: testAction,
},
child: Container(
child: Actions(
actions: const <Type, Action<Intent>>{},
child: Container(key: containerKey),
),
),
),
);
await tester.pump();
expect(Actions.find<TestIntent>(containerKey.currentContext), equals(testAction));
expect(() => Actions.find<DoNothingIntent>(containerKey.currentContext), throwsAssertionError);
expect(Actions.find<DoNothingIntent>(containerKey.currentContext, nullOk: true), isNull);
});
testWidgets('FocusableActionDetector keeps track of focus and hover even when disabled.', (WidgetTester tester) async { testWidgets('FocusableActionDetector keeps track of focus and hover even when disabled.', (WidgetTester tester) async {
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
final GlobalKey containerKey = GlobalKey(); final GlobalKey containerKey = GlobalKey();
bool invoked = false; bool invoked = false;
const Intent intent = Intent(TestAction.key); const Intent intent = TestIntent();
final FocusNode focusNode = FocusNode(debugLabel: 'Test Node'); final FocusNode focusNode = FocusNode(debugLabel: 'Test Node');
final Action testAction = TestAction( final Action<Intent> testAction = TestAction(
onInvoke: (FocusNode node, Intent invocation) { onInvoke: (Intent intent) {
invoked = true; invoked = true;
return invoked;
}, },
); );
bool hovering = false; bool hovering = false;
...@@ -280,15 +335,15 @@ void main() { ...@@ -280,15 +335,15 @@ void main() {
Center( Center(
child: Actions( child: Actions(
dispatcher: TestDispatcher1(postInvoke: collect), dispatcher: TestDispatcher1(postInvoke: collect),
actions: const <LocalKey, ActionFactory>{}, actions: const <Type, Action<Intent>>{},
child: FocusableActionDetector( child: FocusableActionDetector(
enabled: enabled, enabled: enabled,
focusNode: focusNode, focusNode: focusNode,
shortcuts: <LogicalKeySet, Intent>{ shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.enter): intent, LogicalKeySet(LogicalKeyboardKey.enter): intent,
}, },
actions: <LocalKey, ActionFactory>{ actions: <Type, Action<Intent>>{
TestAction.key: () => testAction, TestIntent: testAction,
}, },
onShowHoverHighlight: (bool value) => hovering = value, onShowHoverHighlight: (bool value) => hovering = value,
onShowFocusHighlight: (bool value) => focusing = value, onShowFocusHighlight: (bool value) => focusing = value,
...@@ -299,6 +354,7 @@ void main() { ...@@ -299,6 +354,7 @@ void main() {
); );
return tester.pump(); return tester.pump();
} }
await buildTest(true); await buildTest(true);
focusNode.requestFocus(); focusNode.requestFocus();
await tester.pump(); await tester.pump();
...@@ -330,11 +386,178 @@ void main() { ...@@ -330,11 +386,178 @@ void main() {
expect(focusing, isFalse); expect(focusing, isFalse);
}); });
}); });
group('Listening', () {
testWidgets('can listen to enabled state of Actions', (WidgetTester tester) async {
final GlobalKey containerKey = GlobalKey();
bool invoked1 = false;
bool invoked2 = false;
bool invoked3 = false;
final TestAction action1 = TestAction(
onInvoke: (Intent intent) {
invoked1 = true;
return invoked1;
},
);
final TestAction action2 = TestAction(
onInvoke: (Intent intent) {
invoked2 = true;
return invoked2;
},
);
final TestAction action3 = TestAction(
onInvoke: (Intent intent) {
invoked3 = true;
return invoked3;
},
);
bool enabled1 = true;
action1.addActionListener((Action<Intent> action) => enabled1 = action.enabled);
action1.enabled = false;
expect(enabled1, isFalse);
bool enabled2 = true;
action2.addActionListener((Action<Intent> action) => enabled2 = action.enabled);
action2.enabled = false;
expect(enabled2, isFalse);
bool enabled3 = true;
action3.addActionListener((Action<Intent> action) => enabled3 = action.enabled);
action3.enabled = false;
expect(enabled3, isFalse);
await tester.pumpWidget(
Actions(
actions: <Type, Action<TestIntent>>{
TestIntent: action1,
SecondTestIntent: action2,
},
child: Actions(
actions: <Type, Action<TestIntent>>{
ThirdTestIntent: action3,
},
child: Container(key: containerKey),
),
),
);
Object result = Actions.invoke(
containerKey.currentContext,
const TestIntent(),
);
expect(enabled1, isFalse);
expect(result, isFalse);
expect(invoked1, isFalse);
action1.enabled = true;
result = Actions.invoke(
containerKey.currentContext,
const TestIntent(),
);
expect(enabled1, isTrue);
expect(result, isTrue);
expect(invoked1, isTrue);
bool enabledChanged;
await tester.pumpWidget(
Actions(
actions: <Type, Action<Intent>>{
TestIntent: action1,
SecondTestIntent: action2,
},
child: ActionListener(
listener: (Action<Intent> action) => enabledChanged = action.enabled,
action: action2,
child: Actions(
actions: <Type, Action<Intent>>{
ThirdTestIntent: action3,
},
child: Container(key: containerKey),
),
),
),
);
await tester.pump();
result = Actions.invoke<TestIntent>(
containerKey.currentContext,
const SecondTestIntent(),
);
expect(enabledChanged, isNull);
expect(enabled2, isFalse);
expect(result, isFalse);
expect(invoked2, isFalse);
action2.enabled = true;
expect(enabledChanged, isTrue);
result = Actions.invoke<TestIntent>(
containerKey.currentContext,
const SecondTestIntent(),
);
expect(enabled2, isTrue);
expect(result, isTrue);
expect(invoked2, isTrue);
await tester.pumpWidget(
Actions(
actions: <Type, Action<Intent>>{
TestIntent: action1,
},
child: Actions(
actions: <Type, Action<Intent>>{
ThirdTestIntent: action3,
},
child: Container(key: containerKey),
),
),
);
expect(action1.listeners.length, equals(2));
expect(action2.listeners.length, equals(1));
expect(action3.listeners.length, equals(2));
await tester.pumpWidget(
Actions(
actions: <Type, Action<Intent>>{
TestIntent: action1,
ThirdTestIntent: action3,
},
child: Container(key: containerKey),
),
);
expect(action1.listeners.length, equals(2));
expect(action2.listeners.length, equals(1));
expect(action3.listeners.length, equals(2));
await tester.pumpWidget(
Actions(
actions: <Type, Action<Intent>>{
TestIntent: action1,
},
child: Container(key: containerKey),
),
);
expect(action1.listeners.length, equals(2));
expect(action2.listeners.length, equals(1));
expect(action3.listeners.length, equals(1));
await tester.pumpWidget(Container());
await tester.pump();
expect(action1.listeners.length, equals(1));
expect(action2.listeners.length, equals(1));
expect(action3.listeners.length, equals(1));
});
});
group('Diagnostics', () { group('Diagnostics', () {
testWidgets('default Intent debugFillProperties', (WidgetTester tester) async { testWidgets('default Intent debugFillProperties', (WidgetTester tester) async {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
const Intent(ValueKey<String>('foo')).debugFillProperties(builder); // ignore: invalid_use_of_protected_member
const TestIntent().debugFillProperties(builder);
final List<String> description = builder.properties final List<String> description = builder.properties
.where((DiagnosticsNode node) { .where((DiagnosticsNode node) {
...@@ -343,30 +566,13 @@ void main() { ...@@ -343,30 +566,13 @@ void main() {
.map((DiagnosticsNode node) => node.toString()) .map((DiagnosticsNode node) => node.toString())
.toList(); .toList();
expect(description, equals(<String>["key: [<'foo'>]"])); expect(description, isEmpty);
});
testWidgets('CallbackAction debugFillProperties', (WidgetTester tester) async {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
CallbackAction(
const ValueKey<String>('foo'),
onInvoke: (FocusNode node, Intent intent) {},
).debugFillProperties(builder);
final List<String> description = builder.properties
.where((DiagnosticsNode node) {
return !node.isFiltered(DiagnosticLevel.info);
})
.map((DiagnosticsNode node) => node.toString())
.toList();
expect(description, equals(<String>["intentKey: [<'foo'>]"]));
}); });
testWidgets('default Actions debugFillProperties', (WidgetTester tester) async { testWidgets('default Actions debugFillProperties', (WidgetTester tester) async {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
Actions( Actions(
actions: const <LocalKey, ActionFactory>{}, actions: const <Type, Action<Intent>>{},
dispatcher: const ActionDispatcher(), dispatcher: const ActionDispatcher(),
child: Container(), child: Container(),
).debugFillProperties(builder); ).debugFillProperties(builder);
...@@ -378,6 +584,7 @@ void main() { ...@@ -378,6 +584,7 @@ void main() {
.map((DiagnosticsNode node) => node.toString()) .map((DiagnosticsNode node) => node.toString())
.toList(); .toList();
expect(description.length, equals(2));
expect(description[0], equalsIgnoringHashCodes('dispatcher: ActionDispatcher#00000')); expect(description[0], equalsIgnoringHashCodes('dispatcher: ActionDispatcher#00000'));
expect(description[1], equals('actions: {}')); expect(description[1], equals('actions: {}'));
}); });
...@@ -387,8 +594,8 @@ void main() { ...@@ -387,8 +594,8 @@ void main() {
Actions( Actions(
key: const ValueKey<String>('foo'), key: const ValueKey<String>('foo'),
dispatcher: const ActionDispatcher(), dispatcher: const ActionDispatcher(),
actions: <LocalKey, ActionFactory>{ actions: <Type, Action<Intent>>{
const ValueKey<String>('bar'): () => TestAction(onInvoke: (FocusNode node, Intent intent) {}), TestIntent: TestAction(onInvoke: (Intent intent) => null),
}, },
child: Container(key: const ValueKey<String>('baz')), child: Container(key: const ValueKey<String>('baz')),
).debugFillProperties(builder); ).debugFillProperties(builder);
...@@ -400,8 +607,9 @@ void main() { ...@@ -400,8 +607,9 @@ void main() {
.map((DiagnosticsNode node) => node.toString()) .map((DiagnosticsNode node) => node.toString())
.toList(); .toList();
expect(description.length, equals(2));
expect(description[0], equalsIgnoringHashCodes('dispatcher: ActionDispatcher#00000')); expect(description[0], equalsIgnoringHashCodes('dispatcher: ActionDispatcher#00000'));
expect(description[1], equals("actions: {[<'bar'>]: Closure: () => TestAction}")); expect(description[1], equalsIgnoringHashCodes('actions: {TestIntent: TestAction#00000}'));
}, skip: isBrowser); }, skip: isBrowser);
}); });
} }
...@@ -8,15 +8,19 @@ import 'package:flutter/services.dart'; ...@@ -8,15 +8,19 @@ import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
class TestAction extends Action { class TestIntent extends Intent {
TestAction() : super(key); const TestIntent();
}
class TestAction extends Action<Intent> {
TestAction();
static const LocalKey key = ValueKey<Type>(TestAction); static const LocalKey key = ValueKey<Type>(TestAction);
int calls = 0; int calls = 0;
@override @override
void invoke(FocusNode node, Intent intent) { void invoke(Intent intent) {
calls += 1; calls += 1;
} }
} }
...@@ -67,11 +71,11 @@ void main() { ...@@ -67,11 +71,11 @@ void main() {
await tester.pumpWidget( await tester.pumpWidget(
WidgetsApp( WidgetsApp(
key: key, key: key,
actions: <LocalKey, ActionFactory>{ actions: <Type, Action<Intent>>{
TestAction.key: () => action, TestIntent: action,
}, },
shortcuts: <LogicalKeySet, Intent> { shortcuts: <LogicalKeySet, Intent> {
LogicalKeySet(LogicalKeyboardKey.space): const Intent(TestAction.key), LogicalKeySet(LogicalKeyboardKey.space): const TestIntent(),
}, },
builder: (BuildContext context, Widget child) { builder: (BuildContext context, Widget child) {
return Material( return Material(
......
...@@ -9,13 +9,13 @@ import 'package:flutter/src/services/keyboard_key.dart'; ...@@ -9,13 +9,13 @@ import 'package:flutter/src/services/keyboard_key.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
typedef PostInvokeCallback = void Function({Action action, Intent intent, FocusNode focusNode, ActionDispatcher dispatcher}); typedef PostInvokeCallback = void Function({Action<Intent> action, Intent intent, BuildContext context, ActionDispatcher dispatcher});
class TestAction extends CallbackAction { class TestAction extends CallbackAction<TestIntent> {
const TestAction({ TestAction({
@required OnInvokeCallback onInvoke, @required OnInvokeCallback onInvoke,
}) : assert(onInvoke != null), }) : assert(onInvoke != null),
super(key, onInvoke: onInvoke); super(onInvoke: onInvoke);
static const LocalKey key = ValueKey<Type>(TestAction); static const LocalKey key = ValueKey<Type>(TestAction);
} }
...@@ -26,15 +26,15 @@ class TestDispatcher extends ActionDispatcher { ...@@ -26,15 +26,15 @@ class TestDispatcher extends ActionDispatcher {
final PostInvokeCallback postInvoke; final PostInvokeCallback postInvoke;
@override @override
bool invokeAction(Action action, Intent intent, {FocusNode focusNode}) { Object invokeAction(Action<TestIntent> action, Intent intent, [BuildContext context]) {
final bool result = super.invokeAction(action, intent, focusNode: focusNode); final Object result = super.invokeAction(action, intent, context);
postInvoke?.call(action: action, intent: intent, focusNode: focusNode, dispatcher: this); postInvoke?.call(action: action, intent: intent, context: context, dispatcher: this);
return result; return result;
} }
} }
class TestIntent extends Intent { class TestIntent extends Intent {
const TestIntent() : super(TestAction.key); const TestIntent();
} }
class TestShortcutManager extends ShortcutManager { class TestShortcutManager extends ShortcutManager {
...@@ -210,10 +210,11 @@ void main() { ...@@ -210,10 +210,11 @@ void main() {
bool invoked = false; bool invoked = false;
await tester.pumpWidget( await tester.pumpWidget(
Actions( Actions(
actions: <LocalKey, ActionFactory>{ actions: <Type, Action<Intent>>{
TestAction.key: () => TestAction( TestIntent: TestAction(
onInvoke: (FocusNode node, Intent intent) { onInvoke: (Intent intent) {
invoked = true; invoked = true;
return true;
}, },
), ),
}, },
...@@ -247,10 +248,11 @@ void main() { ...@@ -247,10 +248,11 @@ void main() {
LogicalKeySet(LogicalKeyboardKey.shift): const TestIntent(), LogicalKeySet(LogicalKeyboardKey.shift): const TestIntent(),
}, },
child: Actions( child: Actions(
actions: <LocalKey, ActionFactory>{ actions: <Type, Action<Intent>>{
TestAction.key: () => TestAction( TestIntent: TestAction(
onInvoke: (FocusNode node, Intent intent) { onInvoke: (Intent intent) {
invoked = true; invoked = true;
return invoked;
}, },
), ),
}, },
...@@ -285,10 +287,11 @@ void main() { ...@@ -285,10 +287,11 @@ void main() {
LogicalKeySet(LogicalKeyboardKey.shift): const TestIntent(), LogicalKeySet(LogicalKeyboardKey.shift): const TestIntent(),
}, },
child: Actions( child: Actions(
actions: <LocalKey, ActionFactory>{ actions: <Type, Action<Intent>>{
TestAction.key: () => TestAction( TestIntent: TestAction(
onInvoke: (FocusNode node, Intent intent) { onInvoke: (Intent intent) {
invoked = true; invoked = true;
return invoked;
}, },
), ),
}, },
...@@ -317,7 +320,7 @@ void main() { ...@@ -317,7 +320,7 @@ void main() {
Shortcuts(shortcuts: <LogicalKeySet, Intent>{LogicalKeySet( Shortcuts(shortcuts: <LogicalKeySet, Intent>{LogicalKeySet(
LogicalKeyboardKey.shift, LogicalKeyboardKey.shift,
LogicalKeyboardKey.keyA, LogicalKeyboardKey.keyA,
) : const Intent(ActivateAction.key), ) : const ActivateIntent(),
LogicalKeySet( LogicalKeySet(
LogicalKeyboardKey.shift, LogicalKeyboardKey.shift,
LogicalKeyboardKey.arrowRight, LogicalKeyboardKey.arrowRight,
...@@ -334,7 +337,7 @@ void main() { ...@@ -334,7 +337,7 @@ void main() {
expect( expect(
description[0], description[0],
equalsIgnoringHashCodes( equalsIgnoringHashCodes(
'shortcuts: {{Shift + Key A}: Intent#00000(key: [<ActivateAction>]), {Shift + Arrow Right}: DirectionalFocusIntent#00000(key: [<DirectionalFocusAction>])}')); 'shortcuts: {{Shift + Key A}: ActivateIntent#00000, {Shift + Arrow Right}: DirectionalFocusIntent#00000}'));
}); });
test('Shortcuts diagnostics work when debugLabel specified.', () { test('Shortcuts diagnostics work when debugLabel specified.', () {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
...@@ -345,7 +348,7 @@ void main() { ...@@ -345,7 +348,7 @@ void main() {
LogicalKeySet( LogicalKeySet(
LogicalKeyboardKey.keyA, LogicalKeyboardKey.keyA,
LogicalKeyboardKey.keyB, LogicalKeyboardKey.keyB,
): const Intent(ActivateAction.key) ): const ActivateIntent(),
}, },
).debugFillProperties(builder); ).debugFillProperties(builder);
...@@ -368,7 +371,7 @@ void main() { ...@@ -368,7 +371,7 @@ void main() {
LogicalKeySet( LogicalKeySet(
LogicalKeyboardKey.keyA, LogicalKeyboardKey.keyA,
LogicalKeyboardKey.keyB, LogicalKeyboardKey.keyB,
): const Intent(ActivateAction.key) ): const ActivateIntent(),
}, },
).debugFillProperties(builder); ).debugFillProperties(builder);
...@@ -381,7 +384,7 @@ void main() { ...@@ -381,7 +384,7 @@ void main() {
expect(description.length, equals(2)); expect(description.length, equals(2));
expect(description[0], equalsIgnoringHashCodes('manager: ShortcutManager#00000(shortcuts: {})')); expect(description[0], equalsIgnoringHashCodes('manager: ShortcutManager#00000(shortcuts: {})'));
expect(description[1], equalsIgnoringHashCodes('shortcuts: {{Key A + Key B}: Intent#00000(key: [<ActivateAction>])}')); expect(description[1], equalsIgnoringHashCodes('shortcuts: {{Key A + Key B}: ActivateIntent#00000}'));
}); });
}); });
} }
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