Unverified Commit a0856358 authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Change the way ActionDispatcher is found. (#41245)

This changes the way ActionDispatchers are found by the Actions widget, so that by default it will look for dispatchers of the parent Actions widgets instead of just creating a default ActionDispatcher. This allows overriding of the ActionDispatcher at the top level: before, the custom action dispatcher would only be invoked if explicitly set on all the Actions widgets.

This is not a breaking change because there was a default value to the dispatcher parameter before that performed this function, and not specifying the dispatcher anywhere will still result in a default dispatcher being created.
parent 1a7bb1f5
...@@ -27,6 +27,14 @@ class Intent extends Diagnosticable { ...@@ -27,6 +27,14 @@ class Intent extends Diagnosticable {
/// The [key] argument must not be null. /// The [key] argument must not be null.
const Intent(this.key) : assert(key != null); const Intent(this.key) : assert(key != null);
/// An intent that can't be mapped to an action.
///
/// This Intent is prevented from being mapped to an action in the
/// [ActionDispatcher], and as such can be used to indicate that a shortcut
/// should not do anything, allowing a shortcut defined at a higher level to
/// be disabled at a deeper level in the widget hierarchy.
static const Intent doNothing = Intent(ValueKey<Type>(Intent));
/// The key for the action this intent is associated with. /// The key for the action this intent is associated with.
final LocalKey key; final LocalKey key;
...@@ -141,6 +149,8 @@ class ActionDispatcher extends Diagnosticable { ...@@ -141,6 +149,8 @@ class ActionDispatcher extends Diagnosticable {
/// [FocusManager.primaryFocus] if the given `focusNode` is null. /// [FocusManager.primaryFocus] if the given `focusNode` is null.
/// ///
/// The `action` and `intent` arguments must not be null. /// 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}) { bool invokeAction(Action action, Intent intent, {FocusNode focusNode}) {
assert(action != null); assert(action != null);
assert(intent != null); assert(intent != null);
...@@ -173,22 +183,52 @@ class Actions extends InheritedWidget { ...@@ -173,22 +183,52 @@ class Actions extends InheritedWidget {
/// The [child], [actions], and [dispatcher] arguments must not be null. /// The [child], [actions], and [dispatcher] arguments must not be null.
const Actions({ const Actions({
Key key, Key key,
this.dispatcher = const ActionDispatcher(), this.dispatcher,
@required this.actions, @required this.actions,
@required Widget child, @required Widget child,
}) : assert(dispatcher != null), }) : assert(actions != null),
assert(actions != null),
super(key: key, child: child); super(key: key, child: child);
/// The [ActionDispatcher] object that invokes actions. /// The [ActionDispatcher] object that invokes actions.
/// ///
/// This is what is returned from [Actions.of], and used by [Actions.invoke]. /// This is what is returned from [Actions.of], and used by [Actions.invoke].
///
/// If this [dispatcher] is null, then [Actions.of] and [Actions.invoke] will
/// look up the tree until they find an Actions widget that has a dispatcher
/// set. If not such widget is found, then they will return/use a
/// default-constructed [ActionDispatcher].
final ActionDispatcher dispatcher; final ActionDispatcher dispatcher;
/// A map of [Intent] keys to [ActionFactory] factory methods that defines /// A map of [Intent] keys to [ActionFactory] factory methods that defines
/// which actions this widget knows about. /// which actions this widget knows about.
final Map<LocalKey, ActionFactory> actions; final Map<LocalKey, ActionFactory> actions;
// 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;
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;
if (actions.dispatcher == null) {
// Continue visiting.
return true;
}
dispatcher = actions.dispatcher;
// Stop visiting.
return false;
}
element.visitAncestorElements(visitAncestorElement);
}
return dispatcher ?? const ActionDispatcher();
}
/// Returns the [ActionDispatcher] associated with the [Actions] widget that /// Returns the [ActionDispatcher] associated with the [Actions] widget that
/// most tightly encloses the given [BuildContext]. /// most tightly encloses the given [BuildContext].
/// ///
...@@ -200,7 +240,8 @@ class Actions extends InheritedWidget { ...@@ -200,7 +240,8 @@ class Actions extends InheritedWidget {
/// The `context` argument must not be null. /// The `context` argument must not be null.
static ActionDispatcher of(BuildContext context, {bool nullOk = false}) { static ActionDispatcher of(BuildContext context, {bool nullOk = false}) {
assert(context != null); assert(context != null);
final Actions inherited = context.inheritFromWidgetOfExactType(Actions); final InheritedElement inheritedElement = context.ancestorInheritedElementForWidgetOfExactType(Actions);
final Actions inherited = context.inheritFromElement(inheritedElement);
assert(() { assert(() {
if (nullOk) { if (nullOk) {
return true; return true;
...@@ -217,7 +258,7 @@ class Actions extends InheritedWidget { ...@@ -217,7 +258,7 @@ class Actions extends InheritedWidget {
} }
return true; return true;
}()); }());
return inherited?.dispatcher; return inherited?.dispatcher ?? _findDispatcher(inheritedElement);
} }
/// Invokes the action associated with the given [Intent] using the /// Invokes the action associated with the given [Intent] using the
...@@ -233,8 +274,13 @@ class Actions extends InheritedWidget { ...@@ -233,8 +274,13 @@ class Actions extends InheritedWidget {
/// `intent` doesn't map to an action in any of the [Actions.actions] maps /// `intent` doesn't map to an action in any of the [Actions.actions] maps
/// that are found. /// that are found.
/// ///
/// Returns true if an action was successfully invoked.
///
/// Setting `nullOk` to true means that if no ambient [Actions] widget is /// Setting `nullOk` to true means that if no ambient [Actions] widget is
/// found, then this method will return false instead of throwing. /// found, then this method will return false instead of throwing.
///
/// If the `intent` argument is [Intent.doNothing], then this function will
/// return false, without looking for a matching action.
static bool invoke( static bool invoke(
BuildContext context, BuildContext context,
Intent intent, { Intent intent, {
...@@ -243,8 +289,13 @@ class Actions extends InheritedWidget { ...@@ -243,8 +289,13 @@ class Actions extends InheritedWidget {
}) { }) {
assert(context != null); assert(context != null);
assert(intent != null); assert(intent != null);
Actions actions; Element actionsElement;
Action action; Action action;
if (identical(intent, Intent.doNothing)) {
return false;
}
bool visitAncestorElement(Element element) { bool visitAncestorElement(Element element) {
if (element.widget is! Actions) { if (element.widget is! Actions) {
// Continue visiting. // Continue visiting.
...@@ -252,9 +303,10 @@ class Actions extends InheritedWidget { ...@@ -252,9 +303,10 @@ class Actions extends InheritedWidget {
} }
// Below when we invoke the action, we need to use the dispatcher from the // 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. // Actions widget where we found the action, in case they need to match.
actions = element.widget; actionsElement = element;
final Actions actions = element.widget;
action = actions.actions[intent.key]?.call(); action = actions.actions[intent.key]?.call();
// Don't continue visiting if we successfully created an action. // Keep looking if we failed to find and create an action.
return action == null; return action == null;
} }
...@@ -263,7 +315,7 @@ class Actions extends InheritedWidget { ...@@ -263,7 +315,7 @@ class Actions extends InheritedWidget {
if (nullOk) { if (nullOk) {
return true; return true;
} }
if (actions == null) { if (actionsElement == null) {
throw FlutterError('Unable to find a $Actions widget in the context.\n' 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.invoke() was called with a context that does not contain an '
'$Actions widget.\n' '$Actions widget.\n'
...@@ -288,10 +340,10 @@ class Actions extends InheritedWidget { ...@@ -288,10 +340,10 @@ class Actions extends InheritedWidget {
// Will only get here if nullOk is true. // Will only get here if nullOk is true.
return false; return false;
} }
// Invoke the action we found using the dispatcher from the Actions we
// found, using the given focus node. Or null, if nullOk is true, and we // Invoke the action we found using the dispatcher from the Actions Element
// didn't find something. // we found, using the given focus node.
return actions?.dispatcher?.invokeAction(action, intent, focusNode: focusNode); return _findDispatcher(actionsElement).invokeAction(action, intent, focusNode: focusNode);
} }
@override @override
......
...@@ -1251,6 +1251,7 @@ class FocusManager with DiagnosticableTreeMixin { ...@@ -1251,6 +1251,7 @@ class FocusManager with DiagnosticableTreeMixin {
// Walk the current focus from the leaf to the root, calling each one's // Walk the current focus from the leaf to the root, calling each one's
// onKey on the way up, and if one responds that they handled it, stop. // onKey on the way up, and if one responds that they handled it, stop.
if (_primaryFocus == null) { if (_primaryFocus == null) {
assert(_focusDebug('No primary focus for key event, ignored: $event'));
return; return;
} }
Iterable<FocusNode> allNodes(FocusNode node) sync* { Iterable<FocusNode> allNodes(FocusNode node) sync* {
...@@ -1260,11 +1261,17 @@ class FocusManager with DiagnosticableTreeMixin { ...@@ -1260,11 +1261,17 @@ class FocusManager with DiagnosticableTreeMixin {
} }
} }
bool handled = false;
for (FocusNode node in allNodes(_primaryFocus)) { for (FocusNode node in allNodes(_primaryFocus)) {
if (node.onKey != null && node.onKey(node, event)) { if (node.onKey != null && node.onKey(node, event)) {
assert(_focusDebug('Node $node handled key event $event.'));
handled = true;
break; break;
} }
} }
if (!handled) {
assert(_focusDebug('Key event not handled by anyone: $event.'));
}
} }
/// The node that currently has the primary focus. /// The node that currently has the primary focus.
......
...@@ -345,6 +345,7 @@ class _ShortcutsState extends State<Shortcuts> { ...@@ -345,6 +345,7 @@ class _ShortcutsState extends State<Shortcuts> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Focus( return Focus(
debugLabel: describeIdentity(widget),
canRequestFocus: false, canRequestFocus: false,
onKey: _handleOnKey, onKey: _handleOnKey,
child: _ShortcutsMarker( child: _ShortcutsMarker(
......
...@@ -159,7 +159,7 @@ void main() { ...@@ -159,7 +159,7 @@ void main() {
expect(invoked, isTrue); expect(invoked, isTrue);
expect(invokedIntent, equals(intent)); expect(invokedIntent, equals(intent));
}); });
testWidgets('$Actions widget can invoke actions in ancestor dispatcher', (WidgetTester tester) async { testWidgets('$Actions can invoke actions in ancestor dispatcher', (WidgetTester tester) async {
final GlobalKey containerKey = GlobalKey(); final GlobalKey containerKey = GlobalKey();
bool invoked = false; bool invoked = false;
const Intent intent = Intent(TestAction.key); const Intent intent = Intent(TestAction.key);
...@@ -200,6 +200,46 @@ void main() { ...@@ -200,6 +200,46 @@ void main() {
expect(invokedAction, equals(testAction)); expect(invokedAction, equals(testAction));
expect(invokedDispatcher.runtimeType, equals(TestDispatcher1)); expect(invokedDispatcher.runtimeType, equals(TestDispatcher1));
}); });
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) {
invoked = true;
passedNode = node;
},
);
await tester.pumpWidget(
Actions(
dispatcher: TestDispatcher1(postInvoke: collect),
actions: <LocalKey, ActionFactory>{
TestAction.key: () => testAction,
},
child: Actions(
actions: const <LocalKey, ActionFactory>{},
child: Container(key: containerKey),
),
),
);
await tester.pump();
final bool result = Actions.invoke(
containerKey.currentContext,
intent,
focusNode: testNode,
);
expect(passedNode, equals(testNode));
expect(invokedNode, equals(testNode));
expect(result, isTrue);
expect(invoked, isTrue);
expect(invokedIntent, equals(intent));
expect(invokedAction, equals(testAction));
expect(invokedDispatcher.runtimeType, equals(TestDispatcher1));
});
testWidgets('$Actions widget can be found with of', (WidgetTester tester) async { testWidgets('$Actions widget can be found with of', (WidgetTester tester) async {
final GlobalKey containerKey = GlobalKey(); final GlobalKey containerKey = GlobalKey();
final ActionDispatcher testDispatcher = TestDispatcher1(postInvoke: collect); final ActionDispatcher testDispatcher = TestDispatcher1(postInvoke: collect);
...@@ -214,7 +254,8 @@ void main() { ...@@ -214,7 +254,8 @@ void main() {
await tester.pump(); await tester.pump();
final ActionDispatcher dispatcher = Actions.of( final ActionDispatcher dispatcher = Actions.of(
containerKey.currentContext, nullOk: true, containerKey.currentContext,
nullOk: true,
); );
expect(dispatcher, equals(testDispatcher)); expect(dispatcher, equals(testDispatcher));
}); });
...@@ -254,14 +295,18 @@ void main() { ...@@ -254,14 +295,18 @@ void main() {
testWidgets('default $Actions debugFillProperties', (WidgetTester tester) async { testWidgets('default $Actions debugFillProperties', (WidgetTester tester) async {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
Actions(actions: const <LocalKey, ActionFactory>{}, child: Container()).debugFillProperties(builder); Actions(
actions: const <LocalKey, ActionFactory>{},
dispatcher: const ActionDispatcher(),
child: Container(),
).debugFillProperties(builder);
final List<String> description = builder.properties final List<String> description = builder.properties
.where((DiagnosticsNode node) { .where((DiagnosticsNode node) {
return !node.isFiltered(DiagnosticLevel.info); return !node.isFiltered(DiagnosticLevel.info);
}) })
.map((DiagnosticsNode node) => node.toString()) .map((DiagnosticsNode node) => node.toString())
.toList(); .toList();
expect(description[0], equalsIgnoringHashCodes('dispatcher: ActionDispatcher#00000')); expect(description[0], equalsIgnoringHashCodes('dispatcher: ActionDispatcher#00000'));
expect(description[1], equals('actions: {}')); expect(description[1], equals('actions: {}'));
...@@ -271,6 +316,7 @@ void main() { ...@@ -271,6 +316,7 @@ void main() {
Actions( Actions(
key: const ValueKey<String>('foo'), key: const ValueKey<String>('foo'),
dispatcher: const ActionDispatcher(),
actions: <LocalKey, ActionFactory>{ actions: <LocalKey, ActionFactory>{
const ValueKey<String>('bar'): () => TestAction(onInvoke: (FocusNode node, Intent intent) {}), const ValueKey<String>('bar'): () => TestAction(onInvoke: (FocusNode node, Intent intent) {}),
}, },
...@@ -278,11 +324,11 @@ void main() { ...@@ -278,11 +324,11 @@ void main() {
).debugFillProperties(builder); ).debugFillProperties(builder);
final List<String> description = builder.properties final List<String> description = builder.properties
.where((DiagnosticsNode node) { .where((DiagnosticsNode node) {
return !node.isFiltered(DiagnosticLevel.info); return !node.isFiltered(DiagnosticLevel.info);
}) })
.map((DiagnosticsNode node) => node.toString()) .map((DiagnosticsNode node) => node.toString())
.toList(); .toList();
expect(description[0], equalsIgnoringHashCodes('dispatcher: ActionDispatcher#00000')); expect(description[0], equalsIgnoringHashCodes('dispatcher: ActionDispatcher#00000'));
expect(description[1], equals('actions: {[<\'bar\'>]: Closure: () => TestAction}')); expect(description[1], equals('actions: {[<\'bar\'>]: Closure: () => TestAction}'));
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment