Unverified Commit 6e10719d authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

FocusableActionDetector widget (#44867)

This adds a FocusableActionDetector, a widget which combines the functionality of Actions, Shortcuts, MouseRegion and a Focus widget to create a detector that defines actions and key bindings, and will notify that the focus or hover highlights should be shown or not. This widget can be used to give a control the required detection modes for focus and hover handling on desktop and web platforms.

I replaced a bunch of similar code in many of our widgets with this widget, and found that pretty much any control that wants to be focusable wants all of these features as well: focus highlights, hover highlights, and actions to activate it.

Also eliminated an extra _hasFocus variable in FocusState that wasn't being used.
parent cce445e2
...@@ -159,7 +159,6 @@ class Checkbox extends StatefulWidget { ...@@ -159,7 +159,6 @@ class Checkbox extends StatefulWidget {
class _CheckboxState extends State<Checkbox> with TickerProviderStateMixin { class _CheckboxState extends State<Checkbox> with TickerProviderStateMixin {
bool get enabled => widget.onChanged != null; bool get enabled => widget.onChanged != null;
Map<LocalKey, ActionFactory> _actionMap; Map<LocalKey, ActionFactory> _actionMap;
bool _showHighlight = false;
@override @override
void initState() { void initState() {
...@@ -168,8 +167,6 @@ class _CheckboxState extends State<Checkbox> with TickerProviderStateMixin { ...@@ -168,8 +167,6 @@ class _CheckboxState extends State<Checkbox> with TickerProviderStateMixin {
SelectAction.key: _createAction, SelectAction.key: _createAction,
if (!kIsWeb) ActivateAction.key: _createAction, if (!kIsWeb) ActivateAction.key: _createAction,
}; };
_updateHighlightMode(FocusManager.instance.highlightMode);
FocusManager.instance.addHighlightModeListener(_handleFocusHighlightModeChange);
} }
void _actionHandler(FocusNode node, Intent intent){ void _actionHandler(FocusNode node, Intent intent){
...@@ -197,28 +194,20 @@ class _CheckboxState extends State<Checkbox> with TickerProviderStateMixin { ...@@ -197,28 +194,20 @@ class _CheckboxState extends State<Checkbox> with TickerProviderStateMixin {
); );
} }
void _updateHighlightMode(FocusHighlightMode mode) { bool _focused = false;
switch (FocusManager.instance.highlightMode) { void _handleFocusHighlightChanged(bool focused) {
case FocusHighlightMode.touch: if (focused != _focused) {
_showHighlight = false; setState(() { _focused = focused; });
break;
case FocusHighlightMode.traditional:
_showHighlight = true;
break;
} }
} }
void _handleFocusHighlightModeChange(FocusHighlightMode mode) { bool _hovering = false;
if (!mounted) { void _handleHoverChanged(bool hovering) {
return; if (hovering != _hovering) {
setState(() { _hovering = hovering; });
} }
setState(() { _updateHighlightMode(mode); });
} }
bool hovering = false;
void _handleMouseEnter(PointerEnterEvent event) => setState(() { hovering = true; });
void _handleMouseExit(PointerExitEvent event) => setState(() { hovering = false; });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
assert(debugCheckHasMaterial(context)); assert(debugCheckHasMaterial(context));
...@@ -233,35 +222,30 @@ class _CheckboxState extends State<Checkbox> with TickerProviderStateMixin { ...@@ -233,35 +222,30 @@ class _CheckboxState extends State<Checkbox> with TickerProviderStateMixin {
break; break;
} }
final BoxConstraints additionalConstraints = BoxConstraints.tight(size); final BoxConstraints additionalConstraints = BoxConstraints.tight(size);
return MouseRegion( return FocusableActionDetector(
onEnter: enabled ? _handleMouseEnter : null, actions: _actionMap,
onExit: enabled ? _handleMouseExit : null, focusNode: widget.focusNode,
child: Actions( autofocus: widget.autofocus,
actions: _actionMap, enabled: enabled,
child: Focus( onShowFocusHighlight: _handleFocusHighlightChanged,
focusNode: widget.focusNode, onShowHoverHighlight: _handleHoverChanged,
autofocus: widget.autofocus, child: Builder(
canRequestFocus: enabled, builder: (BuildContext context) {
debugLabel: '${describeIdentity(widget)}(${widget.value})', return _CheckboxRenderObjectWidget(
child: Builder( value: widget.value,
builder: (BuildContext context) { tristate: widget.tristate,
return _CheckboxRenderObjectWidget( activeColor: widget.activeColor ?? themeData.toggleableActiveColor,
value: widget.value, checkColor: widget.checkColor ?? const Color(0xFFFFFFFF),
tristate: widget.tristate, inactiveColor: enabled ? themeData.unselectedWidgetColor : themeData.disabledColor,
activeColor: widget.activeColor ?? themeData.toggleableActiveColor, focusColor: widget.focusColor ?? themeData.focusColor,
checkColor: widget.checkColor ?? const Color(0xFFFFFFFF), hoverColor: widget.hoverColor ?? themeData.hoverColor,
inactiveColor: enabled ? themeData.unselectedWidgetColor : themeData.disabledColor, onChanged: widget.onChanged,
focusColor: widget.focusColor ?? themeData.focusColor, additionalConstraints: additionalConstraints,
hoverColor: widget.hoverColor ?? themeData.hoverColor, vsync: this,
onChanged: widget.onChanged, hasFocus: _focused,
additionalConstraints: additionalConstraints, hovering: _hovering,
vsync: this, );
hasFocus: enabled && _showHighlight && Focus.of(context).hasFocus, },
hovering: enabled && _showHighlight && hovering,
);
},
),
),
), ),
); );
} }
......
...@@ -187,7 +187,6 @@ class Radio<T> extends StatefulWidget { ...@@ -187,7 +187,6 @@ class Radio<T> extends StatefulWidget {
class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin { class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin {
bool get enabled => widget.onChanged != null; bool get enabled => widget.onChanged != null;
Map<LocalKey, ActionFactory> _actionMap; Map<LocalKey, ActionFactory> _actionMap;
bool _showHighlight = false;
@override @override
void initState() { void initState() {
...@@ -196,8 +195,6 @@ class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin { ...@@ -196,8 +195,6 @@ class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin {
SelectAction.key: _createAction, SelectAction.key: _createAction,
if (!kIsWeb) ActivateAction.key: _createAction, if (!kIsWeb) ActivateAction.key: _createAction,
}; };
_updateHighlightMode(FocusManager.instance.highlightMode);
FocusManager.instance.addHighlightModeListener(_handleFocusHighlightModeChange);
} }
void _actionHandler(FocusNode node, Intent intent){ void _actionHandler(FocusNode node, Intent intent){
...@@ -215,28 +212,20 @@ class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin { ...@@ -215,28 +212,20 @@ class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin {
); );
} }
void _updateHighlightMode(FocusHighlightMode mode) { bool _focused = false;
switch (FocusManager.instance.highlightMode) { void _handleHighlightChanged(bool focused) {
case FocusHighlightMode.touch: if (_focused != focused) {
_showHighlight = false; setState(() { _focused = focused; });
break;
case FocusHighlightMode.traditional:
_showHighlight = true;
break;
} }
} }
void _handleFocusHighlightModeChange(FocusHighlightMode mode) { bool _hovering = false;
if (!mounted) { void _handleHoverChanged(bool hovering) {
return; if (_hovering != hovering) {
setState(() { _hovering = hovering; });
} }
setState(() { _updateHighlightMode(mode); });
} }
bool hovering = false;
void _handleMouseEnter(PointerEnterEvent event) => setState(() { hovering = true; });
void _handleMouseExit(PointerExitEvent event) => setState(() { hovering = false; });
Color _getInactiveColor(ThemeData themeData) { Color _getInactiveColor(ThemeData themeData) {
return enabled ? themeData.unselectedWidgetColor : themeData.disabledColor; return enabled ? themeData.unselectedWidgetColor : themeData.disabledColor;
} }
...@@ -260,33 +249,28 @@ class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin { ...@@ -260,33 +249,28 @@ class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin {
break; break;
} }
final BoxConstraints additionalConstraints = BoxConstraints.tight(size); final BoxConstraints additionalConstraints = BoxConstraints.tight(size);
return MouseRegion( return FocusableActionDetector(
onEnter: enabled ? _handleMouseEnter : null, actions: _actionMap,
onExit: enabled ? _handleMouseExit : null, focusNode: widget.focusNode,
child: Actions( autofocus: widget.autofocus,
actions: _actionMap, enabled: enabled,
child: Focus( onShowFocusHighlight: _handleHighlightChanged,
focusNode: widget.focusNode, onShowHoverHighlight: _handleHoverChanged,
autofocus: widget.autofocus, child: Builder(
canRequestFocus: enabled, builder: (BuildContext context) {
debugLabel: '${describeIdentity(widget)}(${widget.value})', return _RadioRenderObjectWidget(
child: Builder( selected: widget.value == widget.groupValue,
builder: (BuildContext context) { activeColor: widget.activeColor ?? themeData.toggleableActiveColor,
return _RadioRenderObjectWidget( inactiveColor: _getInactiveColor(themeData),
selected: widget.value == widget.groupValue, focusColor: widget.focusColor ?? themeData.focusColor,
activeColor: widget.activeColor ?? themeData.toggleableActiveColor, hoverColor: widget.hoverColor ?? themeData.hoverColor,
inactiveColor: _getInactiveColor(themeData), onChanged: enabled ? _handleChanged : null,
focusColor: widget.focusColor ?? themeData.focusColor, additionalConstraints: additionalConstraints,
hoverColor: widget.hoverColor ?? themeData.hoverColor, vsync: this,
onChanged: enabled ? _handleChanged : null, hasFocus: _focused,
additionalConstraints: additionalConstraints, hovering: _hovering,
vsync: this, );
hasFocus: enabled && _showHighlight && Focus.of(context).hasFocus, },
hovering: enabled && _showHighlight && hovering,
);
},
),
),
), ),
); );
} }
......
...@@ -214,7 +214,6 @@ class Switch extends StatefulWidget { ...@@ -214,7 +214,6 @@ class Switch extends StatefulWidget {
class _SwitchState extends State<Switch> with TickerProviderStateMixin { class _SwitchState extends State<Switch> with TickerProviderStateMixin {
Map<LocalKey, ActionFactory> _actionMap; Map<LocalKey, ActionFactory> _actionMap;
bool _showHighlight = false;
@override @override
void initState() { void initState() {
...@@ -223,8 +222,6 @@ class _SwitchState extends State<Switch> with TickerProviderStateMixin { ...@@ -223,8 +222,6 @@ class _SwitchState extends State<Switch> with TickerProviderStateMixin {
SelectAction.key: _createAction, SelectAction.key: _createAction,
if (!kIsWeb) ActivateAction.key: _createAction, if (!kIsWeb) ActivateAction.key: _createAction,
}; };
_updateHighlightMode(FocusManager.instance.highlightMode);
FocusManager.instance.addHighlightModeListener(_handleFocusHighlightModeChange);
} }
void _actionHandler(FocusNode node, Intent intent){ void _actionHandler(FocusNode node, Intent intent){
...@@ -242,22 +239,18 @@ class _SwitchState extends State<Switch> with TickerProviderStateMixin { ...@@ -242,22 +239,18 @@ class _SwitchState extends State<Switch> with TickerProviderStateMixin {
); );
} }
void _updateHighlightMode(FocusHighlightMode mode) { bool _focused = false;
switch (FocusManager.instance.highlightMode) { void _handleFocusHighlightChanged(bool focused) {
case FocusHighlightMode.touch: if (focused != _focused) {
_showHighlight = false; setState(() { _focused = focused; });
break;
case FocusHighlightMode.traditional:
_showHighlight = true;
break;
} }
} }
void _handleFocusHighlightModeChange(FocusHighlightMode mode) { bool _hovering = false;
if (!mounted) { void _handleHoverChanged(bool hovering) {
return; if (hovering != _hovering) {
setState(() { _hovering = hovering; });
} }
setState(() { _updateHighlightMode(mode); });
} }
Size getSwitchSize(ThemeData theme) { Size getSwitchSize(ThemeData theme) {
...@@ -275,10 +268,6 @@ class _SwitchState extends State<Switch> with TickerProviderStateMixin { ...@@ -275,10 +268,6 @@ class _SwitchState extends State<Switch> with TickerProviderStateMixin {
bool get enabled => widget.onChanged != null; bool get enabled => widget.onChanged != null;
bool hovering = false;
void _handleMouseEnter(PointerEnterEvent event) => setState(() { hovering = true; });
void _handleMouseExit(PointerExitEvent event) => setState(() { hovering = false; });
Widget buildMaterialSwitch(BuildContext context) { Widget buildMaterialSwitch(BuildContext context) {
assert(debugCheckHasMaterial(context)); assert(debugCheckHasMaterial(context));
final ThemeData theme = Theme.of(context); final ThemeData theme = Theme.of(context);
...@@ -300,40 +289,34 @@ class _SwitchState extends State<Switch> with TickerProviderStateMixin { ...@@ -300,40 +289,34 @@ class _SwitchState extends State<Switch> with TickerProviderStateMixin {
inactiveTrackColor = widget.inactiveTrackColor ?? (isDark ? Colors.white10 : Colors.black12); inactiveTrackColor = widget.inactiveTrackColor ?? (isDark ? Colors.white10 : Colors.black12);
} }
return MouseRegion( return FocusableActionDetector(
onEnter: enabled ? _handleMouseEnter : null, actions: _actionMap,
onExit: enabled ? _handleMouseExit : null, focusNode: widget.focusNode,
child: Actions( autofocus: widget.autofocus,
actions: _actionMap, enabled: enabled,
child: Focus( onShowFocusHighlight: _handleFocusHighlightChanged,
focusNode: widget.focusNode, onShowHoverHighlight: _handleHoverChanged,
autofocus: widget.autofocus, child: Builder(
canRequestFocus: enabled, builder: (BuildContext context) {
debugLabel: '${describeIdentity(widget)}({$widget.value})', return _SwitchRenderObjectWidget(
child: Builder( dragStartBehavior: widget.dragStartBehavior,
builder: (BuildContext context) { value: widget.value,
final bool hasFocus = Focus.of(context).hasFocus; activeColor: activeThumbColor,
return _SwitchRenderObjectWidget( inactiveColor: inactiveThumbColor,
dragStartBehavior: widget.dragStartBehavior, hoverColor: hoverColor,
value: widget.value, focusColor: focusColor,
activeColor: activeThumbColor, activeThumbImage: widget.activeThumbImage,
inactiveColor: inactiveThumbColor, inactiveThumbImage: widget.inactiveThumbImage,
hoverColor: hoverColor, activeTrackColor: activeTrackColor,
focusColor: focusColor, inactiveTrackColor: inactiveTrackColor,
activeThumbImage: widget.activeThumbImage, configuration: createLocalImageConfiguration(context),
inactiveThumbImage: widget.inactiveThumbImage, onChanged: widget.onChanged,
activeTrackColor: activeTrackColor, additionalConstraints: BoxConstraints.tight(getSwitchSize(theme)),
inactiveTrackColor: inactiveTrackColor, hasFocus: _focused,
configuration: createLocalImageConfiguration(context), hovering: _hovering,
onChanged: widget.onChanged, vsync: this,
additionalConstraints: BoxConstraints.tight(getSwitchSize(theme)), );
hasFocus: enabled && _showHighlight && hasFocus, },
hovering: enabled && _showHighlight && hovering,
vsync: this,
);
},
),
),
), ),
); );
} }
......
...@@ -474,7 +474,9 @@ abstract class RenderToggleable extends RenderConstrainedBox { ...@@ -474,7 +474,9 @@ abstract class RenderToggleable extends RenderConstrainedBox {
final double reactionRadius = hasFocus || hovering final double reactionRadius = hasFocus || hovering
? kRadialReactionRadius ? kRadialReactionRadius
: _kRadialReactionRadiusTween.evaluate(_reaction); : _kRadialReactionRadiusTween.evaluate(_reaction);
canvas.drawCircle(center + offset, reactionRadius, reactionPaint); if (reactionRadius > 0.0) {
canvas.drawCircle(center + offset, reactionRadius, reactionPaint);
}
} }
} }
......
// Copyright 2019 The Chromium Authors. All rights reserved. // Copyright 2019 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'basic.dart';
import 'focus_manager.dart'; import 'focus_manager.dart';
import 'focus_scope.dart';
import 'framework.dart'; import 'framework.dart';
import 'shortcuts.dart';
/// Creates actions for use in defining shortcuts. /// Creates actions for use in defining shortcuts.
/// ///
...@@ -197,12 +202,14 @@ class Actions extends InheritedWidget { ...@@ -197,12 +202,14 @@ class Actions extends InheritedWidget {
/// default-constructed [ActionDispatcher]. /// default-constructed [ActionDispatcher].
final ActionDispatcher dispatcher; final ActionDispatcher dispatcher;
/// {@template flutter.widgets.actions.actions}
/// 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.
/// ///
/// For performance reasons, it is recommended that a pre-built map is /// For performance reasons, it is recommended that a pre-built map is
/// passed in here (e.g. a final variable from your widget class) instead of /// passed in here (e.g. a final variable from your widget class) instead of
/// defining it inline in the build function. /// defining it inline in the build function.
/// {@endtemplate}
final Map<LocalKey, ActionFactory> actions; final Map<LocalKey, ActionFactory> actions;
// Finds the nearest valid ActionDispatcher, or creates a new one if it // Finds the nearest valid ActionDispatcher, or creates a new one if it
...@@ -355,6 +362,313 @@ class Actions extends InheritedWidget { ...@@ -355,6 +362,313 @@ class Actions extends InheritedWidget {
} }
} }
/// 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 snippet --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.
///
/// 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 control.
///
/// ```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.
final ValueChanged<bool> onShowFocusHighlight;
/// A function that will be called when the hover highlight should be shown or hidden.
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();
_updateHighlightMode(FocusManager.instance.highlightMode);
FocusManager.instance.addHighlightModeListener(_handleFocusHighlightModeChange);
}
@override
void dispose() {
FocusManager.instance.removeHighlightModeListener(_handleFocusHighlightModeChange);
super.dispose();
}
bool _canShowHighlight = false;
void _updateHighlightMode(FocusHighlightMode mode) {
final bool couldShowHighlight = _canShowHighlight;
switch (FocusManager.instance.highlightMode) {
case FocusHighlightMode.touch:
_canShowHighlight = false;
break;
case FocusHighlightMode.traditional:
_canShowHighlight = true;
break;
}
if (couldShowHighlight != _canShowHighlight) {
_handleShowFocusHighlight();
_handleShowHoverHighlight();
}
}
/// Have to have this separate from the _updateHighlightMode because it gets
/// called in initState, where things aren't mounted yet.
void _handleFocusHighlightModeChange(FocusHighlightMode mode) {
if (!mounted) {
return;
}
_updateHighlightMode(mode);
}
bool _hovering = false;
void _handleMouseEnter(PointerEnterEvent event) {
assert(widget.onShowHoverHighlight != null);
if (!_hovering) {
// TODO(gspencergoog): remove scheduleMicrotask once MouseRegion event timing has changed.
scheduleMicrotask(() { setState(() { _hovering = true; _handleShowHoverHighlight(); }); });
}
}
void _handleMouseExit(PointerExitEvent event) {
assert(widget.onShowHoverHighlight != null);
if (_hovering) {
// TODO(gspencergoog): remove scheduleMicrotask once MouseRegion event timing has changed.
scheduleMicrotask(() { setState(() { _hovering = false; _handleShowHoverHighlight(); }); });
}
}
bool _focused = false;
void _handleFocusChange(bool focused) {
if (_focused != focused) {
setState(() {
_focused = focused;
_handleShowFocusHighlight();
widget.onFocusChange?.call(_focused);
});
}
}
void _handleShowHoverHighlight() {
widget.onShowHoverHighlight?.call(_hovering && widget.enabled && _canShowHighlight);
}
void _handleShowFocusHighlight() {
widget.onShowFocusHighlight?.call(_focused && widget.enabled && _canShowHighlight);
}
@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. /// An [Action], that, as the name implies, does nothing.
/// ///
/// This action is bound to the [Intent.doNothing] intent inside of /// This action is bound to the [Intent.doNothing] intent inside of
......
...@@ -325,7 +325,6 @@ class Focus extends StatefulWidget { ...@@ -325,7 +325,6 @@ class Focus extends StatefulWidget {
class _FocusState extends State<Focus> { class _FocusState extends State<Focus> {
FocusNode _internalNode; FocusNode _internalNode;
FocusNode get focusNode => widget.focusNode ?? _internalNode; FocusNode get focusNode => widget.focusNode ?? _internalNode;
bool _hasFocus;
bool _hasPrimaryFocus; bool _hasPrimaryFocus;
bool _canRequestFocus; bool _canRequestFocus;
bool _didAutofocus = false; bool _didAutofocus = false;
...@@ -347,7 +346,6 @@ class _FocusState extends State<Focus> { ...@@ -347,7 +346,6 @@ class _FocusState extends State<Focus> {
_focusAttachment = focusNode.attach(context, onKey: widget.onKey); _focusAttachment = focusNode.attach(context, onKey: widget.onKey);
focusNode.skipTraversal = widget.skipTraversal ?? focusNode.skipTraversal; focusNode.skipTraversal = widget.skipTraversal ?? focusNode.skipTraversal;
focusNode.canRequestFocus = widget.canRequestFocus ?? focusNode.canRequestFocus; focusNode.canRequestFocus = widget.canRequestFocus ?? focusNode.canRequestFocus;
_hasFocus = focusNode.hasFocus;
_canRequestFocus = focusNode.canRequestFocus; _canRequestFocus = focusNode.canRequestFocus;
_hasPrimaryFocus = focusNode.hasPrimaryFocus; _hasPrimaryFocus = focusNode.hasPrimaryFocus;
...@@ -425,22 +423,19 @@ class _FocusState extends State<Focus> { ...@@ -425,22 +423,19 @@ class _FocusState extends State<Focus> {
} }
void _handleFocusChanged() { void _handleFocusChanged() {
if (_hasFocus != focusNode.hasFocus) { final bool hasPrimaryFocus = focusNode.hasPrimaryFocus;
setState(() { final bool canRequestFocus = focusNode.canRequestFocus;
_hasFocus = focusNode.hasFocus; if (widget.onFocusChange != null) {
}); widget.onFocusChange(focusNode.hasFocus);
if (widget.onFocusChange != null) {
widget.onFocusChange(focusNode.hasFocus);
}
} }
if (_hasPrimaryFocus != focusNode.hasPrimaryFocus) { if (_hasPrimaryFocus != hasPrimaryFocus) {
setState(() { setState(() {
_hasPrimaryFocus = focusNode.hasPrimaryFocus; _hasPrimaryFocus = hasPrimaryFocus;
}); });
} }
if (_canRequestFocus != focusNode.canRequestFocus) { if (_canRequestFocus != canRequestFocus) {
setState(() { setState(() {
_canRequestFocus = focusNode.canRequestFocus; _canRequestFocus = canRequestFocus;
}); });
} }
} }
......
...@@ -254,11 +254,13 @@ class Shortcuts extends StatefulWidget { ...@@ -254,11 +254,13 @@ class Shortcuts extends StatefulWidget {
/// [shortcuts] change materially. /// [shortcuts] change materially.
final ShortcutManager manager; final ShortcutManager manager;
/// The map of shortcuts that the [manager] will be given to manage. /// {@template flutter.widgets.shortcuts.shortcuts}
/// The map of shortcuts that the [ShortcutManager] will be given to manage.
/// ///
/// For performance reasons, it is recommended that a pre-built map is passed /// 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 /// in here (e.g. a final variable from your widget class) instead of defining
/// it inline in the build function. /// it inline in the build function.
/// {@endtemplate}
final Map<LogicalKeySet, Intent> shortcuts; final Map<LogicalKeySet, Intent> shortcuts;
/// The child widget for this [Shortcuts] widget. /// The child widget for this [Shortcuts] widget.
......
...@@ -68,7 +68,7 @@ void main() { ...@@ -68,7 +68,7 @@ void main() {
), ),
)); ));
expect(tester.getSemantics(find.byType(Focus)), matchesSemantics( expect(tester.getSemantics(find.byType(Focus).last), matchesSemantics(
hasCheckedState: true, hasCheckedState: true,
hasEnabledState: true, hasEnabledState: true,
isEnabled: true, isEnabled: true,
...@@ -83,7 +83,7 @@ void main() { ...@@ -83,7 +83,7 @@ void main() {
), ),
)); ));
expect(tester.getSemantics(find.byType(Focus)), matchesSemantics( expect(tester.getSemantics(find.byType(Focus).last), matchesSemantics(
hasCheckedState: true, hasCheckedState: true,
hasEnabledState: true, hasEnabledState: true,
isChecked: true, isChecked: true,
...@@ -99,10 +99,9 @@ void main() { ...@@ -99,10 +99,9 @@ void main() {
), ),
)); ));
expect(tester.getSemantics(find.byType(Focus)), matchesSemantics( expect(tester.getSemantics(find.byType(Focus).last), matchesSemantics(
hasCheckedState: true, hasCheckedState: true,
hasEnabledState: true, hasEnabledState: true,
isFocusable: true,
)); ));
await tester.pumpWidget(const Material( await tester.pumpWidget(const Material(
...@@ -112,7 +111,7 @@ void main() { ...@@ -112,7 +111,7 @@ void main() {
), ),
)); ));
expect(tester.getSemantics(find.byType(Focus)), matchesSemantics( expect(tester.getSemantics(find.byType(Focus).last), matchesSemantics(
hasCheckedState: true, hasCheckedState: true,
hasEnabledState: true, hasEnabledState: true,
isChecked: true, isChecked: true,
...@@ -134,7 +133,7 @@ void main() { ...@@ -134,7 +133,7 @@ void main() {
), ),
)); ));
expect(tester.getSemantics(find.byType(Focus)), matchesSemantics( expect(tester.getSemantics(find.byType(Focus).last), matchesSemantics(
label: 'foo', label: 'foo',
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
hasCheckedState: true, hasCheckedState: true,
......
...@@ -180,12 +180,11 @@ void main() { ...@@ -180,12 +180,11 @@ void main() {
expect(semantics, hasSemantics(TestSemantics.root( expect(semantics, hasSemantics(TestSemantics.root(
children: <TestSemantics>[ children: <TestSemantics>[
TestSemantics.rootChild( TestSemantics.rootChild(
id: 1, id: 2,
flags: <SemanticsFlag>[ flags: <SemanticsFlag>[
SemanticsFlag.isInMutuallyExclusiveGroup,
SemanticsFlag.hasCheckedState, SemanticsFlag.hasCheckedState,
SemanticsFlag.hasEnabledState, SemanticsFlag.hasEnabledState,
SemanticsFlag.isFocusable, SemanticsFlag.isInMutuallyExclusiveGroup,
], ],
), ),
], ],
...@@ -202,12 +201,12 @@ void main() { ...@@ -202,12 +201,12 @@ void main() {
expect(semantics, hasSemantics(TestSemantics.root( expect(semantics, hasSemantics(TestSemantics.root(
children: <TestSemantics>[ children: <TestSemantics>[
TestSemantics.rootChild( TestSemantics.rootChild(
id: 1, id: 2,
flags: <SemanticsFlag>[ flags: <SemanticsFlag>[
SemanticsFlag.isInMutuallyExclusiveGroup,
SemanticsFlag.hasCheckedState, SemanticsFlag.hasCheckedState,
SemanticsFlag.isChecked, SemanticsFlag.isChecked,
SemanticsFlag.hasEnabledState, SemanticsFlag.hasEnabledState,
SemanticsFlag.isInMutuallyExclusiveGroup,
], ],
), ),
], ],
...@@ -396,6 +395,7 @@ void main() { ...@@ -396,6 +395,7 @@ void main() {
} }
await tester.pumpWidget(buildApp()); await tester.pumpWidget(buildApp());
await tester.pump();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
Material.of(tester.element(find.byKey(radioKey))), Material.of(tester.element(find.byKey(radioKey))),
...@@ -415,6 +415,7 @@ void main() { ...@@ -415,6 +415,7 @@ void main() {
// Check when the radio isn't selected. // Check when the radio isn't selected.
groupValue = 1; groupValue = 1;
await tester.pumpWidget(buildApp()); await tester.pumpWidget(buildApp());
await tester.pump();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
Material.of(tester.element(find.byKey(radioKey))), Material.of(tester.element(find.byKey(radioKey))),
...@@ -429,6 +430,7 @@ void main() { ...@@ -429,6 +430,7 @@ void main() {
// Check when the radio is selected, but disabled. // Check when the radio is selected, but disabled.
groupValue = 0; groupValue = 0;
await tester.pumpWidget(buildApp(enabled: false)); await tester.pumpWidget(buildApp(enabled: false));
await tester.pump();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
Material.of(tester.element(find.byKey(radioKey))), Material.of(tester.element(find.byKey(radioKey))),
......
...@@ -3,7 +3,9 @@ ...@@ -3,7 +3,9 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
...@@ -38,7 +40,7 @@ class TestDispatcher1 extends TestDispatcher { ...@@ -38,7 +40,7 @@ class TestDispatcher1 extends TestDispatcher {
} }
void main() { void main() {
test('$Action passes parameters on when invoked.', () { test('Action passes parameters on when invoked.', () {
bool invoked = false; bool invoked = false;
FocusNode passedNode; FocusNode passedNode;
final TestAction action = TestAction(onInvoke: (FocusNode node, Intent invocation) { final TestAction action = TestAction(onInvoke: (FocusNode node, Intent invocation) {
...@@ -52,7 +54,7 @@ void main() { ...@@ -52,7 +54,7 @@ void main() {
expect(invoked, isTrue); expect(invoked, isTrue);
}); });
group(ActionDispatcher, () { group(ActionDispatcher, () {
test('$ActionDispatcher invokes actions when asked.', () { test('ActionDispatcher invokes actions when asked.', () {
bool invoked = false; bool invoked = false;
FocusNode passedNode; FocusNode passedNode;
const ActionDispatcher dispatcher = ActionDispatcher(); const ActionDispatcher dispatcher = ActionDispatcher();
...@@ -94,7 +96,7 @@ void main() { ...@@ -94,7 +96,7 @@ void main() {
setUp(clear); setUp(clear);
testWidgets('$Actions widget can invoke actions with default dispatcher', (WidgetTester tester) async { testWidgets('Actions widget can invoke actions with default dispatcher', (WidgetTester tester) async {
final GlobalKey containerKey = GlobalKey(); final GlobalKey containerKey = GlobalKey();
bool invoked = false; bool invoked = false;
FocusNode passedNode; FocusNode passedNode;
...@@ -124,7 +126,7 @@ void main() { ...@@ -124,7 +126,7 @@ void main() {
expect(result, isTrue); expect(result, isTrue);
expect(invoked, isTrue); expect(invoked, isTrue);
}); });
testWidgets('$Actions widget can invoke actions with custom dispatcher', (WidgetTester tester) async { testWidgets('Actions widget can invoke actions with custom dispatcher', (WidgetTester tester) async {
final GlobalKey containerKey = GlobalKey(); final GlobalKey containerKey = GlobalKey();
bool invoked = false; bool invoked = false;
const Intent intent = Intent(TestAction.key); const Intent intent = Intent(TestAction.key);
...@@ -159,7 +161,7 @@ void main() { ...@@ -159,7 +161,7 @@ void main() {
expect(invoked, isTrue); expect(invoked, isTrue);
expect(invokedIntent, equals(intent)); expect(invokedIntent, equals(intent));
}); });
testWidgets('$Actions can invoke actions in ancestor dispatcher', (WidgetTester tester) async { testWidgets('Actions can invoke actions in ancestor dispatcher', (WidgetTester tester) async {
final GlobalKey containerKey = GlobalKey(); final GlobalKey containerKey = GlobalKey();
bool invoked = false; bool invoked = false;
const Intent intent = Intent(TestAction.key); const Intent intent = Intent(TestAction.key);
...@@ -200,7 +202,7 @@ void main() { ...@@ -200,7 +202,7 @@ 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 { testWidgets("Actions can invoke actions in ancestor dispatcher if a lower one isn't specified", (WidgetTester tester) async {
final GlobalKey containerKey = GlobalKey(); final GlobalKey containerKey = GlobalKey();
bool invoked = false; bool invoked = false;
const Intent intent = Intent(TestAction.key); const Intent intent = Intent(TestAction.key);
...@@ -240,7 +242,7 @@ void main() { ...@@ -240,7 +242,7 @@ void main() {
expect(invokedAction, equals(testAction)); expect(invokedAction, equals(testAction));
expect(invokedDispatcher.runtimeType, equals(TestDispatcher1)); 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);
...@@ -259,9 +261,77 @@ void main() { ...@@ -259,9 +261,77 @@ void main() {
); );
expect(dispatcher, equals(testDispatcher)); expect(dispatcher, equals(testDispatcher));
}); });
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);
final FocusNode focusNode = FocusNode(debugLabel: 'Test Node');
final Action testAction = TestAction(
onInvoke: (FocusNode node, Intent invocation) {
invoked = true;
},
);
bool hovering = false;
bool focusing = false;
Future<void> buildTest(bool enabled) async {
await tester.pumpWidget(
Center(
child: Actions(
dispatcher: TestDispatcher1(postInvoke: collect),
actions: const <LocalKey, ActionFactory>{},
child: FocusableActionDetector(
enabled: enabled,
focusNode: focusNode,
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.enter): intent,
},
actions: <LocalKey, ActionFactory>{
TestAction.key: () => testAction,
},
onShowHoverHighlight: (bool value) => hovering = value,
onShowFocusHighlight: (bool value) => focusing = value,
child: Container(width: 100, height: 100, key: containerKey),
),
),
),
);
return tester.pump();
}
await buildTest(true);
focusNode.requestFocus();
await tester.pump();
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.moveTo(tester.getCenter(find.byKey(containerKey)));
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
expect(hovering, isTrue);
expect(focusing, isTrue);
expect(invoked, isTrue);
invoked = false;
await buildTest(false);
expect(hovering, isFalse);
expect(focusing, isFalse);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(invoked, isFalse);
await buildTest(true);
expect(focusing, isFalse);
expect(hovering, isTrue);
await buildTest(false);
expect(focusing, isFalse);
expect(hovering, isFalse);
await gesture.moveTo(Offset.zero);
await buildTest(true);
expect(hovering, isFalse);
expect(focusing, isFalse);
});
}); });
group('Diagnostics', () { group('Diagnostics', () {
testWidgets('default $Intent debugFillProperties', (WidgetTester tester) async { testWidgets('default Intent debugFillProperties', (WidgetTester tester) async {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
const Intent(ValueKey<String>('foo')).debugFillProperties(builder); const Intent(ValueKey<String>('foo')).debugFillProperties(builder);
...@@ -275,7 +345,7 @@ void main() { ...@@ -275,7 +345,7 @@ void main() {
expect(description, equals(<String>['key: [<\'foo\'>]'])); expect(description, equals(<String>['key: [<\'foo\'>]']));
}); });
testWidgets('$CallbackAction debugFillProperties', (WidgetTester tester) async { testWidgets('CallbackAction debugFillProperties', (WidgetTester tester) async {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
CallbackAction( CallbackAction(
...@@ -292,7 +362,7 @@ void main() { ...@@ -292,7 +362,7 @@ void main() {
expect(description, equals(<String>['intentKey: [<\'foo\'>]'])); expect(description, equals(<String>['intentKey: [<\'foo\'>]']));
}); });
testWidgets('default $Actions debugFillProperties', (WidgetTester tester) async { testWidgets('default Actions debugFillProperties', (WidgetTester tester) async {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
Actions( Actions(
...@@ -311,7 +381,7 @@ void main() { ...@@ -311,7 +381,7 @@ void main() {
expect(description[0], equalsIgnoringHashCodes('dispatcher: ActionDispatcher#00000')); expect(description[0], equalsIgnoringHashCodes('dispatcher: ActionDispatcher#00000'));
expect(description[1], equals('actions: {}')); expect(description[1], equals('actions: {}'));
}); });
testWidgets('$Actions implements debugFillProperties', (WidgetTester tester) async { testWidgets('Actions implements debugFillProperties', (WidgetTester tester) async {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
Actions( Actions(
......
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