Skip to content

Commit

Permalink
Add VoidCallbackAction and VoidCallbackIntent (#103518)
Browse files Browse the repository at this point in the history
This adds a simple VoidCallbackAction and VoidCallbackIntent that allows configuring an intent that will invoke a void callback when the intent is sent to the action subsystem. This allows binding a shortcut directly to a void callback in a Shortcuts widget.

I also added an instance of VoidCallbackAction to the default actions so that simply binding a shortcut to a VoidCallbackIntent works anywhere in the app, and you don't need to add a VoidCallbackAction at the top of your app to make it work.
  • Loading branch information
gspencergoog committed May 17, 2022
1 parent c248854 commit 1994027
Show file tree
Hide file tree
Showing 3 changed files with 123 additions and 82 deletions.
39 changes: 33 additions & 6 deletions packages/flutter/lib/src/widgets/actions.dart
Expand Up @@ -1296,7 +1296,34 @@ class _FocusableActionDetectorState extends State<FocusableActionDetector> {
}
}

/// An [Intent], that is bound to a [DoNothingAction].
/// An [Intent] that keeps a [VoidCallback] to be invoked by a
/// [VoidCallbackAction] when it receives this intent.
class VoidCallbackIntent extends Intent {
/// Creates a [VoidCallbackIntent].
const VoidCallbackIntent(this.callback);

/// The callback that is to be called by the [VoidCallbackAction] that
/// receives this intent.
final VoidCallback callback;
}

/// An [Action] that invokes the [VoidCallback] given to it in the
/// [VoidCallbackIntent] passed to it when invoked.
///
/// See also:
///
/// * [CallbackAction], which is an action that will invoke a callback with the
/// intent passed to the action's invoke method. The callback is configured
/// on the action, not the intent, like this class.
class VoidCallbackAction extends Action<VoidCallbackIntent> {
@override
Object? invoke(VoidCallbackIntent intent) {
intent.callback();
return null;
}
}

/// An [Intent] that 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 and
Expand All @@ -1317,7 +1344,7 @@ class DoNothingIntent extends Intent {
const DoNothingIntent._();
}

/// An [Intent], that is bound to a [DoNothingAction], but, in addition to not
/// An [Intent] that is bound to a [DoNothingAction], but, in addition to not
/// performing an action, also stops the propagation of the key event bound to
/// this intent to other key event handlers in the focus chain.
///
Expand All @@ -1342,7 +1369,7 @@ class DoNothingAndStopPropagationIntent extends Intent {
const DoNothingAndStopPropagationIntent._();
}

/// An [Action], that doesn't perform any action when invoked.
/// An [Action] that doesn't perform any action when invoked.
///
/// Attaching a [DoNothingAction] to an [Actions.actions] mapping is a way to
/// disable an action defined by a widget higher in the widget hierarchy.
Expand Down Expand Up @@ -1411,15 +1438,15 @@ class ButtonActivateIntent extends Intent {
const ButtonActivateIntent();
}

/// An action that activates the currently focused control.
/// An [Action] that activates the currently focused control.
///
/// This is an abstract class that serves as a base class for actions that
/// 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> { }

/// An intent that selects the currently focused control.
/// An [Intent] that selects the currently focused control.
class SelectIntent extends Intent { }

/// An action that selects the currently focused control.
Expand All @@ -1441,7 +1468,7 @@ class DismissIntent extends Intent {
const DismissIntent();
}

/// An action that dismisses the focused widget.
/// An [Action] that dismisses the focused widget.
///
/// This is an abstract class that serves as a base class for dismiss actions.
abstract class DismissAction extends Action<DismissIntent> { }
Expand Down
1 change: 1 addition & 0 deletions packages/flutter/lib/src/widgets/app.dart
Expand Up @@ -1289,6 +1289,7 @@ class WidgetsApp extends StatefulWidget {
DirectionalFocusIntent: DirectionalFocusAction(),
ScrollIntent: ScrollAction(),
PrioritizedIntents: PrioritizedAction(),
VoidCallbackIntent: VoidCallbackAction(),
};

@override
Expand Down
165 changes: 89 additions & 76 deletions packages/flutter/test/widgets/actions_test.dart
Expand Up @@ -9,83 +9,7 @@ import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';

typedef PostInvokeCallback = void Function({Action<Intent> action, Intent intent, ActionDispatcher dispatcher});

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(onInvoke: onInvoke);

@override
bool isEnabled(TestIntent intent) => enabled;

bool get enabled => _enabled;
bool _enabled = true;
set enabled(bool value) {
if (_enabled == value) {
return;
}
_enabled = value;
notifyActionListeners();
}

@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 {
const TestDispatcher({this.postInvoke});

final PostInvokeCallback? postInvoke;

@override
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;
}
}

class TestDispatcher1 extends TestDispatcher {
const TestDispatcher1({super.postInvoke});
}

void main() {
testWidgets('CallbackAction passes correct intent when invoked.', (WidgetTester tester) async {
late Intent passedIntent;
final TestAction action = TestAction(onInvoke: (Intent intent) {
passedIntent = intent;
return true;
});
const TestIntent intent = TestIntent();
action._testInvoke(intent);
expect(passedIntent, equals(intent));
});
group(ActionDispatcher, () {
testWidgets('ActionDispatcher invokes actions when asked.', (WidgetTester tester) async {
await tester.pumpWidget(Container());
Expand Down Expand Up @@ -1033,6 +957,29 @@ void main() {
);
});

group('Action subclasses', () {
testWidgets('CallbackAction passes correct intent when invoked.', (WidgetTester tester) async {
late Intent passedIntent;
final TestAction action = TestAction(onInvoke: (Intent intent) {
passedIntent = intent;
return true;
});
const TestIntent intent = TestIntent();
action._testInvoke(intent);
expect(passedIntent, equals(intent));
});
testWidgets('VoidCallbackAction', (WidgetTester tester) async {
bool called = false;
void testCallback() {
called = true;
}
final VoidCallbackAction action = VoidCallbackAction();
final VoidCallbackIntent intent = VoidCallbackIntent(testCallback);
action.invoke(intent);
expect(called, isTrue);
});
});

group('Diagnostics', () {
testWidgets('default Intent debugFillProperties', (WidgetTester tester) async {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
Expand Down Expand Up @@ -1766,6 +1713,72 @@ void main() {
});
}

typedef PostInvokeCallback = void Function({Action<Intent> action, Intent intent, ActionDispatcher dispatcher});

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(onInvoke: onInvoke);

@override
bool isEnabled(TestIntent intent) => enabled;

bool get enabled => _enabled;
bool _enabled = true;
set enabled(bool value) {
if (_enabled == value) {
return;
}
_enabled = value;
notifyActionListeners();
}

@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 {
const TestDispatcher({this.postInvoke});

final PostInvokeCallback? postInvoke;

@override
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;
}
}

class TestDispatcher1 extends TestDispatcher {
const TestDispatcher1({super.postInvoke});
}

class TestContextAction extends ContextAction<TestIntent> {
List<BuildContext?> capturedContexts = <BuildContext?>[];

Expand Down

0 comments on commit 1994027

Please sign in to comment.