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
This commit is contained in:
parent
c663cd55a7
commit
0f68b46f6a
@ -2,6 +2,9 @@
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'dart:collection';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@ -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
|
||||
|
||||
/// An [ActionDispatcher] subclass that manages the invocation of undoable
|
||||
@ -29,10 +69,10 @@ class UndoableActionDispatcher extends ActionDispatcher implements Listenable {
|
||||
|
||||
// A stack of actions that have been performed. The most recent action
|
||||
// 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
|
||||
// at the end of the list.
|
||||
final List<UndoableAction> _undoneActions = <UndoableAction>[];
|
||||
final List<Memento> _undoneActions = <Memento>[];
|
||||
|
||||
static const int _defaultMaxUndoLevels = 1000;
|
||||
|
||||
@ -71,11 +111,11 @@ class UndoableActionDispatcher extends ActionDispatcher implements Listenable {
|
||||
}
|
||||
|
||||
@override
|
||||
bool invokeAction(Action action, Intent intent, {FocusNode focusNode}) {
|
||||
final bool result = super.invokeAction(action, intent, focusNode: focusNode);
|
||||
Object invokeAction(Action<Intent> action, Intent intent, [BuildContext context]) {
|
||||
final Object result = super.invokeAction(action, intent, context);
|
||||
print('Invoking ${action is UndoableAction ? 'undoable ' : ''}$intent as $action: $this ');
|
||||
if (action is UndoableAction) {
|
||||
_completedActions.add(action);
|
||||
_completedActions.addLast(result as Memento);
|
||||
_undoneActions.clear();
|
||||
_pruneActions();
|
||||
notifyListeners();
|
||||
@ -86,15 +126,14 @@ class UndoableActionDispatcher extends ActionDispatcher implements Listenable {
|
||||
// Enforces undo level limit.
|
||||
void _pruneActions() {
|
||||
while (_completedActions.length > _maxUndoLevels) {
|
||||
_completedActions.removeAt(0);
|
||||
_completedActions.removeFirst();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if there is an action on the stack that can be undone.
|
||||
bool get canUndo {
|
||||
if (_completedActions.isNotEmpty) {
|
||||
final Intent lastIntent = _completedActions.last.invocationIntent;
|
||||
return lastIntent.isEnabled(primaryFocus.context);
|
||||
return _completedActions.first.canUndo;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@ -102,8 +141,7 @@ class UndoableActionDispatcher extends ActionDispatcher implements Listenable {
|
||||
/// Returns true if an action that has been undone can be re-invoked.
|
||||
bool get canRedo {
|
||||
if (_undoneActions.isNotEmpty) {
|
||||
final Intent lastIntent = _undoneActions.last.invocationIntent;
|
||||
return lastIntent.isEnabled(primaryFocus?.context);
|
||||
return _undoneActions.first.canRedo;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@ -116,9 +154,9 @@ class UndoableActionDispatcher extends ActionDispatcher implements Listenable {
|
||||
if (!canUndo) {
|
||||
return false;
|
||||
}
|
||||
final UndoableAction action = _completedActions.removeLast();
|
||||
action.undo();
|
||||
_undoneActions.add(action);
|
||||
final Memento memento = _completedActions.removeLast();
|
||||
memento.undo();
|
||||
_undoneActions.add(memento);
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
@ -131,9 +169,9 @@ class UndoableActionDispatcher extends ActionDispatcher implements Listenable {
|
||||
if (!canRedo) {
|
||||
return false;
|
||||
}
|
||||
final UndoableAction action = _undoneActions.removeLast();
|
||||
action.invoke(action.invocationNode, action.invocationIntent);
|
||||
_completedActions.add(action);
|
||||
final Memento memento = _undoneActions.removeLast();
|
||||
final Memento replacement = memento.redo();
|
||||
_completedActions.add(replacement);
|
||||
_pruneActions();
|
||||
notifyListeners();
|
||||
return true;
|
||||
@ -144,71 +182,50 @@ class UndoableActionDispatcher extends ActionDispatcher implements Listenable {
|
||||
super.debugFillProperties(properties);
|
||||
properties.add(IntProperty('undoable items', _completedActions.length));
|
||||
properties.add(IntProperty('redoable items', _undoneActions.length));
|
||||
properties.add(IterableProperty<UndoableAction>('undo stack', _completedActions));
|
||||
properties.add(IterableProperty<UndoableAction>('redo stack', _undoneActions));
|
||||
properties.add(IterableProperty<Memento>('undo stack', _completedActions));
|
||||
properties.add(IterableProperty<Memento>('redo stack', _undoneActions));
|
||||
}
|
||||
}
|
||||
|
||||
class UndoIntent extends Intent {
|
||||
const UndoIntent() : super(kUndoActionKey);
|
||||
const UndoIntent();
|
||||
}
|
||||
|
||||
class UndoAction extends Action<UndoIntent> {
|
||||
@override
|
||||
bool get enabled {
|
||||
final UndoableActionDispatcher manager = Actions.of(primaryFocus?.context ?? FocusDemo.appKey.currentContext, nullOk: true) as UndoableActionDispatcher;
|
||||
return manager.canUndo;
|
||||
}
|
||||
|
||||
@override
|
||||
bool isEnabled(BuildContext context) {
|
||||
final UndoableActionDispatcher manager = Actions.of(context, nullOk: true) as UndoableActionDispatcher;
|
||||
return manager.canUndo;
|
||||
void invoke(UndoIntent intent) {
|
||||
final UndoableActionDispatcher manager = Actions.of(primaryFocus?.context ?? FocusDemo.appKey.currentContext, nullOk: true) as UndoableActionDispatcher;
|
||||
manager?.undo();
|
||||
}
|
||||
}
|
||||
|
||||
class RedoIntent extends Intent {
|
||||
const RedoIntent() : super(kRedoActionKey);
|
||||
const RedoIntent();
|
||||
}
|
||||
|
||||
class RedoAction extends Action<RedoIntent> {
|
||||
@override
|
||||
bool get enabled {
|
||||
final UndoableActionDispatcher manager = Actions.of(primaryFocus.context, nullOk: true) as UndoableActionDispatcher;
|
||||
return manager.canRedo;
|
||||
}
|
||||
|
||||
@override
|
||||
bool isEnabled(BuildContext context) {
|
||||
final UndoableActionDispatcher manager = Actions.of(context, nullOk: true) as UndoableActionDispatcher;
|
||||
return manager.canRedo;
|
||||
RedoAction invoke(RedoIntent intent) {
|
||||
final UndoableActionDispatcher manager = Actions.of(primaryFocus.context, nullOk: true) as UndoableActionDispatcher;
|
||||
manager?.redo();
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
const LocalKey kUndoActionKey = ValueKey<String>('Undo');
|
||||
const Intent kUndoIntent = UndoIntent();
|
||||
final Action kUndoAction = CallbackAction(
|
||||
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();
|
||||
},
|
||||
);
|
||||
|
||||
/// An action that can be undone.
|
||||
abstract class UndoableAction extends Action {
|
||||
/// 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;
|
||||
|
||||
abstract class UndoableAction<T extends Intent> extends Action<T> {
|
||||
/// The [Intent] this action was originally invoked with.
|
||||
Intent get invocationIntent => _invocationTag;
|
||||
Intent _invocationTag;
|
||||
@ -216,110 +233,63 @@ abstract class UndoableAction extends Action {
|
||||
@protected
|
||||
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
|
||||
@mustCallSuper
|
||||
void invoke(FocusNode node, Intent intent) {
|
||||
invocationNode = node;
|
||||
void invoke(T intent) {
|
||||
invocationIntent = intent;
|
||||
}
|
||||
}
|
||||
|
||||
class UndoableFocusActionBase<T extends Intent> extends UndoableAction<T> {
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
super.debugFillProperties(properties);
|
||||
properties.add(DiagnosticsProperty<FocusNode>('invocationNode', invocationNode));
|
||||
@mustCallSuper
|
||||
Memento invoke(T intent) {
|
||||
super.invoke(intent);
|
||||
final FocusNode previousFocus = primaryFocus;
|
||||
return Memento(name: previousFocus.debugLabel, undo: () {
|
||||
previousFocus.requestFocus();
|
||||
}, redo: () {
|
||||
return invoke(intent);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class UndoableFocusActionBase extends UndoableAction {
|
||||
UndoableFocusActionBase(LocalKey name) : super(name);
|
||||
|
||||
FocusNode _previousFocus;
|
||||
|
||||
class UndoableRequestFocusAction extends UndoableFocusActionBase<RequestFocusIntent> {
|
||||
@override
|
||||
void invoke(FocusNode node, Intent intent) {
|
||||
super.invoke(node, intent);
|
||||
_previousFocus = primaryFocus;
|
||||
node.requestFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
void undo() {
|
||||
if (_previousFocus == null) {
|
||||
primaryFocus?.unfocus();
|
||||
return;
|
||||
}
|
||||
if (_previousFocus is FocusScopeNode) {
|
||||
// The only way a scope can be the _previousFocus is if there was no
|
||||
// focusedChild for the scope when we invoked this action, so we need to
|
||||
// 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 {
|
||||
UndoableRequestFocusAction() : super(RequestFocusAction.key);
|
||||
|
||||
@override
|
||||
void invoke(FocusNode node, Intent intent) {
|
||||
super.invoke(node, intent);
|
||||
node.requestFocus();
|
||||
Memento invoke(RequestFocusIntent intent) {
|
||||
final Memento memento = super.invoke(intent);
|
||||
intent.focusNode.requestFocus();
|
||||
return memento;
|
||||
}
|
||||
}
|
||||
|
||||
/// Actions for manipulating focus.
|
||||
class UndoableNextFocusAction extends UndoableFocusActionBase {
|
||||
UndoableNextFocusAction() : super(NextFocusAction.key);
|
||||
|
||||
class UndoableNextFocusAction extends UndoableFocusActionBase<NextFocusIntent> {
|
||||
@override
|
||||
void invoke(FocusNode node, Intent intent) {
|
||||
super.invoke(node, intent);
|
||||
node.nextFocus();
|
||||
Memento invoke(NextFocusIntent intent) {
|
||||
final Memento memento = super.invoke(intent);
|
||||
primaryFocus.nextFocus();
|
||||
return memento;
|
||||
}
|
||||
}
|
||||
|
||||
class UndoablePreviousFocusAction extends UndoableFocusActionBase {
|
||||
UndoablePreviousFocusAction() : super(PreviousFocusAction.key);
|
||||
|
||||
class UndoablePreviousFocusAction extends UndoableFocusActionBase<PreviousFocusIntent> {
|
||||
@override
|
||||
void invoke(FocusNode node, Intent intent) {
|
||||
super.invoke(node, intent);
|
||||
node.previousFocus();
|
||||
Memento invoke(PreviousFocusIntent intent) {
|
||||
final Memento memento = super.invoke(intent);
|
||||
primaryFocus.previousFocus();
|
||||
return memento;
|
||||
}
|
||||
}
|
||||
|
||||
class UndoableDirectionalFocusAction extends UndoableFocusActionBase {
|
||||
UndoableDirectionalFocusAction() : super(DirectionalFocusAction.key);
|
||||
|
||||
class UndoableDirectionalFocusAction extends UndoableFocusActionBase<DirectionalFocusIntent> {
|
||||
TraversalDirection direction;
|
||||
|
||||
@override
|
||||
void invoke(FocusNode node, DirectionalFocusIntent intent) {
|
||||
super.invoke(node, intent);
|
||||
final DirectionalFocusIntent args = intent;
|
||||
node.focusInDirection(args.direction);
|
||||
Memento invoke(DirectionalFocusIntent intent) {
|
||||
final Memento memento = super.invoke(intent);
|
||||
primaryFocus.focusInDirection(intent.direction);
|
||||
return memento;
|
||||
}
|
||||
}
|
||||
|
||||
@ -335,6 +305,7 @@ class DemoButton extends StatefulWidget {
|
||||
|
||||
class _DemoButtonState extends State<DemoButton> {
|
||||
FocusNode _focusNode;
|
||||
final GlobalKey _nameKey = GlobalKey();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -345,7 +316,7 @@ class _DemoButtonState extends State<DemoButton> {
|
||||
void _handleOnPressed() {
|
||||
print('Button ${widget.name} pressed.');
|
||||
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> {
|
||||
focusColor: Colors.red,
|
||||
hoverColor: Colors.blue,
|
||||
onPressed: () => _handleOnPressed(),
|
||||
child: Text(widget.name),
|
||||
child: Text(widget.name, key: _nameKey),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -370,6 +341,8 @@ class _DemoButtonState extends State<DemoButton> {
|
||||
class FocusDemo extends StatefulWidget {
|
||||
const FocusDemo({Key key}) : super(key: key);
|
||||
|
||||
static GlobalKey appKey = GlobalKey();
|
||||
|
||||
@override
|
||||
_FocusDemoState createState() => _FocusDemoState();
|
||||
}
|
||||
@ -415,22 +388,23 @@ class _FocusDemoState extends State<FocusDemo> {
|
||||
final TextTheme textTheme = Theme.of(context).textTheme;
|
||||
return Actions(
|
||||
dispatcher: dispatcher,
|
||||
actions: <LocalKey, ActionFactory>{
|
||||
RequestFocusAction.key: () => UndoableRequestFocusAction(),
|
||||
NextFocusAction.key: () => UndoableNextFocusAction(),
|
||||
PreviousFocusAction.key: () => UndoablePreviousFocusAction(),
|
||||
DirectionalFocusAction.key: () => UndoableDirectionalFocusAction(),
|
||||
kUndoActionKey: () => kUndoAction,
|
||||
kRedoActionKey: () => kRedoAction,
|
||||
actions: <Type, Action<Intent>>{
|
||||
RequestFocusIntent: UndoableRequestFocusAction(),
|
||||
NextFocusIntent: UndoableNextFocusAction(),
|
||||
PreviousFocusIntent: UndoablePreviousFocusAction(),
|
||||
DirectionalFocusIntent: UndoableDirectionalFocusAction(),
|
||||
UndoIntent: UndoAction(),
|
||||
RedoIntent: RedoAction(),
|
||||
},
|
||||
child: FocusTraversalGroup(
|
||||
policy: ReadingOrderTraversalPolicy(),
|
||||
child: Shortcuts(
|
||||
shortcuts: <LogicalKeySet, Intent>{
|
||||
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.keyZ): kRedoIntent,
|
||||
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyZ): kUndoIntent,
|
||||
LogicalKeySet(Platform.isMacOS ? LogicalKeyboardKey.meta : LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.keyZ): const RedoIntent(),
|
||||
LogicalKeySet(Platform.isMacOS ? LogicalKeyboardKey.meta : LogicalKeyboardKey.control, LogicalKeyboardKey.keyZ): const UndoIntent(),
|
||||
},
|
||||
child: FocusScope(
|
||||
key: FocusDemo.appKey,
|
||||
debugLabel: 'Scope',
|
||||
autofocus: true,
|
||||
child: DefaultTextStyle(
|
||||
@ -477,7 +451,7 @@ class _FocusDemoState extends State<FocusDemo> {
|
||||
child: const Text('UNDO'),
|
||||
onPressed: canUndo
|
||||
? () {
|
||||
Actions.invoke(context, kUndoIntent);
|
||||
Actions.invoke(context, const UndoIntent());
|
||||
}
|
||||
: null,
|
||||
),
|
||||
@ -488,7 +462,7 @@ class _FocusDemoState extends State<FocusDemo> {
|
||||
child: const Text('REDO'),
|
||||
onPressed: canRedo
|
||||
? () {
|
||||
Actions.invoke(context, kRedoIntent);
|
||||
Actions.invoke(context, const RedoIntent());
|
||||
}
|
||||
: null,
|
||||
),
|
||||
|
@ -213,7 +213,7 @@ class CupertinoApp extends StatefulWidget {
|
||||
/// return WidgetsApp(
|
||||
/// shortcuts: <LogicalKeySet, Intent>{
|
||||
/// ... WidgetsApp.defaultShortcuts,
|
||||
/// LogicalKeySet(LogicalKeyboardKey.select): const Intent(ActivateAction.key),
|
||||
/// LogicalKeySet(LogicalKeyboardKey.select): const ActivateIntent(),
|
||||
/// },
|
||||
/// color: const Color(0xFFFF0000),
|
||||
/// builder: (BuildContext context, Widget child) {
|
||||
@ -239,12 +239,12 @@ class CupertinoApp extends StatefulWidget {
|
||||
/// ```dart
|
||||
/// Widget build(BuildContext context) {
|
||||
/// return WidgetsApp(
|
||||
/// actions: <LocalKey, ActionFactory>{
|
||||
/// actions: <Type, Action<Intent>>{
|
||||
/// ... WidgetsApp.defaultActions,
|
||||
/// ActivateAction.key: () => CallbackAction(
|
||||
/// ActivateAction.key,
|
||||
/// onInvoke: (FocusNode focusNode, Intent intent) {
|
||||
/// ActivateAction: CallbackAction(
|
||||
/// onInvoke: (Intent intent) {
|
||||
/// // Do something here...
|
||||
/// return null;
|
||||
/// },
|
||||
/// ),
|
||||
/// },
|
||||
@ -257,7 +257,7 @@ class CupertinoApp extends StatefulWidget {
|
||||
/// ```
|
||||
/// {@end-tool}
|
||||
/// {@macro flutter.widgets.widgetsApp.actions.seeAlso}
|
||||
final Map<LocalKey, ActionFactory> actions;
|
||||
final Map<Type, Action<Intent>> actions;
|
||||
|
||||
@override
|
||||
_CupertinoAppState createState() => _CupertinoAppState();
|
||||
|
@ -183,9 +183,9 @@ class ChangeNotifier implements Listenable {
|
||||
/// Call all the registered listeners.
|
||||
///
|
||||
/// Call this method whenever the object changes, to notify any clients the
|
||||
/// object may have. 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.
|
||||
/// 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].
|
||||
|
@ -476,7 +476,7 @@ class MaterialApp extends StatefulWidget {
|
||||
/// return WidgetsApp(
|
||||
/// shortcuts: <LogicalKeySet, Intent>{
|
||||
/// ... WidgetsApp.defaultShortcuts,
|
||||
/// LogicalKeySet(LogicalKeyboardKey.select): const Intent(ActivateAction.key),
|
||||
/// LogicalKeySet(LogicalKeyboardKey.select): const ActivateIntent(),
|
||||
/// },
|
||||
/// color: const Color(0xFFFF0000),
|
||||
/// builder: (BuildContext context, Widget child) {
|
||||
@ -502,12 +502,12 @@ class MaterialApp extends StatefulWidget {
|
||||
/// ```dart
|
||||
/// Widget build(BuildContext context) {
|
||||
/// return WidgetsApp(
|
||||
/// actions: <LocalKey, ActionFactory>{
|
||||
/// actions: <Type, Action<Intent>>{
|
||||
/// ... WidgetsApp.defaultActions,
|
||||
/// ActivateAction.key: () => CallbackAction(
|
||||
/// ActivateAction.key,
|
||||
/// onInvoke: (FocusNode focusNode, Intent intent) {
|
||||
/// ActivateAction: CallbackAction(
|
||||
/// onInvoke: (Intent intent) {
|
||||
/// // Do something here...
|
||||
/// return null;
|
||||
/// },
|
||||
/// ),
|
||||
/// },
|
||||
@ -520,7 +520,7 @@ class MaterialApp extends StatefulWidget {
|
||||
/// ```
|
||||
/// {@end-tool}
|
||||
/// {@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
|
||||
/// Material apps.
|
||||
|
@ -169,17 +169,17 @@ class Checkbox extends StatefulWidget {
|
||||
|
||||
class _CheckboxState extends State<Checkbox> with TickerProviderStateMixin {
|
||||
bool get enabled => widget.onChanged != null;
|
||||
Map<LocalKey, ActionFactory> _actionMap;
|
||||
Map<Type, Action<Intent>> _actionMap;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_actionMap = <LocalKey, ActionFactory>{
|
||||
ActivateAction.key: _createAction,
|
||||
_actionMap = <Type, Action<Intent>>{
|
||||
ActivateIntent: CallbackAction<ActivateIntent>(onInvoke: _actionHandler),
|
||||
};
|
||||
}
|
||||
|
||||
void _actionHandler(FocusNode node, Intent intent){
|
||||
void _actionHandler(ActivateIntent intent) {
|
||||
if (widget.onChanged != null) {
|
||||
switch (widget.value) {
|
||||
case false:
|
||||
@ -193,17 +193,10 @@ class _CheckboxState extends State<Checkbox> with TickerProviderStateMixin {
|
||||
break;
|
||||
}
|
||||
}
|
||||
final RenderObject renderObject = node.context.findRenderObject();
|
||||
final RenderObject renderObject = context.findRenderObject();
|
||||
renderObject.sendSemanticsEvent(const TapSemanticEvent());
|
||||
}
|
||||
|
||||
Action _createAction() {
|
||||
return CallbackAction(
|
||||
ActivateAction.key,
|
||||
onInvoke: _actionHandler,
|
||||
);
|
||||
}
|
||||
|
||||
bool _focused = false;
|
||||
void _handleFocusHighlightChanged(bool focused) {
|
||||
if (focused != _focused) {
|
||||
|
@ -158,7 +158,7 @@ class _DropdownMenuItemButtonState<T> extends State<_DropdownMenuItemButton<T>>
|
||||
}
|
||||
|
||||
static final Map<LogicalKeySet, Intent> _webShortcuts =<LogicalKeySet, Intent>{
|
||||
LogicalKeySet(LogicalKeyboardKey.enter): const Intent(ActivateAction.key),
|
||||
LogicalKeySet(LogicalKeyboardKey.enter): const ActivateIntent(),
|
||||
};
|
||||
|
||||
@override
|
||||
@ -1080,7 +1080,7 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi
|
||||
FocusNode _internalNode;
|
||||
FocusNode get focusNode => widget.focusNode ?? _internalNode;
|
||||
bool _hasPrimaryFocus = false;
|
||||
Map<LocalKey, ActionFactory> _actionMap;
|
||||
Map<Type, Action<Intent>> _actionMap;
|
||||
FocusHighlightMode _focusHighlightMode;
|
||||
|
||||
// Only used if needed to create _internalNode.
|
||||
@ -1095,8 +1095,10 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi
|
||||
if (widget.focusNode == null) {
|
||||
_internalNode ??= _createFocusNode();
|
||||
}
|
||||
_actionMap = <LocalKey, ActionFactory>{
|
||||
ActivateAction.key: _createAction,
|
||||
_actionMap = <Type, Action<Intent>>{
|
||||
ActivateIntent: CallbackAction<ActivateIntent>(
|
||||
onInvoke: (ActivateIntent intent) => _handleTap(),
|
||||
),
|
||||
};
|
||||
focusNode.addListener(_handleFocusChanged);
|
||||
final FocusManager focusManager = WidgetsBinding.instance.focusManager;
|
||||
@ -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
|
||||
// _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
|
||||
|
@ -559,27 +559,20 @@ class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKe
|
||||
InteractiveInkFeature _currentSplash;
|
||||
bool _hovering = false;
|
||||
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;
|
||||
|
||||
void _handleAction(FocusNode node, Intent intent) {
|
||||
_startSplash(context: node.context);
|
||||
_handleTap(node.context);
|
||||
}
|
||||
|
||||
Action _createAction() {
|
||||
return CallbackAction(
|
||||
ActivateAction.key,
|
||||
onInvoke: _handleAction,
|
||||
);
|
||||
void _handleAction(ActivateIntent intent) {
|
||||
_startSplash(context: context);
|
||||
_handleTap(context);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_actionMap = <LocalKey, ActionFactory>{
|
||||
ActivateAction.key: _createAction,
|
||||
_actionMap = <Type, Action<Intent>>{
|
||||
ActivateIntent: CallbackAction<ActivateIntent>(onInvoke: _handleAction),
|
||||
};
|
||||
FocusManager.instance.addHighlightModeListener(_handleFocusHighlightModeChange);
|
||||
}
|
||||
|
@ -262,31 +262,26 @@ class Radio<T> extends StatefulWidget {
|
||||
|
||||
class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin {
|
||||
bool get enabled => widget.onChanged != null;
|
||||
Map<LocalKey, ActionFactory> _actionMap;
|
||||
Map<Type, Action<Intent>> _actionMap;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_actionMap = <LocalKey, ActionFactory>{
|
||||
ActivateAction.key: _createAction,
|
||||
_actionMap = <Type, Action<Intent>>{
|
||||
ActivateIntent: CallbackAction<ActivateIntent>(
|
||||
onInvoke: _actionHandler,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
void _actionHandler(FocusNode node, Intent intent) {
|
||||
void _actionHandler(ActivateIntent intent) {
|
||||
if (widget.onChanged != null) {
|
||||
widget.onChanged(widget.value);
|
||||
}
|
||||
final RenderObject renderObject = node.context.findRenderObject();
|
||||
final RenderObject renderObject = context.findRenderObject();
|
||||
renderObject.sendSemanticsEvent(const TapSemanticEvent());
|
||||
}
|
||||
|
||||
Action _createAction() {
|
||||
return CallbackAction(
|
||||
ActivateAction.key,
|
||||
onInvoke: _actionHandler,
|
||||
);
|
||||
}
|
||||
|
||||
bool _focused = false;
|
||||
void _handleHighlightChanged(bool focused) {
|
||||
if (_focused != focused) {
|
||||
|
@ -230,31 +230,24 @@ class Switch extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _SwitchState extends State<Switch> with TickerProviderStateMixin {
|
||||
Map<LocalKey, ActionFactory> _actionMap;
|
||||
Map<Type, Action<Intent>> _actionMap;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_actionMap = <LocalKey, ActionFactory>{
|
||||
ActivateAction.key: _createAction,
|
||||
_actionMap = <Type, Action<Intent>>{
|
||||
ActivateIntent: CallbackAction<ActivateIntent>(onInvoke: _actionHandler),
|
||||
};
|
||||
}
|
||||
|
||||
void _actionHandler(FocusNode node, Intent intent){
|
||||
void _actionHandler(ActivateIntent intent) {
|
||||
if (widget.onChanged != null) {
|
||||
widget.onChanged(!widget.value);
|
||||
}
|
||||
final RenderObject renderObject = node.context.findRenderObject();
|
||||
final RenderObject renderObject = context.findRenderObject();
|
||||
renderObject.sendSemanticsEvent(const TapSemanticEvent());
|
||||
}
|
||||
|
||||
Action _createAction() {
|
||||
return CallbackAction(
|
||||
ActivateAction.key,
|
||||
onInvoke: _actionHandler,
|
||||
);
|
||||
}
|
||||
|
||||
bool _focused = false;
|
||||
void _handleFocusHighlightChanged(bool focused) {
|
||||
if (focused != _focused) {
|
||||
|
@ -78,7 +78,7 @@ export 'package:flutter/services.dart' show SmartQuotesType, SmartDashesType;
|
||||
/// shortcuts: <LogicalKeySet, Intent>{
|
||||
/// // Pressing enter on the field will now move to the next field.
|
||||
/// LogicalKeySet(LogicalKeyboardKey.enter):
|
||||
/// Intent(NextFocusAction.key),
|
||||
/// NextFocusIntent(),
|
||||
/// },
|
||||
/// child: FocusTraversalGroup(
|
||||
/// child: Form(
|
||||
|
@ -12,48 +12,44 @@ import 'focus_scope.dart';
|
||||
import 'framework.dart';
|
||||
import 'shortcuts.dart';
|
||||
|
||||
/// Creates actions for use in defining shortcuts.
|
||||
///
|
||||
/// Used by clients of [ShortcutMap] to define shortcut maps.
|
||||
typedef ActionFactory = Action Function();
|
||||
// BuildContext/Element doesn't have a parent accessor, but it can be
|
||||
// simulated with visitAncestorElements. _getParent is needed because
|
||||
// context.getElementForInheritedWidgetOfExactType will return itself if it
|
||||
// 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.
|
||||
///
|
||||
/// 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
|
||||
/// object to extract configuration information from.
|
||||
///
|
||||
/// If this intent returns false from [isEnabled], then its associated action will
|
||||
/// not be invoked if requested.
|
||||
@immutable
|
||||
class Intent with Diagnosticable {
|
||||
/// A const constructor for an [Intent].
|
||||
///
|
||||
/// The [key] argument must not be null.
|
||||
const Intent(this.key) : assert(key != null);
|
||||
const Intent();
|
||||
|
||||
/// An intent that can't be mapped to an action.
|
||||
///
|
||||
/// 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
|
||||
/// disable a key binding made above it in the hierarchy.
|
||||
static const Intent doNothing = Intent(DoNothingAction.key);
|
||||
|
||||
/// 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));
|
||||
}
|
||||
static const DoNothingIntent doNothing = DoNothingIntent._();
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// As the name implies, an [Action] is an action or command to be performed.
|
||||
@ -71,46 +67,266 @@ class Intent with Diagnosticable {
|
||||
/// up key combinations in order to invoke actions.
|
||||
/// * [Actions], which is a widget that defines a map of [Intent] to [Action]
|
||||
/// and allows redefining of actions for its descendants.
|
||||
/// * [ActionDispatcher], a class that takes an [Action] and invokes it using a
|
||||
/// [FocusNode] for context.
|
||||
abstract class Action with Diagnosticable {
|
||||
/// A const constructor for an [Action].
|
||||
///
|
||||
/// The [intentKey] parameter must not be null.
|
||||
const Action(this.intentKey) : assert(intentKey != null);
|
||||
/// * [ActionDispatcher], a class that takes an [Action] and invokes it, passing
|
||||
/// a given [Intent].
|
||||
abstract class Action<T extends Intent> with Diagnosticable {
|
||||
final ObserverList<ActionListenerCallback> _listeners = ObserverList<ActionListenerCallback>();
|
||||
|
||||
/// The unique key for this action.
|
||||
/// Gets the type of intent this action responds to.
|
||||
Type get intentType => T;
|
||||
|
||||
/// 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].
|
||||
final LocalKey intentKey;
|
||||
/// This will be called by the [ActionDispatcher] before attempting to invoke
|
||||
/// 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.
|
||||
///
|
||||
/// This is called by the [ActionDispatcher] when an action is accepted by a
|
||||
/// [FocusNode] by returning true from its `onAction` callback, or when an
|
||||
/// action is invoked using [ActionDispatcher.invokeAction].
|
||||
/// 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
|
||||
/// subclasses.
|
||||
/// its subclasses, and only when [enabled] is true.
|
||||
///
|
||||
/// Actions invoked directly with [ActionDispatcher.invokeAction] may receive a
|
||||
/// null `node`. If the information available from a focus node is
|
||||
/// needed in the action, use [ActionDispatcher.invokeFocusedAction] instead.
|
||||
/// 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 Action<IncrementIntent> {
|
||||
/// @override
|
||||
/// int invoke(IncrementIntent intent) {
|
||||
/// return intent.index + 1;
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
@protected
|
||||
void invoke(FocusNode node, covariant Intent intent);
|
||||
Object invoke(covariant T intent);
|
||||
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
super.debugFillProperties(properties);
|
||||
properties.add(DiagnosticsProperty<LocalKey>('intentKey', intentKey));
|
||||
/// 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
|
||||
_ActionListenerState createState() => _ActionListenerState();
|
||||
}
|
||||
|
||||
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].
|
||||
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
|
||||
/// subclass it.
|
||||
/// create an explicit [Action] subclass just to call a callback.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
@ -120,48 +336,57 @@ typedef OnInvokeCallback = void Function(FocusNode node, Intent tag);
|
||||
/// and allows redefining of actions for its descendants.
|
||||
/// * [ActionDispatcher], a class that takes an [Action] and invokes it using a
|
||||
/// [FocusNode] for context.
|
||||
class CallbackAction extends Action {
|
||||
/// A const constructor for an [Action].
|
||||
class CallbackAction<T extends Intent> extends Action<T> {
|
||||
/// A constructor for a [CallbackAction].
|
||||
///
|
||||
/// The `intentKey` and [onInvoke] parameters must not be null.
|
||||
/// The [onInvoke] parameter is required.
|
||||
const CallbackAction(LocalKey intentKey, {@required this.onInvoke})
|
||||
: assert(onInvoke != null),
|
||||
super(intentKey);
|
||||
CallbackAction({@required this.onInvoke}) : assert(onInvoke != null);
|
||||
|
||||
/// The callback to be called when invoked.
|
||||
///
|
||||
/// Must not be null.
|
||||
@protected
|
||||
final OnInvokeCallback onInvoke;
|
||||
final OnInvokeCallback<T> onInvoke;
|
||||
|
||||
@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 {
|
||||
/// Const constructor so that subclasses can be const.
|
||||
/// Const constructor so that subclasses can be immutable.
|
||||
const ActionDispatcher();
|
||||
|
||||
/// Invokes the given action, optionally without regard for the currently
|
||||
/// focused node in the focus tree.
|
||||
/// Invokes the given `action`, passing it the given `intent`.
|
||||
///
|
||||
/// Actions invoked will receive the given `focusNode`, or the
|
||||
/// [FocusManager.primaryFocus] if the given `focusNode` is 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.
|
||||
///
|
||||
/// The `action` and `intent` arguments must not be null.
|
||||
///
|
||||
/// Returns true if the action was successfully invoked.
|
||||
bool invokeAction(Action action, Intent intent, {FocusNode focusNode}) {
|
||||
/// Returns the object returned from [Action.invoke] if the action was
|
||||
/// 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(intent != null);
|
||||
focusNode ??= primaryFocus;
|
||||
if (action != null && intent.isEnabled(focusNode.context)) {
|
||||
action.invoke(focusNode, intent);
|
||||
return true;
|
||||
context ??= primaryFocus?.context;
|
||||
if (action.enabled) {
|
||||
if (action is ContextAction) {
|
||||
return action.invoke(intent, context);
|
||||
} else {
|
||||
return action.invoke(intent);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -179,7 +404,7 @@ class ActionDispatcher with Diagnosticable {
|
||||
/// * [Intent], a class that holds a unique [LocalKey] identifying an action,
|
||||
/// as well as configuration information for running the [Action].
|
||||
/// * [Shortcuts], a widget used to bind key combinations to [Intent]s.
|
||||
class Actions extends InheritedWidget {
|
||||
class Actions extends StatefulWidget {
|
||||
/// Creates an [Actions] widget.
|
||||
///
|
||||
/// The [child], [actions], and [dispatcher] arguments must not be null.
|
||||
@ -187,9 +412,10 @@ class Actions extends InheritedWidget {
|
||||
Key key,
|
||||
this.dispatcher,
|
||||
@required this.actions,
|
||||
@required Widget child,
|
||||
@required this.child,
|
||||
}) : assert(actions != null),
|
||||
super(key: key, child: child);
|
||||
assert(child != null),
|
||||
super(key: key);
|
||||
|
||||
/// The [ActionDispatcher] object that invokes actions.
|
||||
///
|
||||
@ -202,42 +428,110 @@ class Actions extends InheritedWidget {
|
||||
final ActionDispatcher dispatcher;
|
||||
|
||||
/// {@template flutter.widgets.actions.actions}
|
||||
/// A map of [Intent] keys to [ActionFactory] factory methods that defines
|
||||
/// which actions this widget knows about.
|
||||
/// A map of [Intent] keys to [Action<Intent>] objects that defines which
|
||||
/// actions this widget knows about.
|
||||
///
|
||||
/// 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
|
||||
/// defining it inline in the build function.
|
||||
/// {@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
|
||||
// doesn't find one.
|
||||
static ActionDispatcher _findDispatcher(Element element) {
|
||||
assert(element.widget is Actions);
|
||||
final Actions actions = element.widget as Actions;
|
||||
ActionDispatcher dispatcher = actions.dispatcher;
|
||||
if (dispatcher == null) {
|
||||
bool visitAncestorElement(Element visitedElement) {
|
||||
if (visitedElement.widget is! Actions) {
|
||||
// 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;
|
||||
static ActionDispatcher _findDispatcher(BuildContext context) {
|
||||
ActionDispatcher dispatcher;
|
||||
_visitActionsAncestors(context, (InheritedElement element) {
|
||||
final ActionDispatcher found = (element.widget as _ActionsMarker).dispatcher;
|
||||
if (found != null) {
|
||||
dispatcher = found;
|
||||
return true;
|
||||
}
|
||||
|
||||
element.visitAncestorElements(visitAncestorElement);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return dispatcher ?? const ActionDispatcher();
|
||||
}
|
||||
|
||||
/// 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 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
|
||||
/// most tightly encloses the given [BuildContext].
|
||||
///
|
||||
@ -249,14 +543,13 @@ class Actions extends InheritedWidget {
|
||||
/// The `context` argument must not be null.
|
||||
static ActionDispatcher of(BuildContext context, {bool nullOk = false}) {
|
||||
assert(context != null);
|
||||
final InheritedElement inheritedElement = context.getElementForInheritedWidgetOfExactType<Actions>();
|
||||
final Actions inherited = context.dependOnInheritedElement(inheritedElement) as Actions;
|
||||
final _ActionsMarker marker = context.dependOnInheritedWidgetOfExactType<_ActionsMarker>();
|
||||
assert(() {
|
||||
if (nullOk) {
|
||||
return true;
|
||||
}
|
||||
if (inherited == null) {
|
||||
throw FlutterError('Unable to find an $Actions widget in the context.\n'
|
||||
if (marker == null) {
|
||||
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 widget.\n'
|
||||
'No $Actions ancestor could be found starting from the context that '
|
||||
@ -267,7 +560,7 @@ class Actions extends InheritedWidget {
|
||||
}
|
||||
return true;
|
||||
}());
|
||||
return inherited?.dispatcher ?? _findDispatcher(inheritedElement);
|
||||
return marker?.dispatcher ?? _findDispatcher(context);
|
||||
}
|
||||
|
||||
/// Invokes the action associated with the given [Intent] using the
|
||||
@ -276,88 +569,173 @@ class Actions extends InheritedWidget {
|
||||
/// The `context`, `intent` and `nullOk` arguments must not be null.
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// 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
|
||||
/// that are found.
|
||||
///
|
||||
/// Returns true if an action was successfully invoked.
|
||||
///
|
||||
/// Setting `nullOk` to true means that if no ambient [Actions] widget is
|
||||
/// found, then this method will return false instead of throwing.
|
||||
static bool invoke(
|
||||
static Object invoke<T extends Intent>(
|
||||
BuildContext context,
|
||||
Intent intent, {
|
||||
FocusNode focusNode,
|
||||
T intent, {
|
||||
bool nullOk = false,
|
||||
}) {
|
||||
assert(context != null);
|
||||
assert(intent != null);
|
||||
Element actionsElement;
|
||||
Action action;
|
||||
assert(nullOk != null);
|
||||
assert(context != null);
|
||||
Action<T> action;
|
||||
InheritedElement actionElement;
|
||||
|
||||
bool visitAncestorElement(Element element) {
|
||||
if (element.widget is! Actions) {
|
||||
// Continue visiting.
|
||||
_visitActionsAncestors(context, (InheritedElement element) {
|
||||
final _ActionsMarker actions = element.widget as _ActionsMarker;
|
||||
final Action<T> result = actions.actions[intent.runtimeType] as Action<T>;
|
||||
if (result != null) {
|
||||
action = result;
|
||||
actionElement = element;
|
||||
return true;
|
||||
}
|
||||
// Below when we invoke the action, we need to use the dispatcher from the
|
||||
// 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;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
context.visitAncestorElements(visitAncestorElement);
|
||||
assert(() {
|
||||
if (nullOk) {
|
||||
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) {
|
||||
throw FlutterError('Unable to find an action for an intent in the $Actions widget in the context.\n'
|
||||
"$Actions.invoke() was called on an $Actions widget that doesn't "
|
||||
'contain a mapping for the given intent.\n'
|
||||
throw FlutterError('Unable to find an action for an Intent with type '
|
||||
'${intent.runtimeType} in an $Actions widget in the given context.\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'
|
||||
' $context\n'
|
||||
'The intent requested was:\n'
|
||||
' $intent');
|
||||
'The intent type requested was:\n'
|
||||
' ${intent.runtimeType}');
|
||||
}
|
||||
return true;
|
||||
}());
|
||||
if (action == null) {
|
||||
// Will only get here if nullOk is true.
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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);
|
||||
// Invoke the action we found using the relevant dispatcher from the Actions
|
||||
// Element we found.
|
||||
return actionElement != null ? _findDispatcher(actionElement).invokeAction(action, intent, context) != null : null;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(Actions oldWidget) {
|
||||
return oldWidget.dispatcher != dispatcher || !mapEquals<LocalKey, ActionFactory>(oldWidget.actions, actions);
|
||||
}
|
||||
State<Actions> createState() => _ActionsState();
|
||||
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
super.debugFillProperties(properties);
|
||||
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 {
|
||||
/// bool _focused = false;
|
||||
/// bool _hovering = false;
|
||||
/// bool _on = false;
|
||||
/// Map<LocalKey, ActionFactory> _actionMap;
|
||||
/// Map<Type, Action<Intent>> _actionMap;
|
||||
/// Map<LogicalKeySet, Intent> _shortcutMap;
|
||||
///
|
||||
/// @override
|
||||
/// void initState() {
|
||||
/// super.initState();
|
||||
/// _actionMap = <LocalKey, ActionFactory>{
|
||||
/// ActivateAction.key: () {
|
||||
/// return CallbackAction(
|
||||
/// ActivateAction.key,
|
||||
/// onInvoke: (FocusNode node, Intent intent) => _toggleState(),
|
||||
/// );
|
||||
/// },
|
||||
/// _actionMap = <Type, Action<Intent>>{
|
||||
/// ActivateIntent: CallbackAction(
|
||||
/// onInvoke: (Intent intent) => _toggleState(),
|
||||
/// ),
|
||||
/// };
|
||||
/// _shortcutMap = <LogicalKeySet, Intent>{
|
||||
/// LogicalKeySet(LogicalKeyboardKey.keyX): Intent(ActivateAction.key),
|
||||
/// LogicalKeySet(LogicalKeyboardKey.keyX): const ActivateIntent(),
|
||||
/// };
|
||||
/// }
|
||||
///
|
||||
@ -546,7 +921,7 @@ class FocusableActionDetector extends StatefulWidget {
|
||||
final bool autofocus;
|
||||
|
||||
/// {@macro flutter.widgets.actions.actions}
|
||||
final Map<LocalKey, ActionFactory> actions;
|
||||
final Map<Type, Action<Intent>> actions;
|
||||
|
||||
/// {@macro flutter.widgets.shortcuts.shortcuts}
|
||||
final Map<LogicalKeySet, Intent> shortcuts;
|
||||
@ -656,6 +1031,7 @@ class _FocusableActionDetectorState extends State<FocusableActionDetector> {
|
||||
bool shouldShowHoverHighlight(FocusableActionDetector target) {
|
||||
return _hovering && target.enabled && _canShowHighlight;
|
||||
}
|
||||
|
||||
bool shouldShowFocusHighlight(FocusableActionDetector target) {
|
||||
return _focused && target.enabled && _canShowHighlight;
|
||||
}
|
||||
@ -664,14 +1040,17 @@ class _FocusableActionDetectorState extends State<FocusableActionDetector> {
|
||||
final FocusableActionDetector oldTarget = oldWidget ?? widget;
|
||||
final bool didShowHoverHighlight = shouldShowHoverHighlight(oldTarget);
|
||||
final bool didShowFocusHighlight = shouldShowFocusHighlight(oldTarget);
|
||||
if (task != null)
|
||||
if (task != null) {
|
||||
task();
|
||||
}
|
||||
final bool doShowHoverHighlight = shouldShowHoverHighlight(widget);
|
||||
final bool doShowFocusHighlight = shouldShowFocusHighlight(widget);
|
||||
if (didShowFocusHighlight != doShowFocusHighlight)
|
||||
if (didShowFocusHighlight != doShowFocusHighlight) {
|
||||
widget.onShowFocusHighlight?.call(doShowFocusHighlight);
|
||||
if (didShowHoverHighlight != doShowHoverHighlight)
|
||||
}
|
||||
if (didShowHoverHighlight != doShowHoverHighlight) {
|
||||
widget.onShowHoverHighlight?.call(doShowHoverHighlight);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@ -707,44 +1086,54 @@ class _FocusableActionDetectorState extends State<FocusableActionDetector> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 intent cannot be subclassed.
|
||||
class DoNothingIntent extends Intent {
|
||||
/// Creates a const [DoNothingIntent].
|
||||
factory DoNothingIntent() => const DoNothingIntent._();
|
||||
|
||||
// Make DoNothingIntent constructor private so it can't be subclassed.
|
||||
const DoNothingIntent._();
|
||||
}
|
||||
|
||||
/// An [Action], that, as the name implies, does nothing.
|
||||
///
|
||||
/// This action is bound to the [Intent.doNothing] intent inside of
|
||||
/// [WidgetsApp.build] so that a [Shortcuts] widget can bind a key to it to
|
||||
/// override another shortcut binding defined above it in the hierarchy.
|
||||
class DoNothingAction extends Action {
|
||||
/// Const constructor for [DoNothingAction].
|
||||
const DoNothingAction() : super(key);
|
||||
|
||||
/// The Key used for the [DoNothingIntent] intent, and registered at the top
|
||||
/// level actions in [WidgetsApp.build].
|
||||
static const LocalKey key = ValueKey<Type>(DoNothingAction);
|
||||
|
||||
/// 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
|
||||
void invoke(FocusNode node, Intent intent) { }
|
||||
void invoke(Intent intent) {}
|
||||
}
|
||||
|
||||
/// An action that invokes the currently focused control.
|
||||
/// 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 activates the currently focused control.
|
||||
///
|
||||
/// 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
|
||||
/// the default keyboard map in [WidgetsApp].
|
||||
abstract class ActivateAction extends Action {
|
||||
/// Creates a [ActivateAction] with a fixed [key];
|
||||
const ActivateAction() : super(key);
|
||||
/// activate a control. By default, is bound to [LogicalKeyboardKey.enter],
|
||||
/// [LogicalKeyboardKey.gameButtonA], and [LogicalKeyboardKey.space] in the
|
||||
/// default keyboard map in [WidgetsApp].
|
||||
abstract class ActivateAction extends Action<ActivateIntent> {}
|
||||
|
||||
/// The [LocalKey] that uniquely identifies this action.
|
||||
static const LocalKey key = ValueKey<Type>(ActivateAction);
|
||||
}
|
||||
/// An intent that selects the currently focused control.
|
||||
class SelectIntent extends Intent {}
|
||||
|
||||
/// An action that selects the currently focused control.
|
||||
///
|
||||
/// 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.
|
||||
abstract class SelectAction extends Action {
|
||||
/// 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);
|
||||
}
|
||||
abstract class SelectAction extends Action<SelectIntent> {}
|
||||
|
@ -743,7 +743,7 @@ class WidgetsApp extends StatefulWidget {
|
||||
/// return WidgetsApp(
|
||||
/// shortcuts: <LogicalKeySet, Intent>{
|
||||
/// ... WidgetsApp.defaultShortcuts,
|
||||
/// LogicalKeySet(LogicalKeyboardKey.select): const Intent(ActivateAction.key),
|
||||
/// LogicalKeySet(LogicalKeyboardKey.select): const ActivateIntent(),
|
||||
/// },
|
||||
/// color: const Color(0xFFFF0000),
|
||||
/// builder: (BuildContext context, Widget child) {
|
||||
@ -790,12 +790,12 @@ class WidgetsApp extends StatefulWidget {
|
||||
/// ```dart
|
||||
/// Widget build(BuildContext context) {
|
||||
/// return WidgetsApp(
|
||||
/// actions: <LocalKey, ActionFactory>{
|
||||
/// actions: <Type, Action<Intent>>{
|
||||
/// ... WidgetsApp.defaultActions,
|
||||
/// ActivateAction.key: () => CallbackAction(
|
||||
/// ActivateAction.key,
|
||||
/// onInvoke: (FocusNode focusNode, Intent intent) {
|
||||
/// ActivateAction: CallbackAction(
|
||||
/// onInvoke: (Intent intent) {
|
||||
/// // Do something here...
|
||||
/// return null;
|
||||
/// },
|
||||
/// ),
|
||||
/// },
|
||||
@ -818,7 +818,7 @@ class WidgetsApp extends StatefulWidget {
|
||||
/// * The [Intent] and [Action] classes, which allow definition of new
|
||||
/// actions.
|
||||
/// {@endtemplate}
|
||||
final Map<LocalKey, ActionFactory> actions;
|
||||
final Map<Type, Action<Intent>> actions;
|
||||
|
||||
/// If true, forces the performance overlay to be visible in all instances.
|
||||
///
|
||||
@ -845,13 +845,13 @@ class WidgetsApp extends StatefulWidget {
|
||||
|
||||
static final Map<LogicalKeySet, Intent> _defaultShortcuts = <LogicalKeySet, Intent>{
|
||||
// Activation
|
||||
LogicalKeySet(LogicalKeyboardKey.enter): const Intent(ActivateAction.key),
|
||||
LogicalKeySet(LogicalKeyboardKey.space): const Intent(ActivateAction.key),
|
||||
LogicalKeySet(LogicalKeyboardKey.gameButtonA): const Intent(ActivateAction.key),
|
||||
LogicalKeySet(LogicalKeyboardKey.enter): const ActivateIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.space): const ActivateIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.gameButtonA): const ActivateIntent(),
|
||||
|
||||
// Keyboard traversal.
|
||||
LogicalKeySet(LogicalKeyboardKey.tab): const Intent(NextFocusAction.key),
|
||||
LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.tab): const Intent(PreviousFocusAction.key),
|
||||
LogicalKeySet(LogicalKeyboardKey.tab): const NextFocusIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.tab): const PreviousFocusIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.arrowLeft): const DirectionalFocusIntent(TraversalDirection.left),
|
||||
LogicalKeySet(LogicalKeyboardKey.arrowRight): const DirectionalFocusIntent(TraversalDirection.right),
|
||||
LogicalKeySet(LogicalKeyboardKey.arrowDown): const DirectionalFocusIntent(TraversalDirection.down),
|
||||
@ -869,11 +869,11 @@ class WidgetsApp extends StatefulWidget {
|
||||
// Default shortcuts for the web platform.
|
||||
static final Map<LogicalKeySet, Intent> _defaultWebShortcuts = <LogicalKeySet, Intent>{
|
||||
// Activation
|
||||
LogicalKeySet(LogicalKeyboardKey.space): const Intent(ActivateAction.key),
|
||||
LogicalKeySet(LogicalKeyboardKey.space): const ActivateIntent(),
|
||||
|
||||
// Keyboard traversal.
|
||||
LogicalKeySet(LogicalKeyboardKey.tab): const Intent(NextFocusAction.key),
|
||||
LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.tab): const Intent(PreviousFocusAction.key),
|
||||
LogicalKeySet(LogicalKeyboardKey.tab): const NextFocusIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.tab): const PreviousFocusIntent(),
|
||||
|
||||
// Scrolling
|
||||
LogicalKeySet(LogicalKeyboardKey.arrowUp): const ScrollIntent(direction: AxisDirection.up),
|
||||
@ -887,12 +887,12 @@ class WidgetsApp extends StatefulWidget {
|
||||
// Default shortcuts for the macOS platform.
|
||||
static final Map<LogicalKeySet, Intent> _defaultMacOsShortcuts = <LogicalKeySet, Intent>{
|
||||
// Activation
|
||||
LogicalKeySet(LogicalKeyboardKey.enter): const Intent(ActivateAction.key),
|
||||
LogicalKeySet(LogicalKeyboardKey.space): const Intent(ActivateAction.key),
|
||||
LogicalKeySet(LogicalKeyboardKey.enter): const ActivateIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.space): const ActivateIntent(),
|
||||
|
||||
// Keyboard traversal
|
||||
LogicalKeySet(LogicalKeyboardKey.tab): const Intent(NextFocusAction.key),
|
||||
LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.tab): const Intent(PreviousFocusAction.key),
|
||||
LogicalKeySet(LogicalKeyboardKey.tab): const NextFocusIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.tab): const PreviousFocusIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.arrowLeft): const DirectionalFocusIntent(TraversalDirection.left),
|
||||
LogicalKeySet(LogicalKeyboardKey.arrowRight): const DirectionalFocusIntent(TraversalDirection.right),
|
||||
LogicalKeySet(LogicalKeyboardKey.arrowDown): const DirectionalFocusIntent(TraversalDirection.down),
|
||||
@ -932,13 +932,13 @@ class WidgetsApp extends StatefulWidget {
|
||||
}
|
||||
|
||||
/// The default value of [WidgetsApp.actions].
|
||||
static final Map<LocalKey, ActionFactory> defaultActions = <LocalKey, ActionFactory>{
|
||||
DoNothingAction.key: () => const DoNothingAction(),
|
||||
RequestFocusAction.key: () => RequestFocusAction(),
|
||||
NextFocusAction.key: () => NextFocusAction(),
|
||||
PreviousFocusAction.key: () => PreviousFocusAction(),
|
||||
DirectionalFocusAction.key: () => DirectionalFocusAction(),
|
||||
ScrollAction.key: () => ScrollAction(),
|
||||
static Map<Type, Action<Intent>> defaultActions = <Type, Action<Intent>>{
|
||||
DoNothingIntent: DoNothingAction(),
|
||||
RequestFocusIntent: RequestFocusAction(),
|
||||
NextFocusIntent: NextFocusAction(),
|
||||
PreviousFocusIntent: PreviousFocusAction(),
|
||||
DirectionalFocusIntent: DirectionalFocusAction(),
|
||||
ScrollIntent: ScrollAction(),
|
||||
};
|
||||
|
||||
@override
|
||||
|
@ -92,7 +92,8 @@ enum TraversalDirection {
|
||||
/// [FocusTraversalGroup] widget.
|
||||
///
|
||||
/// 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
|
||||
/// create a unique focus order.
|
||||
@ -1713,88 +1714,94 @@ class _FocusTraversalGroupMarker extends InheritedWidget {
|
||||
bool updateShouldNotify(InheritedWidget oldWidget) => false;
|
||||
}
|
||||
|
||||
// A base class for all of the default actions that request focus for a node.
|
||||
class _RequestFocusActionBase extends Action {
|
||||
_RequestFocusActionBase(LocalKey name) : super(name);
|
||||
/// An intent for use with the [RequestFocusAction], which supplies the
|
||||
/// [FocusNode] that should be focused.
|
||||
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;
|
||||
|
||||
@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));
|
||||
}
|
||||
/// The [FocusNode] that is to be focused.
|
||||
final FocusNode focusNode;
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// [Action.invoke] like so:
|
||||
///
|
||||
/// ```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
|
||||
/// [_focusNode.requestFocus] directly is that it will use the [Action]
|
||||
/// registered in the nearest [Actions] widget associated with [key] to make the
|
||||
/// request, rather than just requesting focus directly. This allows the action
|
||||
/// to have additional side effects, like logging, or undo and redo
|
||||
/// functionality.
|
||||
/// [FocusNode.requestFocus] directly is that it will use the [Action]
|
||||
/// registered in the nearest [Actions] widget associated with
|
||||
/// [RequestFocusIntent] to make the request, rather than just requesting focus
|
||||
/// directly. This allows the action to have additional side effects, like
|
||||
/// logging, or undo and redo functionality.
|
||||
///
|
||||
/// However, this [RequestFocusAction] is the default action associated with the
|
||||
/// [key] in the [WidgetsApp], and it simply requests focus and has no side
|
||||
/// effects.
|
||||
class RequestFocusAction extends _RequestFocusActionBase {
|
||||
/// Creates a [RequestFocusAction] with a fixed [key].
|
||||
RequestFocusAction() : super(key);
|
||||
|
||||
/// The [LocalKey] that uniquely identifies this action to an [Intent].
|
||||
static const LocalKey key = ValueKey<Type>(RequestFocusAction);
|
||||
|
||||
/// This [RequestFocusAction] class is the default action associated with the
|
||||
/// [RequestFocusIntent] in the [WidgetsApp], and it simply requests focus. You
|
||||
/// can redefine the associated action with your own [Actions] widget.
|
||||
///
|
||||
/// See [FocusTraversalPolicy] for more information about focus traversal.
|
||||
class RequestFocusAction extends Action<RequestFocusIntent> {
|
||||
@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
|
||||
/// order.
|
||||
///
|
||||
/// This action is the default action registered for the [key], and by default
|
||||
/// is bound to the [LogicalKeyboardKey.tab] key in the [WidgetsApp].
|
||||
class NextFocusAction extends _RequestFocusActionBase {
|
||||
/// Creates a [NextFocusAction] with a fixed [key];
|
||||
NextFocusAction() : super(key);
|
||||
|
||||
/// The [LocalKey] that uniquely identifies this action to an [Intent].
|
||||
static const LocalKey key = ValueKey<Type>(NextFocusAction);
|
||||
|
||||
/// This action is the default action registered for the [NextFocusIntent], and
|
||||
/// by default is bound to the [LogicalKeyboardKey.tab] key in the [WidgetsApp].
|
||||
///
|
||||
/// See [FocusTraversalPolicy] for more information about focus traversal.
|
||||
class NextFocusAction extends Action<NextFocusIntent> {
|
||||
@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
|
||||
/// order.
|
||||
///
|
||||
/// This action is the default action registered for the [key], and by default
|
||||
/// is bound to a combination of the [LogicalKeyboardKey.tab] key and the
|
||||
/// [LogicalKeyboardKey.shift] key in the [WidgetsApp].
|
||||
class PreviousFocusAction extends _RequestFocusActionBase {
|
||||
/// Creates a [PreviousFocusAction] with a fixed [key];
|
||||
PreviousFocusAction() : super(key);
|
||||
|
||||
/// The [LocalKey] that uniquely identifies this action to an [Intent].
|
||||
static const LocalKey key = ValueKey<Type>(PreviousFocusAction);
|
||||
|
||||
/// This action is the default action registered for the [PreviousFocusIntent],
|
||||
/// and by default is bound to a combination of the [LogicalKeyboardKey.tab] key
|
||||
/// and the [LogicalKeyboardKey.shift] key in the [WidgetsApp].
|
||||
///
|
||||
/// See [FocusTraversalPolicy] for more information about focus traversal.
|
||||
class PreviousFocusAction extends Action<PreviousFocusIntent> {
|
||||
@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
|
||||
@ -1804,12 +1811,13 @@ class PreviousFocusAction extends _RequestFocusActionBase {
|
||||
/// [LogicalKeyboardKey.arrowDown], [LogicalKeyboardKey.arrowLeft], and
|
||||
/// [LogicalKeyboardKey.arrowRight] keys in the [WidgetsApp], with the
|
||||
/// appropriate associated directions.
|
||||
///
|
||||
/// See [FocusTraversalPolicy] for more information about focus traversal.
|
||||
class DirectionalFocusIntent extends Intent {
|
||||
/// Creates a [DirectionalFocusIntent] with a fixed [key], and the given
|
||||
/// [direction].
|
||||
/// Creates a [DirectionalFocusIntent] intending to move the focus in the
|
||||
/// given [direction].
|
||||
const DirectionalFocusIntent(this.direction, {this.ignoreTextFields = true})
|
||||
: assert(ignoreTextFields != null),
|
||||
super(DirectionalFocusAction.key);
|
||||
: assert(ignoreTextFields != null);
|
||||
|
||||
/// The direction in which to look for the next focusable node when the
|
||||
/// associated [DirectionalFocusAction] is invoked.
|
||||
@ -1826,21 +1834,15 @@ class DirectionalFocusIntent extends Intent {
|
||||
/// An [Action] that moves the focus to the focusable node in the direction
|
||||
/// configured by the associated [DirectionalFocusIntent.direction].
|
||||
///
|
||||
/// This is the [Action] associated with the [key] and bound by default to the
|
||||
/// [LogicalKeyboardKey.arrowUp], [LogicalKeyboardKey.arrowDown],
|
||||
/// This is the [Action] associated with [DirectionalFocusIntent] and bound by
|
||||
/// default to the [LogicalKeyboardKey.arrowUp], [LogicalKeyboardKey.arrowDown],
|
||||
/// [LogicalKeyboardKey.arrowLeft], and [LogicalKeyboardKey.arrowRight] keys in
|
||||
/// the [WidgetsApp], with the appropriate associated directions.
|
||||
class DirectionalFocusAction extends _RequestFocusActionBase {
|
||||
/// Creates a [DirectionalFocusAction] with a fixed [key];
|
||||
DirectionalFocusAction() : super(key);
|
||||
|
||||
/// The [LocalKey] that uniquely identifies this action to [DirectionalFocusIntent].
|
||||
static const LocalKey key = ValueKey<Type>(DirectionalFocusAction);
|
||||
|
||||
class DirectionalFocusAction extends Action<DirectionalFocusIntent> {
|
||||
@override
|
||||
void invoke(FocusNode node, DirectionalFocusIntent intent) {
|
||||
if (!intent.ignoreTextFields || node.context.widget is! EditableText) {
|
||||
node.focusInDirection(intent.direction);
|
||||
void invoke(DirectionalFocusIntent intent) {
|
||||
if (!intent.ignoreTextFields || primaryFocus.context.widget is! EditableText) {
|
||||
primaryFocus.focusInDirection(intent.direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -132,7 +132,7 @@ abstract class GlobalKey<T extends State<StatefulWidget>> extends Key {
|
||||
|
||||
static final Map<GlobalKey, Element> _registry = <GlobalKey, 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.
|
||||
// This provides us a way to remove old reservation while parent rebuilds the
|
||||
// child in the same slot.
|
||||
|
@ -889,8 +889,7 @@ class ScrollIntent extends Intent {
|
||||
@required this.direction,
|
||||
this.type = ScrollIncrementType.line,
|
||||
}) : assert(direction != null),
|
||||
assert(type != null),
|
||||
super(ScrollAction.key);
|
||||
assert(type != null);
|
||||
|
||||
/// The direction in which to scroll the scrollable containing the focused
|
||||
/// widget.
|
||||
@ -898,11 +897,6 @@ class ScrollIntent extends Intent {
|
||||
|
||||
/// The type of scrolling that is intended.
|
||||
final ScrollIncrementType type;
|
||||
|
||||
@override
|
||||
bool isEnabled(BuildContext context) {
|
||||
return Scrollable.of(context) != null;
|
||||
}
|
||||
}
|
||||
|
||||
/// An [Action] that scrolls the [Scrollable] that encloses the current
|
||||
@ -912,13 +906,16 @@ class ScrollIntent extends Intent {
|
||||
/// for a [ScrollIntent.type] set to [ScrollIncrementType.page] is 80% of the
|
||||
/// size of the scroll window, and for [ScrollIncrementType.line], 50 logical
|
||||
/// pixels.
|
||||
class ScrollAction extends Action {
|
||||
/// Creates a const [ScrollAction].
|
||||
ScrollAction() : super(key);
|
||||
|
||||
class ScrollAction extends Action<ScrollIntent> {
|
||||
/// The [LocalKey] that uniquely connects this action to a [ScrollIntent].
|
||||
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
|
||||
// scrolling using a hardware keyboard.
|
||||
//
|
||||
@ -1013,8 +1010,8 @@ class ScrollAction extends Action {
|
||||
}
|
||||
|
||||
@override
|
||||
void invoke(FocusNode node, ScrollIntent intent) {
|
||||
final ScrollableState state = Scrollable.of(node.context);
|
||||
void invoke(ScrollIntent intent) {
|
||||
final ScrollableState state = Scrollable.of(primaryFocus.context);
|
||||
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.viewportDimension != null);
|
||||
|
@ -286,6 +286,14 @@ class ShortcutManager extends ChangeNotifier with Diagnosticable {
|
||||
/// The optional `keysPressed` argument provides an override to keys that the
|
||||
/// [RawKeyboard] reports. If not specified, uses [RawKeyboard.keysPressed]
|
||||
/// 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
|
||||
bool handleKeypress(
|
||||
BuildContext context,
|
||||
@ -316,10 +324,9 @@ class ShortcutManager extends ChangeNotifier with Diagnosticable {
|
||||
}
|
||||
if (matchedIntent != null) {
|
||||
final BuildContext primaryContext = primaryFocus?.context;
|
||||
if (primaryContext == null) {
|
||||
return false;
|
||||
}
|
||||
return Actions.invoke(primaryContext, matchedIntent, nullOk: true);
|
||||
assert (primaryContext != null);
|
||||
Actions.invoke(primaryContext, matchedIntent, nullOk: true);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
@ -174,6 +174,7 @@ void main() {
|
||||
' Focus\n'
|
||||
' _FocusTraversalGroupMarker\n'
|
||||
' FocusTraversalGroup\n'
|
||||
' _ActionsMarker\n'
|
||||
' Actions\n'
|
||||
' _ShortcutsMarker\n'
|
||||
' Semantics\n'
|
||||
|
@ -2,7 +2,6 @@
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@ -259,11 +258,11 @@ void main() {
|
||||
final BorderRadius borderRadius = BorderRadius.circular(6.0);
|
||||
|
||||
final FocusNode focusNode = FocusNode(debugLabel: 'Test Node');
|
||||
Future<void> buildTest(LocalKey actionKey) async {
|
||||
Future<void> buildTest(Intent intent) async {
|
||||
return await tester.pumpWidget(
|
||||
Shortcuts(
|
||||
shortcuts: <LogicalKeySet, Intent>{
|
||||
LogicalKeySet(LogicalKeyboardKey.space): Intent(actionKey),
|
||||
LogicalKeySet(LogicalKeyboardKey.space): intent,
|
||||
},
|
||||
child: Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
@ -289,7 +288,7 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
await buildTest(ActivateAction.key);
|
||||
await buildTest(const ActivateIntent());
|
||||
focusNode.requestFocus();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@ -322,7 +321,7 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
await buildTest(ActivateAction.key);
|
||||
await buildTest(const ActivateIntent());
|
||||
await tester.pumpAndSettle();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.space);
|
||||
await tester.pump();
|
||||
|
@ -323,7 +323,7 @@ void main() {
|
||||
semantics.dispose();
|
||||
});
|
||||
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 GlobalKey childKey = GlobalKey();
|
||||
await tester.pumpWidget(
|
||||
@ -359,7 +359,7 @@ void main() {
|
||||
});
|
||||
|
||||
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 GlobalKey childKey = GlobalKey();
|
||||
bool hovering = false;
|
||||
|
@ -47,8 +47,8 @@ void main() {
|
||||
await tester.pumpWidget(
|
||||
Shortcuts(
|
||||
shortcuts: <LogicalKeySet, Intent>{
|
||||
LogicalKeySet(LogicalKeyboardKey.enter): const Intent(ActivateAction.key),
|
||||
LogicalKeySet(LogicalKeyboardKey.space): const Intent(ActivateAction.key),
|
||||
LogicalKeySet(LogicalKeyboardKey.enter): const ActivateIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.space): const ActivateIntent(),
|
||||
},
|
||||
child: Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
|
@ -9,17 +9,51 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.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 {
|
||||
const TestAction({
|
||||
class TestIntent extends Intent {
|
||||
const TestIntent();
|
||||
}
|
||||
|
||||
class SecondTestIntent extends TestIntent {
|
||||
const SecondTestIntent();
|
||||
}
|
||||
|
||||
class ThirdTestIntent extends SecondTestIntent {
|
||||
const ThirdTestIntent();
|
||||
}
|
||||
|
||||
class TestAction extends CallbackAction<TestIntent> {
|
||||
TestAction({
|
||||
@required OnInvokeCallback onInvoke,
|
||||
}) : 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 {
|
||||
@ -28,9 +62,9 @@ class TestDispatcher extends ActionDispatcher {
|
||||
final PostInvokeCallback postInvoke;
|
||||
|
||||
@override
|
||||
bool invokeAction(Action action, Intent intent, {FocusNode focusNode}) {
|
||||
final bool result = super.invokeAction(action, intent, focusNode: focusNode);
|
||||
postInvoke?.call(action: action, intent: intent, focusNode: focusNode, dispatcher: this);
|
||||
Object invokeAction(Action<Intent> action, Intent intent, [BuildContext context]) {
|
||||
final Object result = super.invokeAction(action, intent, context);
|
||||
postInvoke?.call(action: action, intent: intent, dispatcher: this);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@ -40,57 +74,48 @@ class TestDispatcher1 extends TestDispatcher {
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('Action passes parameters on when invoked.', () {
|
||||
bool invoked = false;
|
||||
FocusNode passedNode;
|
||||
final TestAction action = TestAction(onInvoke: (FocusNode node, Intent invocation) {
|
||||
invoked = true;
|
||||
passedNode = node;
|
||||
testWidgets('CallbackAction passes correct intent when invoked.', (WidgetTester tester) async {
|
||||
Intent passedIntent;
|
||||
final TestAction action = TestAction(onInvoke: (Intent intent) {
|
||||
passedIntent = intent;
|
||||
return true;
|
||||
});
|
||||
final FocusNode testNode = FocusNode(debugLabel: 'Test Node');
|
||||
action._testInvoke(testNode, null);
|
||||
expect(passedNode, equals(testNode));
|
||||
expect(action.intentKey, equals(TestAction.key));
|
||||
expect(invoked, isTrue);
|
||||
const TestIntent intent = TestIntent();
|
||||
action._testInvoke(intent);
|
||||
expect(passedIntent, equals(intent));
|
||||
});
|
||||
group(ActionDispatcher, () {
|
||||
test('ActionDispatcher invokes actions when asked.', () {
|
||||
testWidgets('ActionDispatcher invokes actions when asked.', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(Container());
|
||||
bool invoked = false;
|
||||
FocusNode passedNode;
|
||||
const ActionDispatcher dispatcher = ActionDispatcher();
|
||||
final FocusNode testNode = FocusNode(debugLabel: 'Test Node');
|
||||
final bool result = dispatcher.invokeAction(
|
||||
final Object result = dispatcher.invokeAction(
|
||||
TestAction(
|
||||
onInvoke: (FocusNode node, Intent invocation) {
|
||||
onInvoke: (Intent intent) {
|
||||
invoked = true;
|
||||
passedNode = node;
|
||||
return invoked;
|
||||
},
|
||||
),
|
||||
const Intent(TestAction.key),
|
||||
focusNode: testNode,
|
||||
const TestIntent(),
|
||||
);
|
||||
expect(passedNode, equals(testNode));
|
||||
expect(result, isTrue);
|
||||
expect(invoked, isTrue);
|
||||
});
|
||||
});
|
||||
group(Actions, () {
|
||||
Intent invokedIntent;
|
||||
Action invokedAction;
|
||||
FocusNode invokedNode;
|
||||
Action<Intent> invokedAction;
|
||||
ActionDispatcher invokedDispatcher;
|
||||
|
||||
void collect({Action action, Intent intent, FocusNode focusNode, ActionDispatcher dispatcher}) {
|
||||
void collect({Action<Intent> action, Intent intent, ActionDispatcher dispatcher}) {
|
||||
invokedIntent = intent;
|
||||
invokedAction = action;
|
||||
invokedNode = focusNode;
|
||||
invokedDispatcher = dispatcher;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
invokedIntent = null;
|
||||
invokedAction = null;
|
||||
invokedNode = null;
|
||||
invokedDispatcher = null;
|
||||
}
|
||||
|
||||
@ -99,64 +124,55 @@ void main() {
|
||||
testWidgets('Actions widget can invoke actions with default dispatcher', (WidgetTester tester) async {
|
||||
final GlobalKey containerKey = GlobalKey();
|
||||
bool invoked = false;
|
||||
FocusNode passedNode;
|
||||
final FocusNode testNode = FocusNode(debugLabel: 'Test Node');
|
||||
|
||||
await tester.pumpWidget(
|
||||
Actions(
|
||||
actions: <LocalKey, ActionFactory>{
|
||||
TestAction.key: () => TestAction(
|
||||
onInvoke: (FocusNode node, Intent invocation) {
|
||||
invoked = true;
|
||||
passedNode = node;
|
||||
},
|
||||
),
|
||||
actions: <Type, Action<Intent>>{
|
||||
TestIntent: TestAction(
|
||||
onInvoke: (Intent intent) {
|
||||
invoked = true;
|
||||
return invoked;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: Container(key: containerKey),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
final bool result = Actions.invoke(
|
||||
final Object result = Actions.invoke(
|
||||
containerKey.currentContext,
|
||||
const Intent(TestAction.key),
|
||||
focusNode: testNode,
|
||||
const TestIntent(),
|
||||
);
|
||||
expect(passedNode, equals(testNode));
|
||||
expect(result, isTrue);
|
||||
expect(invoked, isTrue);
|
||||
});
|
||||
testWidgets('Actions widget can invoke actions with custom dispatcher', (WidgetTester tester) async {
|
||||
final GlobalKey containerKey = GlobalKey();
|
||||
bool invoked = false;
|
||||
const Intent intent = Intent(TestAction.key);
|
||||
FocusNode passedNode;
|
||||
final FocusNode testNode = FocusNode(debugLabel: 'Test Node');
|
||||
final Action testAction = TestAction(
|
||||
onInvoke: (FocusNode node, Intent intent) {
|
||||
const TestIntent intent = TestIntent();
|
||||
final Action<Intent> testAction = TestAction(
|
||||
onInvoke: (Intent intent) {
|
||||
invoked = true;
|
||||
passedNode = node;
|
||||
return invoked;
|
||||
},
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
Actions(
|
||||
dispatcher: TestDispatcher(postInvoke: collect),
|
||||
actions: <LocalKey, ActionFactory>{
|
||||
TestAction.key: () => testAction,
|
||||
actions: <Type, Action<Intent>>{
|
||||
TestIntent: testAction,
|
||||
},
|
||||
child: Container(key: containerKey),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
final bool result = Actions.invoke(
|
||||
final Object result = Actions.invoke<TestIntent>(
|
||||
containerKey.currentContext,
|
||||
intent,
|
||||
focusNode: testNode,
|
||||
);
|
||||
expect(passedNode, equals(testNode));
|
||||
expect(invokedNode, equals(testNode));
|
||||
expect(result, isTrue);
|
||||
expect(invoked, isTrue);
|
||||
expect(invokedIntent, equals(intent));
|
||||
@ -164,38 +180,33 @@ void main() {
|
||||
testWidgets('Actions can invoke actions in ancestor dispatcher', (WidgetTester tester) async {
|
||||
final GlobalKey containerKey = GlobalKey();
|
||||
bool invoked = false;
|
||||
const Intent intent = Intent(TestAction.key);
|
||||
FocusNode passedNode;
|
||||
final FocusNode testNode = FocusNode(debugLabel: 'Test Node');
|
||||
final Action testAction = TestAction(
|
||||
onInvoke: (FocusNode node, Intent invocation) {
|
||||
const TestIntent intent = TestIntent();
|
||||
final Action<Intent> testAction = TestAction(
|
||||
onInvoke: (Intent intent) {
|
||||
invoked = true;
|
||||
passedNode = node;
|
||||
return invoked;
|
||||
},
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
Actions(
|
||||
dispatcher: TestDispatcher1(postInvoke: collect),
|
||||
actions: <LocalKey, ActionFactory>{
|
||||
TestAction.key: () => testAction,
|
||||
actions: <Type, Action<Intent>>{
|
||||
TestIntent: testAction,
|
||||
},
|
||||
child: Actions(
|
||||
dispatcher: TestDispatcher(postInvoke: collect),
|
||||
actions: const <LocalKey, ActionFactory>{},
|
||||
actions: const <Type, Action<Intent>>{},
|
||||
child: Container(key: containerKey),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
final bool result = Actions.invoke(
|
||||
final Object result = Actions.invoke<TestIntent>(
|
||||
containerKey.currentContext,
|
||||
intent,
|
||||
focusNode: testNode,
|
||||
);
|
||||
expect(passedNode, equals(testNode));
|
||||
expect(invokedNode, equals(testNode));
|
||||
expect(result, isTrue);
|
||||
expect(invoked, isTrue);
|
||||
expect(invokedIntent, equals(intent));
|
||||
@ -205,37 +216,32 @@ void main() {
|
||||
testWidgets("Actions can invoke actions in ancestor dispatcher if a lower one isn't specified", (WidgetTester tester) async {
|
||||
final GlobalKey containerKey = GlobalKey();
|
||||
bool invoked = false;
|
||||
const Intent intent = Intent(TestAction.key);
|
||||
FocusNode passedNode;
|
||||
final FocusNode testNode = FocusNode(debugLabel: 'Test Node');
|
||||
final Action testAction = TestAction(
|
||||
onInvoke: (FocusNode node, Intent invocation) {
|
||||
const TestIntent intent = TestIntent();
|
||||
final Action<Intent> testAction = TestAction(
|
||||
onInvoke: (Intent intent) {
|
||||
invoked = true;
|
||||
passedNode = node;
|
||||
return invoked;
|
||||
},
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
Actions(
|
||||
dispatcher: TestDispatcher1(postInvoke: collect),
|
||||
actions: <LocalKey, ActionFactory>{
|
||||
TestAction.key: () => testAction,
|
||||
actions: <Type, Action<Intent>>{
|
||||
TestIntent: testAction,
|
||||
},
|
||||
child: Actions(
|
||||
actions: const <LocalKey, ActionFactory>{},
|
||||
actions: const <Type, Action<Intent>>{},
|
||||
child: Container(key: containerKey),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
final bool result = Actions.invoke(
|
||||
final Object result = Actions.invoke<TestIntent>(
|
||||
containerKey.currentContext,
|
||||
intent,
|
||||
focusNode: testNode,
|
||||
);
|
||||
expect(passedNode, equals(testNode));
|
||||
expect(invokedNode, equals(testNode));
|
||||
expect(result, isTrue);
|
||||
expect(invoked, isTrue);
|
||||
expect(invokedIntent, equals(intent));
|
||||
@ -249,7 +255,7 @@ void main() {
|
||||
await tester.pumpWidget(
|
||||
Actions(
|
||||
dispatcher: testDispatcher,
|
||||
actions: const <LocalKey, ActionFactory>{},
|
||||
actions: const <Type, Action<Intent>>{},
|
||||
child: Container(key: containerKey),
|
||||
),
|
||||
);
|
||||
@ -261,15 +267,64 @@ void main() {
|
||||
);
|
||||
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 {
|
||||
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
|
||||
final GlobalKey containerKey = GlobalKey();
|
||||
bool invoked = false;
|
||||
const Intent intent = Intent(TestAction.key);
|
||||
const Intent intent = TestIntent();
|
||||
final FocusNode focusNode = FocusNode(debugLabel: 'Test Node');
|
||||
final Action testAction = TestAction(
|
||||
onInvoke: (FocusNode node, Intent invocation) {
|
||||
final Action<Intent> testAction = TestAction(
|
||||
onInvoke: (Intent intent) {
|
||||
invoked = true;
|
||||
return invoked;
|
||||
},
|
||||
);
|
||||
bool hovering = false;
|
||||
@ -280,15 +335,15 @@ void main() {
|
||||
Center(
|
||||
child: Actions(
|
||||
dispatcher: TestDispatcher1(postInvoke: collect),
|
||||
actions: const <LocalKey, ActionFactory>{},
|
||||
actions: const <Type, Action<Intent>>{},
|
||||
child: FocusableActionDetector(
|
||||
enabled: enabled,
|
||||
focusNode: focusNode,
|
||||
shortcuts: <LogicalKeySet, Intent>{
|
||||
LogicalKeySet(LogicalKeyboardKey.enter): intent,
|
||||
},
|
||||
actions: <LocalKey, ActionFactory>{
|
||||
TestAction.key: () => testAction,
|
||||
actions: <Type, Action<Intent>>{
|
||||
TestIntent: testAction,
|
||||
},
|
||||
onShowHoverHighlight: (bool value) => hovering = value,
|
||||
onShowFocusHighlight: (bool value) => focusing = value,
|
||||
@ -299,6 +354,7 @@ void main() {
|
||||
);
|
||||
return tester.pump();
|
||||
}
|
||||
|
||||
await buildTest(true);
|
||||
focusNode.requestFocus();
|
||||
await tester.pump();
|
||||
@ -330,11 +386,178 @@ void main() {
|
||||
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', () {
|
||||
testWidgets('default Intent debugFillProperties', (WidgetTester tester) async {
|
||||
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
|
||||
.where((DiagnosticsNode node) {
|
||||
@ -343,30 +566,13 @@ void main() {
|
||||
.map((DiagnosticsNode node) => node.toString())
|
||||
.toList();
|
||||
|
||||
expect(description, equals(<String>["key: [<'foo'>]"]));
|
||||
});
|
||||
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'>]"]));
|
||||
expect(description, isEmpty);
|
||||
});
|
||||
testWidgets('default Actions debugFillProperties', (WidgetTester tester) async {
|
||||
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
|
||||
|
||||
Actions(
|
||||
actions: const <LocalKey, ActionFactory>{},
|
||||
actions: const <Type, Action<Intent>>{},
|
||||
dispatcher: const ActionDispatcher(),
|
||||
child: Container(),
|
||||
).debugFillProperties(builder);
|
||||
@ -378,6 +584,7 @@ void main() {
|
||||
.map((DiagnosticsNode node) => node.toString())
|
||||
.toList();
|
||||
|
||||
expect(description.length, equals(2));
|
||||
expect(description[0], equalsIgnoringHashCodes('dispatcher: ActionDispatcher#00000'));
|
||||
expect(description[1], equals('actions: {}'));
|
||||
});
|
||||
@ -387,8 +594,8 @@ void main() {
|
||||
Actions(
|
||||
key: const ValueKey<String>('foo'),
|
||||
dispatcher: const ActionDispatcher(),
|
||||
actions: <LocalKey, ActionFactory>{
|
||||
const ValueKey<String>('bar'): () => TestAction(onInvoke: (FocusNode node, Intent intent) {}),
|
||||
actions: <Type, Action<Intent>>{
|
||||
TestIntent: TestAction(onInvoke: (Intent intent) => null),
|
||||
},
|
||||
child: Container(key: const ValueKey<String>('baz')),
|
||||
).debugFillProperties(builder);
|
||||
@ -400,8 +607,9 @@ void main() {
|
||||
.map((DiagnosticsNode node) => node.toString())
|
||||
.toList();
|
||||
|
||||
expect(description.length, equals(2));
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
@ -8,15 +8,19 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
class TestAction extends Action {
|
||||
TestAction() : super(key);
|
||||
class TestIntent extends Intent {
|
||||
const TestIntent();
|
||||
}
|
||||
|
||||
class TestAction extends Action<Intent> {
|
||||
TestAction();
|
||||
|
||||
static const LocalKey key = ValueKey<Type>(TestAction);
|
||||
|
||||
int calls = 0;
|
||||
|
||||
@override
|
||||
void invoke(FocusNode node, Intent intent) {
|
||||
void invoke(Intent intent) {
|
||||
calls += 1;
|
||||
}
|
||||
}
|
||||
@ -67,11 +71,11 @@ void main() {
|
||||
await tester.pumpWidget(
|
||||
WidgetsApp(
|
||||
key: key,
|
||||
actions: <LocalKey, ActionFactory>{
|
||||
TestAction.key: () => action,
|
||||
actions: <Type, Action<Intent>>{
|
||||
TestIntent: action,
|
||||
},
|
||||
shortcuts: <LogicalKeySet, Intent> {
|
||||
LogicalKeySet(LogicalKeyboardKey.space): const Intent(TestAction.key),
|
||||
LogicalKeySet(LogicalKeyboardKey.space): const TestIntent(),
|
||||
},
|
||||
builder: (BuildContext context, Widget child) {
|
||||
return Material(
|
||||
|
@ -9,13 +9,13 @@ import 'package:flutter/src/services/keyboard_key.dart';
|
||||
import 'package:flutter/widgets.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 {
|
||||
const TestAction({
|
||||
class TestAction extends CallbackAction<TestIntent> {
|
||||
TestAction({
|
||||
@required OnInvokeCallback onInvoke,
|
||||
}) : assert(onInvoke != null),
|
||||
super(key, onInvoke: onInvoke);
|
||||
super(onInvoke: onInvoke);
|
||||
|
||||
static const LocalKey key = ValueKey<Type>(TestAction);
|
||||
}
|
||||
@ -26,15 +26,15 @@ class TestDispatcher extends ActionDispatcher {
|
||||
final PostInvokeCallback postInvoke;
|
||||
|
||||
@override
|
||||
bool invokeAction(Action action, Intent intent, {FocusNode focusNode}) {
|
||||
final bool result = super.invokeAction(action, intent, focusNode: focusNode);
|
||||
postInvoke?.call(action: action, intent: intent, focusNode: focusNode, dispatcher: this);
|
||||
Object invokeAction(Action<TestIntent> action, Intent intent, [BuildContext context]) {
|
||||
final Object result = super.invokeAction(action, intent, context);
|
||||
postInvoke?.call(action: action, intent: intent, context: context, dispatcher: this);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class TestIntent extends Intent {
|
||||
const TestIntent() : super(TestAction.key);
|
||||
const TestIntent();
|
||||
}
|
||||
|
||||
class TestShortcutManager extends ShortcutManager {
|
||||
@ -210,10 +210,11 @@ void main() {
|
||||
bool invoked = false;
|
||||
await tester.pumpWidget(
|
||||
Actions(
|
||||
actions: <LocalKey, ActionFactory>{
|
||||
TestAction.key: () => TestAction(
|
||||
onInvoke: (FocusNode node, Intent intent) {
|
||||
actions: <Type, Action<Intent>>{
|
||||
TestIntent: TestAction(
|
||||
onInvoke: (Intent intent) {
|
||||
invoked = true;
|
||||
return true;
|
||||
},
|
||||
),
|
||||
},
|
||||
@ -247,10 +248,11 @@ void main() {
|
||||
LogicalKeySet(LogicalKeyboardKey.shift): const TestIntent(),
|
||||
},
|
||||
child: Actions(
|
||||
actions: <LocalKey, ActionFactory>{
|
||||
TestAction.key: () => TestAction(
|
||||
onInvoke: (FocusNode node, Intent intent) {
|
||||
actions: <Type, Action<Intent>>{
|
||||
TestIntent: TestAction(
|
||||
onInvoke: (Intent intent) {
|
||||
invoked = true;
|
||||
return invoked;
|
||||
},
|
||||
),
|
||||
},
|
||||
@ -285,10 +287,11 @@ void main() {
|
||||
LogicalKeySet(LogicalKeyboardKey.shift): const TestIntent(),
|
||||
},
|
||||
child: Actions(
|
||||
actions: <LocalKey, ActionFactory>{
|
||||
TestAction.key: () => TestAction(
|
||||
onInvoke: (FocusNode node, Intent intent) {
|
||||
actions: <Type, Action<Intent>>{
|
||||
TestIntent: TestAction(
|
||||
onInvoke: (Intent intent) {
|
||||
invoked = true;
|
||||
return invoked;
|
||||
},
|
||||
),
|
||||
},
|
||||
@ -317,7 +320,7 @@ void main() {
|
||||
Shortcuts(shortcuts: <LogicalKeySet, Intent>{LogicalKeySet(
|
||||
LogicalKeyboardKey.shift,
|
||||
LogicalKeyboardKey.keyA,
|
||||
) : const Intent(ActivateAction.key),
|
||||
) : const ActivateIntent(),
|
||||
LogicalKeySet(
|
||||
LogicalKeyboardKey.shift,
|
||||
LogicalKeyboardKey.arrowRight,
|
||||
@ -334,7 +337,7 @@ void main() {
|
||||
expect(
|
||||
description[0],
|
||||
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.', () {
|
||||
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
|
||||
@ -345,7 +348,7 @@ void main() {
|
||||
LogicalKeySet(
|
||||
LogicalKeyboardKey.keyA,
|
||||
LogicalKeyboardKey.keyB,
|
||||
): const Intent(ActivateAction.key)
|
||||
): const ActivateIntent(),
|
||||
},
|
||||
).debugFillProperties(builder);
|
||||
|
||||
@ -368,7 +371,7 @@ void main() {
|
||||
LogicalKeySet(
|
||||
LogicalKeyboardKey.keyA,
|
||||
LogicalKeyboardKey.keyB,
|
||||
): const Intent(ActivateAction.key)
|
||||
): const ActivateIntent(),
|
||||
},
|
||||
).debugFillProperties(builder);
|
||||
|
||||
@ -381,7 +384,7 @@ void main() {
|
||||
|
||||
expect(description.length, equals(2));
|
||||
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}'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user