blob: 6385f4c6d325d70e869503098bf8603c520ac7bc [file] [log] [blame]
// Copyright 2014 The Flutter Authors. All rights reserved.
// 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/scheduler.dart';
import 'package:flutter/gestures.dart';
import 'basic.dart';
import 'focus_manager.dart';
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();
/// 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.
class Intent with Diagnosticable {
/// A const constructor for an [Intent].
///
/// The [key] argument must not be null.
const Intent(this.key) : assert(key != null);
/// An intent that can't be mapped to an action.
///
/// 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));
}
}
/// Base class for actions.
///
/// As the name implies, an [Action] is an action or command to be performed.
/// They are typically invoked as a result of a user action, such as a keyboard
/// shortcut in a [Shortcuts] widget, which is used to look up an [Intent],
/// which is given to an [ActionDispatcher] to map the [Intent] to an [Action]
/// and invoke it.
///
/// The [ActionDispatcher] can invoke an [Action] on the primary focus, or
/// without regard for focus.
///
/// See also:
///
/// * [Shortcuts], which is a widget that contains a key map, in which it looks
/// 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);
/// The unique key for this action.
///
/// This key will be used to map to this action in an [ActionDispatcher].
final LocalKey intentKey;
/// 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 method is only meant to be invoked by an [ActionDispatcher], or by
/// subclasses.
///
/// 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.
@protected
void invoke(FocusNode node, covariant Intent intent);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<LocalKey>('intentKey', intentKey));
}
}
/// The signature of a callback accepted by [CallbackAction].
typedef OnInvokeCallback = void Function(FocusNode node, Intent tag);
/// An [Action] that takes a callback in order to configure it without having to
/// subclass it.
///
/// See also:
///
/// * [Shortcuts], which is a widget that contains a key map, in which it looks
/// 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.
class CallbackAction extends Action {
/// A const constructor for an [Action].
///
/// 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);
/// The callback to be called when invoked.
///
/// Must not be null.
@protected
final OnInvokeCallback onInvoke;
@override
void invoke(FocusNode node, Intent intent) => onInvoke.call(node, intent);
}
/// An action manager that simply invokes the actions given to it.
class ActionDispatcher with Diagnosticable {
/// Const constructor so that subclasses can be const.
const ActionDispatcher();
/// Invokes the given action, optionally without regard for the currently
/// focused node in the focus tree.
///
/// Actions invoked will receive the given `focusNode`, or the
/// [FocusManager.primaryFocus] if the given `focusNode` is null.
///
/// The `action` and `intent` arguments must not be null.
///
/// Returns true if the action was successfully invoked.
bool invokeAction(Action action, Intent intent, {FocusNode focusNode}) {
assert(action != null);
assert(intent != null);
focusNode ??= primaryFocus;
if (action != null && intent.isEnabled(focusNode.context)) {
action.invoke(focusNode, intent);
return true;
}
return false;
}
}
/// A widget that establishes an [ActionDispatcher] and a map of [Intent] to
/// [Action] to be used by its descendants when invoking an [Action].
///
/// Actions are typically invoked using [Actions.invoke] with the context
/// containing the ambient [Actions] widget.
///
/// See also:
///
/// * [ActionDispatcher], the object that this widget uses to manage actions.
/// * [Action], a class for containing and defining an invocation of a user
/// action.
/// * [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 {
/// Creates an [Actions] widget.
///
/// The [child], [actions], and [dispatcher] arguments must not be null.
const Actions({
Key key,
this.dispatcher,
@required this.actions,
@required Widget child,
}) : assert(actions != null),
super(key: key, child: child);
/// The [ActionDispatcher] object that invokes actions.
///
/// 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;
/// {@template flutter.widgets.actions.actions}
/// A map of [Intent] keys to [ActionFactory] factory methods 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;
// 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;
}
element.visitAncestorElements(visitAncestorElement);
}
return dispatcher ?? const ActionDispatcher();
}
/// Returns the [ActionDispatcher] associated with the [Actions] widget that
/// most tightly encloses the given [BuildContext].
///
/// Will throw if no ambient [Actions] widget is found.
///
/// If `nullOk` is set to true, then if no ambient [Actions] widget is found,
/// this will return null.
///
/// 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;
assert(() {
if (nullOk) {
return true;
}
if (inherited == null) {
throw FlutterError('Unable to find an $Actions widget in the 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 '
'was passed to $Actions.of(). This can happen if the context comes '
'from a widget above those widgets.\n'
'The context used was:\n'
' $context');
}
return true;
}());
return inherited?.dispatcher ?? _findDispatcher(inheritedElement);
}
/// Invokes the action associated with the given [Intent] using the
/// [Actions] widget that most tightly encloses the given [BuildContext].
///
/// 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
/// 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(
BuildContext context,
Intent intent, {
FocusNode focusNode,
bool nullOk = false,
}) {
assert(context != null);
assert(intent != null);
Element actionsElement;
Action action;
bool visitAncestorElement(Element element) {
if (element.widget is! Actions) {
// Continue visiting.
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;
}
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'
'The context used was:\n'
' $context\n'
'The intent requested was:\n'
' $intent');
}
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);
}
@override
bool updateShouldNotify(Actions oldWidget) {
return oldWidget.dispatcher != dispatcher || !mapEquals<LocalKey, ActionFactory>(oldWidget.actions, actions);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<ActionDispatcher>('dispatcher', dispatcher));
properties.add(DiagnosticsProperty<Map<LocalKey, ActionFactory>>('actions', actions));
}
}
/// A widget that combines the functionality of [Actions], [Shortcuts],
/// [MouseRegion] and a [Focus] widget to create a detector that defines actions
/// and key bindings, and provides callbacks for handling focus and hover
/// highlights.
///
/// This widget can be used to give a control the required detection modes for
/// focus and hover handling. It is most often used when authoring a new control
/// widget, and the new control should be enabled for keyboard traversal and
/// activation.
///
/// {@tool dartpad --template=stateful_widget_material}
/// This example shows how keyboard interaction can be added to a custom control
/// that changes color when hovered and focused, and can toggle a light when
/// activated, either by touch or by hitting the `X` key on the keyboard when
/// the "And Me" button has the keyboard focus (be sure to use TAB to move the
/// focus to the "And Me" button before trying it out).
///
/// This example defines its own key binding for the `X` key, but in this case,
/// there is also a default key binding for [ActivateAction] in the default key
/// bindings created by [WidgetsApp] (the parent for [MaterialApp], and
/// [CupertinoApp]), so the `ENTER` key will also activate the buttons.
///
/// ```dart imports
/// import 'package:flutter/services.dart';
/// ```
///
/// ```dart preamble
/// class FadButton extends StatefulWidget {
/// const FadButton({Key key, this.onPressed, this.child}) : super(key: key);
///
/// final VoidCallback onPressed;
/// final Widget child;
///
/// @override
/// _FadButtonState createState() => _FadButtonState();
/// }
///
/// class _FadButtonState extends State<FadButton> {
/// bool _focused = false;
/// bool _hovering = false;
/// bool _on = false;
/// Map<LocalKey, ActionFactory> _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(),
/// );
/// },
/// };
/// _shortcutMap = <LogicalKeySet, Intent>{
/// LogicalKeySet(LogicalKeyboardKey.keyX): Intent(ActivateAction.key),
/// };
/// }
///
/// Color get color {
/// Color baseColor = Colors.lightBlue;
/// if (_focused) {
/// baseColor = Color.alphaBlend(Colors.black.withOpacity(0.25), baseColor);
/// }
/// if (_hovering) {
/// baseColor = Color.alphaBlend(Colors.black.withOpacity(0.1), baseColor);
/// }
/// return baseColor;
/// }
///
/// void _toggleState() {
/// setState(() {
/// _on = !_on;
/// });
/// }
///
/// void _handleFocusHighlight(bool value) {
/// setState(() {
/// _focused = value;
/// });
/// }
///
/// void _handleHoveHighlight(bool value) {
/// setState(() {
/// _hovering = value;
/// });
/// }
///
/// @override
/// Widget build(BuildContext context) {
/// return GestureDetector(
/// onTap: _toggleState,
/// child: FocusableActionDetector(
/// actions: _actionMap,
/// shortcuts: _shortcutMap,
/// onShowFocusHighlight: _handleFocusHighlight,
/// onShowHoverHighlight: _handleHoveHighlight,
/// child: Row(
/// children: <Widget>[
/// Container(
/// padding: EdgeInsets.all(10.0),
/// color: color,
/// child: widget.child,
/// ),
/// Container(
/// width: 30,
/// height: 30,
/// margin: EdgeInsets.all(10.0),
/// color: _on ? Colors.red : Colors.transparent,
/// ),
/// ],
/// ),
/// ),
/// );
/// }
/// }
/// ```
///
/// ```dart
/// Widget build(BuildContext context) {
/// return Scaffold(
/// appBar: AppBar(
/// title: Text('FocusableActionDetector Example'),
/// ),
/// body: Center(
/// child: Row(
/// mainAxisAlignment: MainAxisAlignment.center,
/// children: <Widget>[
/// Padding(
/// padding: const EdgeInsets.all(8.0),
/// child: FlatButton(onPressed: () {}, child: Text('Press Me')),
/// ),
/// Padding(
/// padding: const EdgeInsets.all(8.0),
/// child: FadButton(onPressed: () {}, child: Text('And Me')),
/// ),
/// ],
/// ),
/// ),
/// );
/// }
/// ```
/// {@end-tool}
///
/// This widget doesn't have any visual representation, it is just a detector that
/// provides focus and hover capabilities.
///
/// It hosts its own [FocusNode] or uses [focusNode], if given.
class FocusableActionDetector extends StatefulWidget {
/// Create a const [FocusableActionDetector].
///
/// The [enabled], [autofocus], and [child] arguments must not be null.
const FocusableActionDetector({
Key key,
this.enabled = true,
this.focusNode,
this.autofocus = false,
this.shortcuts,
this.actions,
this.onShowFocusHighlight,
this.onShowHoverHighlight,
this.onFocusChange,
@required this.child,
}) : assert(enabled != null),
assert(autofocus != null),
assert(child != null),
super(key: key);
/// Is this widget enabled or not.
///
/// If disabled, will not send any notifications needed to update highlight or
/// focus state, and will not define or respond to any actions or shortcuts.
///
/// When disabled, adds [Focus] to the widget tree, but sets
/// [Focus.canRequestFocus] to false.
final bool enabled;
/// {@macro flutter.widgets.Focus.focusNode}
final FocusNode focusNode;
/// {@macro flutter.widgets.Focus.autofocus}
final bool autofocus;
/// {@macro flutter.widgets.actions.actions}
final Map<LocalKey, ActionFactory> actions;
/// {@macro flutter.widgets.shortcuts.shortcuts}
final Map<LogicalKeySet, Intent> shortcuts;
/// A function that will be called when the focus highlight should be shown or
/// hidden.
///
/// This method is not triggered at the unmount of the widget.
final ValueChanged<bool> onShowFocusHighlight;
/// A function that will be called when the hover highlight should be shown or hidden.
///
/// This method is not triggered at the unmount of the widget.
final ValueChanged<bool> onShowHoverHighlight;
/// A function that will be called when the focus changes.
///
/// Called with true if the [focusNode] has primary focus.
final ValueChanged<bool> onFocusChange;
/// The child widget for this [FocusableActionDetector] widget.
///
/// {@macro flutter.widgets.child}
final Widget child;
@override
_FocusableActionDetectorState createState() => _FocusableActionDetectorState();
}
class _FocusableActionDetectorState extends State<FocusableActionDetector> {
@override
void initState() {
super.initState();
SchedulerBinding.instance.addPostFrameCallback((Duration duration) {
_updateHighlightMode(FocusManager.instance.highlightMode);
});
FocusManager.instance.addHighlightModeListener(_handleFocusHighlightModeChange);
}
@override
void dispose() {
FocusManager.instance.removeHighlightModeListener(_handleFocusHighlightModeChange);
super.dispose();
}
bool _canShowHighlight = false;
void _updateHighlightMode(FocusHighlightMode mode) {
_mayTriggerCallback(task: () {
switch (FocusManager.instance.highlightMode) {
case FocusHighlightMode.touch:
_canShowHighlight = false;
break;
case FocusHighlightMode.traditional:
_canShowHighlight = true;
break;
}
});
}
// Have to have this separate from the _updateHighlightMode because it gets
// called in initState, where things aren't mounted yet.
// Since this method is a highlight mode listener, it is only called
// immediately following pointer events.
void _handleFocusHighlightModeChange(FocusHighlightMode mode) {
if (!mounted) {
return;
}
_updateHighlightMode(mode);
}
bool _hovering = false;
void _handleMouseEnter(PointerEnterEvent event) {
assert(widget.onShowHoverHighlight != null);
if (!_hovering) {
_mayTriggerCallback(task: () {
_hovering = true;
});
}
}
void _handleMouseExit(PointerExitEvent event) {
assert(widget.onShowHoverHighlight != null);
if (_hovering) {
_mayTriggerCallback(task: () {
_hovering = false;
});
}
}
bool _focused = false;
void _handleFocusChange(bool focused) {
if (_focused != focused) {
_mayTriggerCallback(task: () {
_focused = focused;
});
widget.onFocusChange?.call(_focused);
}
}
// Record old states, do `task` if not null, then compare old states with the
// new states, and trigger callbacks if necessary.
//
// The old states are collected from `oldWidget` if it is provided, or the
// current widget (before doing `task`) otherwise. The new states are always
// collected from the current widget.
void _mayTriggerCallback({VoidCallback task, FocusableActionDetector oldWidget}) {
bool shouldShowHoverHighlight(FocusableActionDetector target) {
return _hovering && target.enabled && _canShowHighlight;
}
bool shouldShowFocusHighlight(FocusableActionDetector target) {
return _focused && target.enabled && _canShowHighlight;
}
assert(SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks);
final FocusableActionDetector oldTarget = oldWidget ?? widget;
final bool didShowHoverHighlight = shouldShowHoverHighlight(oldTarget);
final bool didShowFocusHighlight = shouldShowFocusHighlight(oldTarget);
if (task != null)
task();
final bool doShowHoverHighlight = shouldShowHoverHighlight(widget);
final bool doShowFocusHighlight = shouldShowFocusHighlight(widget);
if (didShowFocusHighlight != doShowFocusHighlight)
widget.onShowFocusHighlight?.call(doShowFocusHighlight);
if (didShowHoverHighlight != doShowHoverHighlight)
widget.onShowHoverHighlight?.call(doShowHoverHighlight);
}
@override
void didUpdateWidget(FocusableActionDetector oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.enabled != oldWidget.enabled) {
SchedulerBinding.instance.addPostFrameCallback((Duration duration) {
_mayTriggerCallback(oldWidget: oldWidget);
});
}
}
@override
Widget build(BuildContext context) {
Widget child = MouseRegion(
onEnter: _handleMouseEnter,
onExit: _handleMouseExit,
child: Focus(
focusNode: widget.focusNode,
autofocus: widget.autofocus,
canRequestFocus: widget.enabled,
onFocusChange: _handleFocusChange,
child: widget.child,
),
);
if (widget.enabled && widget.actions != null && widget.actions.isNotEmpty) {
child = Actions(actions: widget.actions, child: child);
}
if (widget.enabled && widget.shortcuts != null && widget.shortcuts.isNotEmpty) {
child = Shortcuts(shortcuts: widget.shortcuts, child: child);
}
return child;
}
}
/// 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);
@override
void invoke(FocusNode node, Intent intent) { }
}
/// An action that invokes 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);
/// The [LocalKey] that uniquely identifies this action.
static const LocalKey key = ValueKey<Type>(ActivateAction);
}
/// 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);
}