Add focus nodes, hover, and shortcuts to switches, checkboxes, and radio buttons. (#43213)

In order to enable keyboard navigation to more controls, I've added focus nodes to switches, checkboxes, and radio buttons. In addition, this change enables mouse hover over these controls.

- Added tests for focus, hover, and shortcut activation for Checkbox, Radio, and Switch.
This commit is contained in:
Greg Spencer 2019-10-23 11:22:22 -07:00 committed by GitHub
parent 320a933d9e
commit 900067207e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 1245 additions and 104 deletions

View file

@ -4,6 +4,8 @@
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
@ -52,7 +54,7 @@ class Checkbox extends StatefulWidget {
/// * [onChanged], which is called when the value of the checkbox should
/// change. It can be set to null to disable the checkbox.
///
/// The value of [tristate] must not be null.
/// The values of [tristate] and [autofocus] must not be null.
const Checkbox({
Key key,
@required this.value,
@ -60,9 +62,14 @@ class Checkbox extends StatefulWidget {
@required this.onChanged,
this.activeColor,
this.checkColor,
this.focusColor,
this.hoverColor,
this.materialTapTargetSize,
this.focusNode,
this.autofocus = false,
}) : assert(tristate != null),
assert(tristate || value != null),
assert(autofocus != null),
super(key: key);
/// Whether this checkbox is checked.
@ -113,10 +120,10 @@ class Checkbox extends StatefulWidget {
///
/// Checkbox displays a dash when its value is null.
///
/// When a tri-state checkbox is tapped its [onChanged] callback will be
/// applied to true if the current value is null or false, false otherwise.
/// Typically tri-state checkboxes are disabled (the onChanged callback is
/// null) so they don't respond to taps.
/// When a tri-state checkbox ([tristate] is true) is tapped, its [onChanged]
/// callback will be applied to true if the current value is false, to null if
/// value is true, and to false if value is null (i.e. it cycles through false
/// => true => null => false when tapped).
///
/// If tristate is false (the default), [value] must not be null.
final bool tristate;
@ -130,6 +137,18 @@ class Checkbox extends StatefulWidget {
/// * [MaterialTapTargetSize], for a description of how this affects tap targets.
final MaterialTapTargetSize materialTapTargetSize;
/// The color for the checkbox's [Material] when it has the input focus.
final Color focusColor;
/// The color for the checkbox's [Material] when a pointer is hovering over it.
final Color hoverColor;
/// {@macro flutter.widgets.Focus.focusNode}
final FocusNode focusNode;
/// {@macro flutter.widgets.Focus.autofocus}
final bool autofocus;
/// The width of a checkbox widget.
static const double width = 18.0;
@ -138,6 +157,68 @@ class Checkbox extends StatefulWidget {
}
class _CheckboxState extends State<Checkbox> with TickerProviderStateMixin {
bool get enabled => widget.onChanged != null;
Map<LocalKey, ActionFactory> _actionMap;
bool _showHighlight = false;
@override
void initState() {
super.initState();
_actionMap = <LocalKey, ActionFactory>{
SelectAction.key: _createAction,
if (!kIsWeb) ActivateAction.key: _createAction,
};
_updateHighlightMode(WidgetsBinding.instance.focusManager.highlightMode);
WidgetsBinding.instance.focusManager.addHighlightModeListener(_handleFocusHighlightModeChange);
}
void _actionHandler(FocusNode node, Intent intent){
if (widget.onChanged != null) {
switch (widget.value) {
case false:
widget.onChanged(true);
break;
case true:
widget.onChanged(widget.tristate ? null : false);
break;
default: // case null:
widget.onChanged(false);
break;
}
}
final RenderObject renderObject = node.context.findRenderObject();
renderObject.sendSemanticsEvent(const TapSemanticEvent());
}
Action _createAction() {
return CallbackAction(
SelectAction.key,
onInvoke: _actionHandler,
);
}
void _updateHighlightMode(FocusHighlightMode mode) {
switch (WidgetsBinding.instance.focusManager.highlightMode) {
case FocusHighlightMode.touch:
_showHighlight = false;
break;
case FocusHighlightMode.traditional:
_showHighlight = true;
break;
}
}
void _handleFocusHighlightModeChange(FocusHighlightMode mode) {
if (!mounted) {
return;
}
setState(() { _updateHighlightMode(mode); });
}
bool hovering = false;
void _handleMouseEnter(PointerEnterEvent event) => setState(() { hovering = true; });
void _handleMouseExit(PointerExitEvent event) => setState(() { hovering = false; });
@override
Widget build(BuildContext context) {
assert(debugCheckHasMaterial(context));
@ -152,15 +233,36 @@ class _CheckboxState extends State<Checkbox> with TickerProviderStateMixin {
break;
}
final BoxConstraints additionalConstraints = BoxConstraints.tight(size);
return _CheckboxRenderObjectWidget(
value: widget.value,
tristate: widget.tristate,
activeColor: widget.activeColor ?? themeData.toggleableActiveColor,
checkColor: widget.checkColor ?? const Color(0xFFFFFFFF),
inactiveColor: widget.onChanged != null ? themeData.unselectedWidgetColor : themeData.disabledColor,
onChanged: widget.onChanged,
additionalConstraints: additionalConstraints,
vsync: this,
return MouseRegion(
onEnter: enabled ? _handleMouseEnter : null,
onExit: enabled ? _handleMouseExit : null,
child: Actions(
actions: _actionMap,
child: Focus(
focusNode: widget.focusNode,
autofocus: widget.autofocus,
canRequestFocus: enabled,
debugLabel: '${describeIdentity(widget)}(${widget.value})',
child: Builder(
builder: (BuildContext context) {
return _CheckboxRenderObjectWidget(
value: widget.value,
tristate: widget.tristate,
activeColor: widget.activeColor ?? themeData.toggleableActiveColor,
checkColor: widget.checkColor ?? const Color(0xFFFFFFFF),
inactiveColor: enabled ? themeData.unselectedWidgetColor : themeData.disabledColor,
focusColor: widget.focusColor ?? themeData.focusColor,
hoverColor: widget.hoverColor ?? themeData.hoverColor,
onChanged: widget.onChanged,
additionalConstraints: additionalConstraints,
vsync: this,
hasFocus: enabled && _showHighlight && Focus.of(context).hasFocus,
hovering: enabled && _showHighlight && hovering,
);
},
),
),
),
);
}
}
@ -173,9 +275,13 @@ class _CheckboxRenderObjectWidget extends LeafRenderObjectWidget {
@required this.activeColor,
@required this.checkColor,
@required this.inactiveColor,
@required this.focusColor,
@required this.hoverColor,
@required this.onChanged,
@required this.vsync,
@required this.additionalConstraints,
@required this.hasFocus,
@required this.hovering,
}) : assert(tristate != null),
assert(tristate || value != null),
assert(activeColor != null),
@ -185,9 +291,13 @@ class _CheckboxRenderObjectWidget extends LeafRenderObjectWidget {
final bool value;
final bool tristate;
final bool hasFocus;
final bool hovering;
final Color activeColor;
final Color checkColor;
final Color inactiveColor;
final Color focusColor;
final Color hoverColor;
final ValueChanged<bool> onChanged;
final TickerProvider vsync;
final BoxConstraints additionalConstraints;
@ -199,9 +309,13 @@ class _CheckboxRenderObjectWidget extends LeafRenderObjectWidget {
activeColor: activeColor,
checkColor: checkColor,
inactiveColor: inactiveColor,
focusColor: focusColor,
hoverColor: hoverColor,
onChanged: onChanged,
vsync: vsync,
additionalConstraints: additionalConstraints,
hasFocus: hasFocus,
hovering: hovering,
);
@override
@ -212,9 +326,13 @@ class _CheckboxRenderObjectWidget extends LeafRenderObjectWidget {
..activeColor = activeColor
..checkColor = checkColor
..inactiveColor = inactiveColor
..focusColor = focusColor
..hoverColor = hoverColor
..onChanged = onChanged
..additionalConstraints = additionalConstraints
..vsync = vsync;
..vsync = vsync
..hasFocus = hasFocus
..hovering = hovering;
}
}
@ -229,8 +347,12 @@ class _RenderCheckbox extends RenderToggleable {
Color activeColor,
this.checkColor,
Color inactiveColor,
Color focusColor,
Color hoverColor,
BoxConstraints additionalConstraints,
ValueChanged<bool> onChanged,
bool hasFocus,
bool hovering,
@required TickerProvider vsync,
}) : _oldValue = value,
super(
@ -238,9 +360,13 @@ class _RenderCheckbox extends RenderToggleable {
tristate: tristate,
activeColor: activeColor,
inactiveColor: inactiveColor,
focusColor: focusColor,
hoverColor: hoverColor,
onChanged: onChanged,
additionalConstraints: additionalConstraints,
vsync: vsync,
hasFocus: hasFocus,
hovering: hovering,
);
bool _oldValue;

View file

@ -489,20 +489,25 @@ class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKe
bool get highlightsExist => _highlights.values.where((InkHighlight highlight) => highlight != null).isNotEmpty;
void _handleAction(FocusNode node, Intent intent) {
_startSplash(context: node.context);
_handleTap(node.context);
}
Action _createAction() {
return CallbackAction(
ActivateAction.key,
onInvoke: (FocusNode node, Intent intent) {
_startSplash(context: node.context);
_handleTap(node.context);
},
onInvoke: _handleAction,
);
}
@override
void initState() {
super.initState();
_actionMap = <LocalKey, ActionFactory>{ ActivateAction.key: _createAction };
_actionMap = <LocalKey, ActionFactory>{
SelectAction.key: _createAction,
if (!kIsWeb) ActivateAction.key: _createAction,
};
WidgetsBinding.instance.focusManager.addHighlightModeListener(_handleFocusHighlightModeChange);
}

View file

@ -2,6 +2,8 @@
// 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/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
@ -107,8 +109,13 @@ class Radio<T> extends StatefulWidget {
@required this.groupValue,
@required this.onChanged,
this.activeColor,
this.focusColor,
this.hoverColor,
this.materialTapTargetSize,
}) : super(key: key);
this.focusNode,
this.autofocus = false,
}) : assert(autofocus != null),
super(key: key);
/// The value represented by this radio button.
final T value;
@ -161,15 +168,77 @@ class Radio<T> extends StatefulWidget {
/// * [MaterialTapTargetSize], for a description of how this affects tap targets.
final MaterialTapTargetSize materialTapTargetSize;
/// The color for the radio's [Material] when it has the input focus.
final Color focusColor;
/// The color for the radio's [Material] when a pointer is hovering over it.
final Color hoverColor;
/// {@macro flutter.widgets.Focus.focusNode}
final FocusNode focusNode;
/// {@macro flutter.widgets.Focus.autofocus}
final bool autofocus;
@override
_RadioState<T> createState() => _RadioState<T>();
}
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;
bool _showHighlight = false;
@override
void initState() {
super.initState();
_actionMap = <LocalKey, ActionFactory>{
SelectAction.key: _createAction,
if (!kIsWeb) ActivateAction.key: _createAction,
};
_updateHighlightMode(WidgetsBinding.instance.focusManager.highlightMode);
WidgetsBinding.instance.focusManager.addHighlightModeListener(_handleFocusHighlightModeChange);
}
void _actionHandler(FocusNode node, Intent intent){
if (widget.onChanged != null) {
widget.onChanged(widget.value);
}
final RenderObject renderObject = node.context.findRenderObject();
renderObject.sendSemanticsEvent(const TapSemanticEvent());
}
Action _createAction() {
return CallbackAction(
SelectAction.key,
onInvoke: _actionHandler,
);
}
void _updateHighlightMode(FocusHighlightMode mode) {
switch (WidgetsBinding.instance.focusManager.highlightMode) {
case FocusHighlightMode.touch:
_showHighlight = false;
break;
case FocusHighlightMode.traditional:
_showHighlight = true;
break;
}
}
void _handleFocusHighlightModeChange(FocusHighlightMode mode) {
if (!mounted) {
return;
}
setState(() { _updateHighlightMode(mode); });
}
bool hovering = false;
void _handleMouseEnter(PointerEnterEvent event) => setState(() { hovering = true; });
void _handleMouseExit(PointerExitEvent event) => setState(() { hovering = false; });
Color _getInactiveColor(ThemeData themeData) {
return _enabled ? themeData.unselectedWidgetColor : themeData.disabledColor;
return enabled ? themeData.unselectedWidgetColor : themeData.disabledColor;
}
void _handleChanged(bool selected) {
@ -191,13 +260,34 @@ class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin {
break;
}
final BoxConstraints additionalConstraints = BoxConstraints.tight(size);
return _RadioRenderObjectWidget(
selected: widget.value == widget.groupValue,
activeColor: widget.activeColor ?? themeData.toggleableActiveColor,
inactiveColor: _getInactiveColor(themeData),
onChanged: _enabled ? _handleChanged : null,
additionalConstraints: additionalConstraints,
vsync: this,
return MouseRegion(
onEnter: enabled ? _handleMouseEnter : null,
onExit: enabled ? _handleMouseExit : null,
child: Actions(
actions: _actionMap,
child: Focus(
focusNode: widget.focusNode,
autofocus: widget.autofocus,
canRequestFocus: enabled,
debugLabel: '${describeIdentity(widget)}(${widget.value})',
child: Builder(
builder: (BuildContext context) {
return _RadioRenderObjectWidget(
selected: widget.value == widget.groupValue,
activeColor: widget.activeColor ?? themeData.toggleableActiveColor,
inactiveColor: _getInactiveColor(themeData),
focusColor: widget.focusColor ?? themeData.focusColor,
hoverColor: widget.hoverColor ?? themeData.hoverColor,
onChanged: enabled ? _handleChanged : null,
additionalConstraints: additionalConstraints,
vsync: this,
hasFocus: enabled && _showHighlight && Focus.of(context).hasFocus,
hovering: enabled && _showHighlight && hovering,
);
},
),
),
),
);
}
}
@ -208,9 +298,13 @@ class _RadioRenderObjectWidget extends LeafRenderObjectWidget {
@required this.selected,
@required this.activeColor,
@required this.inactiveColor,
@required this.focusColor,
@required this.hoverColor,
@required this.additionalConstraints,
this.onChanged,
@required this.vsync,
@required this.hasFocus,
@required this.hovering,
}) : assert(selected != null),
assert(activeColor != null),
assert(inactiveColor != null),
@ -218,8 +312,12 @@ class _RadioRenderObjectWidget extends LeafRenderObjectWidget {
super(key: key);
final bool selected;
final bool hasFocus;
final bool hovering;
final Color inactiveColor;
final Color activeColor;
final Color focusColor;
final Color hoverColor;
final ValueChanged<bool> onChanged;
final TickerProvider vsync;
final BoxConstraints additionalConstraints;
@ -229,9 +327,13 @@ class _RadioRenderObjectWidget extends LeafRenderObjectWidget {
value: selected,
activeColor: activeColor,
inactiveColor: inactiveColor,
focusColor: focusColor,
hoverColor: hoverColor,
onChanged: onChanged,
vsync: vsync,
additionalConstraints: additionalConstraints,
hasFocus: hasFocus,
hovering: hovering,
);
@override
@ -240,9 +342,13 @@ class _RadioRenderObjectWidget extends LeafRenderObjectWidget {
..value = selected
..activeColor = activeColor
..inactiveColor = inactiveColor
..focusColor = focusColor
..hoverColor = hoverColor
..onChanged = onChanged
..additionalConstraints = additionalConstraints
..vsync = vsync;
..vsync = vsync
..hasFocus = hasFocus
..hovering = hovering;
}
}
@ -251,17 +357,25 @@ class _RenderRadio extends RenderToggleable {
bool value,
Color activeColor,
Color inactiveColor,
Color focusColor,
Color hoverColor,
ValueChanged<bool> onChanged,
BoxConstraints additionalConstraints,
@required TickerProvider vsync,
bool hasFocus,
bool hovering,
}) : super(
value: value,
tristate: false,
activeColor: activeColor,
inactiveColor: inactiveColor,
focusColor: focusColor,
hoverColor: hoverColor,
onChanged: onChanged,
additionalConstraints: additionalConstraints,
vsync: vsync,
hasFocus: hasFocus,
hovering: hovering,
);
@override

View file

@ -74,9 +74,13 @@ class Switch extends StatefulWidget {
this.inactiveThumbImage,
this.materialTapTargetSize,
this.dragStartBehavior = DragStartBehavior.start,
}) : _switchType = _SwitchType.material,
assert(dragStartBehavior != null),
super(key: key);
this.focusColor,
this.hoverColor,
this.focusNode,
this.autofocus = false,
}) : _switchType = _SwitchType.material,
assert(dragStartBehavior != null),
super(key: key);
/// Creates a [CupertinoSwitch] if the target platform is iOS, creates a
/// material design switch otherwise.
@ -98,8 +102,13 @@ class Switch extends StatefulWidget {
this.inactiveThumbImage,
this.materialTapTargetSize,
this.dragStartBehavior = DragStartBehavior.start,
}) : _switchType = _SwitchType.adaptive,
super(key: key);
this.focusColor,
this.hoverColor,
this.focusNode,
this.autofocus = false,
}) : assert(autofocus != null),
_switchType = _SwitchType.adaptive,
super(key: key);
/// Whether this switch is on or off.
///
@ -180,6 +189,18 @@ class Switch extends StatefulWidget {
/// {@macro flutter.cupertino.switch.dragStartBehavior}
final DragStartBehavior dragStartBehavior;
/// The color for the button's [Material] when it has the input focus.
final Color focusColor;
/// The color for the button's [Material] when a pointer is hovering over it.
final Color hoverColor;
/// {@macro flutter.widgets.Focus.focusNode}
final FocusNode focusNode;
/// {@macro flutter.widgets.Focus.autofocus}
final bool autofocus;
@override
_SwitchState createState() => _SwitchState();
@ -192,6 +213,53 @@ class Switch extends StatefulWidget {
}
class _SwitchState extends State<Switch> with TickerProviderStateMixin {
Map<LocalKey, ActionFactory> _actionMap;
bool _showHighlight = false;
@override
void initState() {
super.initState();
_actionMap = <LocalKey, ActionFactory>{
SelectAction.key: _createAction,
if (!kIsWeb) ActivateAction.key: _createAction,
};
_updateHighlightMode(WidgetsBinding.instance.focusManager.highlightMode);
WidgetsBinding.instance.focusManager.addHighlightModeListener(_handleFocusHighlightModeChange);
}
void _actionHandler(FocusNode node, Intent intent){
if (widget.onChanged != null) {
widget.onChanged(!widget.value);
}
final RenderObject renderObject = node.context.findRenderObject();
renderObject.sendSemanticsEvent(const TapSemanticEvent());
}
Action _createAction() {
return CallbackAction(
SelectAction.key,
onInvoke: _actionHandler,
);
}
void _updateHighlightMode(FocusHighlightMode mode) {
switch (WidgetsBinding.instance.focusManager.highlightMode) {
case FocusHighlightMode.touch:
_showHighlight = false;
break;
case FocusHighlightMode.traditional:
_showHighlight = true;
break;
}
}
void _handleFocusHighlightModeChange(FocusHighlightMode mode) {
if (!mounted) {
return;
}
setState(() { _updateHighlightMode(mode); });
}
Size getSwitchSize(ThemeData theme) {
switch (widget.materialTapTargetSize ?? theme.materialTapTargetSize) {
case MaterialTapTargetSize.padded:
@ -205,6 +273,12 @@ class _SwitchState extends State<Switch> with TickerProviderStateMixin {
return 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) {
assert(debugCheckHasMaterial(context));
final ThemeData theme = Theme.of(context);
@ -212,10 +286,12 @@ class _SwitchState extends State<Switch> with TickerProviderStateMixin {
final Color activeThumbColor = widget.activeColor ?? theme.toggleableActiveColor;
final Color activeTrackColor = widget.activeTrackColor ?? activeThumbColor.withAlpha(0x80);
final Color hoverColor = widget.hoverColor ?? theme.hoverColor;
final Color focusColor = widget.focusColor ?? theme.focusColor;
Color inactiveThumbColor;
Color inactiveTrackColor;
if (widget.onChanged != null) {
if (enabled) {
const Color black32 = Color(0x52000000); // Black with 32% opacity
inactiveThumbColor = widget.inactiveThumbColor ?? (isDark ? Colors.grey.shade400 : Colors.grey.shade50);
inactiveTrackColor = widget.inactiveTrackColor ?? (isDark ? Colors.white30 : black32);
@ -224,33 +300,59 @@ class _SwitchState extends State<Switch> with TickerProviderStateMixin {
inactiveTrackColor = widget.inactiveTrackColor ?? (isDark ? Colors.white10 : Colors.black12);
}
return _SwitchRenderObjectWidget(
dragStartBehavior: widget.dragStartBehavior,
value: widget.value,
activeColor: activeThumbColor,
inactiveColor: inactiveThumbColor,
activeThumbImage: widget.activeThumbImage,
inactiveThumbImage: widget.inactiveThumbImage,
activeTrackColor: activeTrackColor,
inactiveTrackColor: inactiveTrackColor,
configuration: createLocalImageConfiguration(context),
onChanged: widget.onChanged,
additionalConstraints: BoxConstraints.tight(getSwitchSize(theme)),
vsync: this,
return MouseRegion(
onEnter: enabled ? _handleMouseEnter : null,
onExit: enabled ? _handleMouseExit : null,
child: Actions(
actions: _actionMap,
child: Focus(
focusNode: widget.focusNode,
autofocus: widget.autofocus,
canRequestFocus: enabled,
debugLabel: '${describeIdentity(widget)}({$widget.value})',
child: Builder(
builder: (BuildContext context) {
final bool hasFocus = Focus.of(context).hasFocus;
return _SwitchRenderObjectWidget(
dragStartBehavior: widget.dragStartBehavior,
value: widget.value,
activeColor: activeThumbColor,
inactiveColor: inactiveThumbColor,
hoverColor: hoverColor,
focusColor: focusColor,
activeThumbImage: widget.activeThumbImage,
inactiveThumbImage: widget.inactiveThumbImage,
activeTrackColor: activeTrackColor,
inactiveTrackColor: inactiveTrackColor,
configuration: createLocalImageConfiguration(context),
onChanged: widget.onChanged,
additionalConstraints: BoxConstraints.tight(getSwitchSize(theme)),
hasFocus: enabled && _showHighlight && hasFocus,
hovering: enabled && _showHighlight && hovering,
vsync: this,
);
},
),
),
),
);
}
Widget buildCupertinoSwitch(BuildContext context) {
final Size size = getSwitchSize(Theme.of(context));
return Container(
width: size.width, // Same size as the Material switch.
height: size.height,
alignment: Alignment.center,
child: CupertinoSwitch(
dragStartBehavior: widget.dragStartBehavior,
value: widget.value,
onChanged: widget.onChanged,
activeColor: widget.activeColor,
return Focus(
focusNode: widget.focusNode,
autofocus: widget.autofocus,
child: Container(
width: size.width, // Same size as the Material switch.
height: size.height,
alignment: Alignment.center,
child: CupertinoSwitch(
dragStartBehavior: widget.dragStartBehavior,
value: widget.value,
onChanged: widget.onChanged,
activeColor: widget.activeColor,
),
),
);
}
@ -284,6 +386,8 @@ class _SwitchRenderObjectWidget extends LeafRenderObjectWidget {
this.value,
this.activeColor,
this.inactiveColor,
this.hoverColor,
this.focusColor,
this.activeThumbImage,
this.inactiveThumbImage,
this.activeTrackColor,
@ -293,11 +397,15 @@ class _SwitchRenderObjectWidget extends LeafRenderObjectWidget {
this.vsync,
this.additionalConstraints,
this.dragStartBehavior,
this.hasFocus,
this.hovering,
}) : super(key: key);
final bool value;
final Color activeColor;
final Color inactiveColor;
final Color hoverColor;
final Color focusColor;
final ImageProvider activeThumbImage;
final ImageProvider inactiveThumbImage;
final Color activeTrackColor;
@ -307,6 +415,8 @@ class _SwitchRenderObjectWidget extends LeafRenderObjectWidget {
final TickerProvider vsync;
final BoxConstraints additionalConstraints;
final DragStartBehavior dragStartBehavior;
final bool hasFocus;
final bool hovering;
@override
_RenderSwitch createRenderObject(BuildContext context) {
@ -315,6 +425,8 @@ class _SwitchRenderObjectWidget extends LeafRenderObjectWidget {
value: value,
activeColor: activeColor,
inactiveColor: inactiveColor,
hoverColor: hoverColor,
focusColor: focusColor,
activeThumbImage: activeThumbImage,
inactiveThumbImage: inactiveThumbImage,
activeTrackColor: activeTrackColor,
@ -323,6 +435,8 @@ class _SwitchRenderObjectWidget extends LeafRenderObjectWidget {
onChanged: onChanged,
textDirection: Directionality.of(context),
additionalConstraints: additionalConstraints,
hasFocus: hasFocus,
hovering: hovering,
vsync: vsync,
);
}
@ -333,6 +447,8 @@ class _SwitchRenderObjectWidget extends LeafRenderObjectWidget {
..value = value
..activeColor = activeColor
..inactiveColor = inactiveColor
..hoverColor = hoverColor
..focusColor = focusColor
..activeThumbImage = activeThumbImage
..inactiveThumbImage = inactiveThumbImage
..activeTrackColor = activeTrackColor
@ -342,6 +458,8 @@ class _SwitchRenderObjectWidget extends LeafRenderObjectWidget {
..textDirection = Directionality.of(context)
..additionalConstraints = additionalConstraints
..dragStartBehavior = dragStartBehavior
..hasFocus = hasFocus
..hovering = hovering
..vsync = vsync;
}
}
@ -351,6 +469,8 @@ class _RenderSwitch extends RenderToggleable {
bool value,
Color activeColor,
Color inactiveColor,
Color hoverColor,
Color focusColor,
ImageProvider activeThumbImage,
ImageProvider inactiveThumbImage,
Color activeTrackColor,
@ -359,8 +479,10 @@ class _RenderSwitch extends RenderToggleable {
BoxConstraints additionalConstraints,
@required TextDirection textDirection,
ValueChanged<bool> onChanged,
@required TickerProvider vsync,
DragStartBehavior dragStartBehavior,
bool hasFocus,
bool hovering,
@required TickerProvider vsync,
}) : assert(textDirection != null),
_activeThumbImage = activeThumbImage,
_inactiveThumbImage = inactiveThumbImage,
@ -373,8 +495,12 @@ class _RenderSwitch extends RenderToggleable {
tristate: false,
activeColor: activeColor,
inactiveColor: inactiveColor,
hoverColor: hoverColor,
focusColor: focusColor,
onChanged: onChanged,
additionalConstraints: additionalConstraints,
hasFocus: hasFocus,
hovering: hovering,
vsync: vsync,
) {
_drag = HorizontalDragGestureRecognizer()

View file

@ -10,9 +10,15 @@ import 'package:flutter/scheduler.dart';
import 'constants.dart';
// Duration of the animation that moves the toggle from one state to another.
const Duration _kToggleDuration = Duration(milliseconds: 200);
// Radius of the radial reaction over time.
final Animatable<double> _kRadialReactionRadiusTween = Tween<double>(begin: 0.0, end: kRadialReactionRadius);
// Duration of the fade animation for the reaction when focus and hover occur.
const Duration _kReactionFadeDuration = Duration(milliseconds: 50);
/// A base class for material style toggleable controls with toggle animations.
///
/// This class handles storing the current value, dispatching ValueChanged on a
@ -28,9 +34,13 @@ abstract class RenderToggleable extends RenderConstrainedBox {
bool tristate = false,
@required Color activeColor,
@required Color inactiveColor,
Color hoverColor,
Color focusColor,
ValueChanged<bool> onChanged,
BoxConstraints additionalConstraints,
@required TickerProvider vsync,
bool hasFocus = false,
bool hovering = false,
}) : assert(tristate != null),
assert(tristate || value != null),
assert(activeColor != null),
@ -40,7 +50,11 @@ abstract class RenderToggleable extends RenderConstrainedBox {
_tristate = tristate,
_activeColor = activeColor,
_inactiveColor = inactiveColor,
_hoverColor = hoverColor ?? activeColor.withAlpha(kRadialReactionAlpha),
_focusColor = focusColor ?? activeColor.withAlpha(kRadialReactionAlpha),
_onChanged = onChanged,
_hasFocus = hasFocus,
_hovering = hovering,
_vsync = vsync,
super(additionalConstraints: additionalConstraints) {
_tap = TapGestureRecognizer()
@ -66,6 +80,24 @@ abstract class RenderToggleable extends RenderConstrainedBox {
parent: _reactionController,
curve: Curves.fastOutSlowIn,
)..addListener(markNeedsPaint);
_reactionHoverFadeController = AnimationController(
duration: _kReactionFadeDuration,
value: hovering || hasFocus ? 1.0 : 0.0,
vsync: vsync,
);
_reactionHoverFade = CurvedAnimation(
parent: _reactionHoverFadeController,
curve: Curves.fastOutSlowIn,
)..addListener(markNeedsPaint);
_reactionFocusFadeController = AnimationController(
duration: _kReactionFadeDuration,
value: hovering || hasFocus ? 1.0 : 0.0,
vsync: vsync,
);
_reactionFocusFade = CurvedAnimation(
parent: _reactionFocusFadeController,
curve: Curves.fastOutSlowIn,
)..addListener(markNeedsPaint);
}
/// Used by subclasses to manipulate the visual value of the control.
@ -102,6 +134,66 @@ abstract class RenderToggleable extends RenderConstrainedBox {
AnimationController _reactionController;
Animation<double> _reaction;
/// Used by subclasses to control the radial reaction's opacity animation for
/// [hasFocus] changes.
///
/// Some controls have a radial ink reaction to focus. This animation
/// controller can be used to start or stop these ink reaction fade-ins and
/// fade-outs.
///
/// Subclasses should call [paintRadialReaction] to actually paint the radial
/// reaction.
@protected
AnimationController get reactionFocusFadeController => _reactionFocusFadeController;
AnimationController _reactionFocusFadeController;
Animation<double> _reactionFocusFade;
/// Used by subclasses to control the radial reaction's opacity animation for
/// [hovering] changes.
///
/// Some controls have a radial ink reaction to pointer hover. This animation
/// controller can be used to start or stop these ink reaction fade-ins and
/// fade-outs.
///
/// Subclasses should call [paintRadialReaction] to actually paint the radial
/// reaction.
@protected
AnimationController get reactionHoverFadeController => _reactionHoverFadeController;
AnimationController _reactionHoverFadeController;
Animation<double> _reactionHoverFade;
/// True if this toggleable has the input focus.
bool get hasFocus => _hasFocus;
bool _hasFocus;
set hasFocus(bool value) {
assert(value != null);
if (value == _hasFocus)
return;
_hasFocus = value;
if (_hasFocus) {
_reactionFocusFadeController.forward();
} else {
_reactionFocusFadeController.reverse();
}
markNeedsPaint();
}
/// True if this toggleable is being hovered over by a pointer.
bool get hovering => _hovering;
bool _hovering;
set hovering(bool value) {
assert(value != null);
if (value == _hovering)
return;
_hovering = value;
if (_hovering) {
_reactionHoverFadeController.forward();
} else {
_reactionHoverFadeController.reverse();
}
markNeedsPaint();
}
/// The [TickerProvider] for the [AnimationController]s that run the animations.
TickerProvider get vsync => _vsync;
TickerProvider _vsync;
@ -192,6 +284,54 @@ abstract class RenderToggleable extends RenderConstrainedBox {
markNeedsPaint();
}
/// The color that should be used for the reaction when [hovering] is true.
///
/// Used when the toggleable needs to change the reaction color/transparency,
/// when it is being hovered over.
///
/// Defaults to the [activeColor] at alpha [kRadialReactionAlpha].
Color get hoverColor => _hoverColor;
Color _hoverColor;
set hoverColor(Color value) {
assert(value != null);
if (value == _hoverColor)
return;
_hoverColor = value;
markNeedsPaint();
}
/// The color that should be used for the reaction when [hasFocus] is true.
///
/// Used when the toggleable needs to change the reaction color/transparency,
/// when it has focus.
///
/// Defaults to the [activeColor] at alpha [kRadialReactionAlpha].
Color get focusColor => _focusColor;
Color _focusColor;
set focusColor(Color value) {
assert(value != null);
if (value == _focusColor)
return;
_focusColor = value;
markNeedsPaint();
}
/// The color that should be used for the reaction when drawn.
///
/// Used when the toggleable needs to change the reaction color/transparency
/// that is displayed when the toggleable is toggled by a tap.
///
/// Defaults to the [activeColor] at alpha [kRadialReactionAlpha].
Color get reactionColor => _reactionColor;
Color _reactionColor;
set reactionColor(Color value) {
assert(value != null);
if (value == _reactionColor)
return;
_reactionColor = value;
markNeedsPaint();
}
/// Called when the control changes value.
///
/// If the control is tapped, [onChanged] is called immediately with the new
@ -323,12 +463,18 @@ abstract class RenderToggleable extends RenderConstrainedBox {
/// point at which the user interacted with the control, which is handled
/// automatically).
void paintRadialReaction(Canvas canvas, Offset offset, Offset origin) {
if (!_reaction.isDismissed) {
// TODO(abarth): We should have a different reaction color when position is zero.
final Paint reactionPaint = Paint()..color = activeColor.withAlpha(kRadialReactionAlpha);
if (!_reaction.isDismissed || !_reactionFocusFade.isDismissed || !_reactionHoverFade.isDismissed) {
final Paint reactionPaint = Paint()
..color = Color.lerp(
Color.lerp(activeColor.withAlpha(kRadialReactionAlpha), hoverColor, _reactionHoverFade.value),
focusColor,
_reactionFocusFade.value,
);
final Offset center = Offset.lerp(_downPosition ?? origin, origin, _reaction.value);
final double radius = _kRadialReactionRadiusTween.evaluate(_reaction);
canvas.drawCircle(center + offset, radius, reactionPaint);
final double reactionRadius = hasFocus || hovering
? kRadialReactionRadius
: _kRadialReactionRadiusTween.evaluate(_reaction);
canvas.drawCircle(center + offset, reactionRadius, reactionPaint);
}
}

View file

@ -376,8 +376,8 @@ class DoNothingAction extends Action {
/// 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. It is bound to [LogicalKeyboardKey.enter] in the default
/// keyboard map in [WidgetsApp].
/// 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);
@ -385,3 +385,16 @@ abstract class ActivateAction extends Action {
/// 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, like a checkbox or a radio button. By default, it is bound
/// to [LogicalKeyboardKey.space] in the default keyboard map in [WidgetsApp].
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);
}

View file

@ -1046,6 +1046,7 @@ class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver {
LogicalKeySet(LogicalKeyboardKey.arrowDown): const DirectionalFocusIntent(TraversalDirection.down),
LogicalKeySet(LogicalKeyboardKey.arrowUp): const DirectionalFocusIntent(TraversalDirection.up),
LogicalKeySet(LogicalKeyboardKey.enter): const Intent(ActivateAction.key),
LogicalKeySet(LogicalKeyboardKey.space): const Intent(SelectAction.key),
};
final Map<LocalKey, ActionFactory> _actionMap = <LocalKey, ActionFactory>{

View file

@ -897,8 +897,8 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<BuildContext>('context', context, defaultValue: null));
properties.add(FlagProperty('canRequestFocus', value: canRequestFocus, ifFalse: 'NOT FOCUSABLE', defaultValue: true));
properties.add(FlagProperty('hasFocus', value: hasFocus, ifTrue: 'FOCUSED', defaultValue: false));
properties.add(StringProperty('debugLabel', debugLabel, defaultValue: null));
properties.add(FlagProperty('hasFocus', value: hasFocus && !hasPrimaryFocus, ifTrue: 'IN FOCUS PATH', defaultValue: false));
properties.add(FlagProperty('hasPrimaryFocus', value: hasPrimaryFocus, ifTrue: 'PRIMARY FOCUS', defaultValue: false));
}
@override

View file

@ -67,11 +67,12 @@ void main() {
),
));
expect(tester.getSemantics(find.byType(Checkbox)), matchesSemantics(
expect(tester.getSemantics(find.byType(Focus)), matchesSemantics(
hasCheckedState: true,
hasEnabledState: true,
isEnabled: true,
hasTapAction: true,
isFocusable: true,
));
await tester.pumpWidget(Material(
@ -81,12 +82,13 @@ void main() {
),
));
expect(tester.getSemantics(find.byType(Checkbox)), matchesSemantics(
expect(tester.getSemantics(find.byType(Focus)), matchesSemantics(
hasCheckedState: true,
hasEnabledState: true,
isChecked: true,
isEnabled: true,
hasTapAction: true,
isFocusable: true,
));
await tester.pumpWidget(const Material(
@ -96,9 +98,10 @@ void main() {
),
));
expect(tester.getSemantics(find.byType(Checkbox)), matchesSemantics(
expect(tester.getSemantics(find.byType(Focus)), matchesSemantics(
hasCheckedState: true,
hasEnabledState: true,
isFocusable: true,
));
await tester.pumpWidget(const Material(
@ -108,7 +111,7 @@ void main() {
),
));
expect(tester.getSemantics(find.byType(Checkbox)), matchesSemantics(
expect(tester.getSemantics(find.byType(Focus)), matchesSemantics(
hasCheckedState: true,
hasEnabledState: true,
isChecked: true,
@ -130,13 +133,14 @@ void main() {
),
));
expect(tester.getSemantics(find.byType(Checkbox)), matchesSemantics(
expect(tester.getSemantics(find.byType(Focus)), matchesSemantics(
label: 'foo',
textDirection: TextDirection.ltr,
hasCheckedState: true,
hasEnabledState: true,
isEnabled: true,
hasTapAction: true,
isFocusable: true,
));
handle.dispose();
});
@ -202,6 +206,7 @@ void main() {
SemanticsFlag.hasCheckedState,
SemanticsFlag.hasEnabledState,
SemanticsFlag.isEnabled,
SemanticsFlag.isFocusable,
],
actions: <SemanticsAction>[SemanticsAction.tap],
), hasLength(1));
@ -222,6 +227,7 @@ void main() {
SemanticsFlag.hasEnabledState,
SemanticsFlag.isEnabled,
SemanticsFlag.isChecked,
SemanticsFlag.isFocusable,
],
actions: <SemanticsAction>[SemanticsAction.tap],
), hasLength(1));
@ -241,6 +247,7 @@ void main() {
SemanticsFlag.hasCheckedState,
SemanticsFlag.hasEnabledState,
SemanticsFlag.isEnabled,
SemanticsFlag.isFocusable,
],
actions: <SemanticsAction>[SemanticsAction.tap],
), hasLength(1));
@ -274,7 +281,7 @@ void main() {
);
await tester.tap(find.byType(Checkbox));
final RenderObject object = tester.firstRenderObject(find.byType(Checkbox));
final RenderObject object = tester.firstRenderObject(find.byType(Focus));
expect(checkboxValue, true);
expect(semanticEvent, <String, dynamic>{
@ -304,7 +311,9 @@ void main() {
}
RenderToggleable getCheckboxRenderer() {
return tester.renderObject<RenderToggleable>(find.byType(Checkbox));
return tester.renderObject<RenderToggleable>(find.byWidgetPredicate((Widget widget) {
return widget.runtimeType.toString() == '_CheckboxRenderObjectWidget';
}));
}
await tester.pumpWidget(buildFrame(false));
@ -356,7 +365,9 @@ void main() {
}
RenderToggleable getCheckboxRenderer() {
return tester.renderObject<RenderToggleable>(find.byType(Checkbox));
return tester.renderObject<RenderToggleable>(find.byWidgetPredicate((Widget widget) {
return widget.runtimeType.toString() == '_CheckboxRenderObjectWidget';
}));
}
await tester.pumpWidget(buildFrame(checkColor: const Color(0xFFFFFFFF)));
@ -376,4 +387,181 @@ void main() {
expect(getCheckboxRenderer(), paints..rrect(color: const Color(0xFF000000))); // paints's color is 0xFF000000 (params)
});
testWidgets('Checkbox is focusable and has correct focus color', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode(debugLabel: 'Checkbox');
tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
bool value = true;
Widget buildApp({bool enabled = true}) {
return MaterialApp(
home: Material(
child: Center(
child: StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
return Checkbox(
value: value,
onChanged: enabled ? (bool newValue) {
setState(() {
value = newValue;
});
} : null,
focusColor: Colors.orange[500],
autofocus: true,
focusNode: focusNode,
);
}),
),
),
);
}
await tester.pumpWidget(buildApp());
await tester.pumpAndSettle();
expect(focusNode.hasPrimaryFocus, isTrue);
expect(
Material.of(tester.element(find.byType(Checkbox))),
paints
..circle(color: Colors.orange[500])
..rrect(
color: const Color(0xff1e88e5),
rrect: RRect.fromLTRBR(
391.0, 291.0, 409.0, 309.0, const Radius.circular(1.0)))
..path(color: Colors.white),
);
// Check the false value.
value = false;
await tester.pumpWidget(buildApp());
await tester.pumpAndSettle();
expect(focusNode.hasPrimaryFocus, isTrue);
expect(
Material.of(tester.element(find.byType(Checkbox))),
paints
..circle(color: Colors.orange[500])
..drrect(
color: const Color(0x8a000000),
outer: RRect.fromLTRBR(
391.0, 291.0, 409.0, 309.0, const Radius.circular(1.0)),
inner: RRect.fromLTRBR(393.0,
293.0, 407.0, 307.0, const Radius.circular(-1.0))),
);
// Check what happens when disabled.
value = false;
await tester.pumpWidget(buildApp(enabled: false));
await tester.pumpAndSettle();
expect(focusNode.hasPrimaryFocus, isFalse);
expect(
Material.of(tester.element(find.byType(Checkbox))),
paints
..drrect(
color: const Color(0x61000000),
outer: RRect.fromLTRBR(
391.0, 291.0, 409.0, 309.0, const Radius.circular(1.0)),
inner: RRect.fromLTRBR(393.0,
293.0, 407.0, 307.0, const Radius.circular(-1.0))),
);
});
testWidgets('Checkbox can be hovered and has correct hover color', (WidgetTester tester) async {
tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
bool value = true;
Widget buildApp({bool enabled = true}) {
return MaterialApp(
home: Material(
child: Center(
child: StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
return Checkbox(
value: value,
onChanged: enabled ? (bool newValue) {
setState(() {
value = newValue;
});
} : null,
hoverColor: Colors.orange[500],
);
}),
),
),
);
}
await tester.pumpWidget(buildApp());
await tester.pumpAndSettle();
expect(
Material.of(tester.element(find.byType(Checkbox))),
paints
..rrect(
color: const Color(0xff1e88e5),
rrect: RRect.fromLTRBR(
391.0, 291.0, 409.0, 309.0, const Radius.circular(1.0)))
..path(color: const Color(0xffffffff), style: PaintingStyle.stroke, strokeWidth: 2.0),
);
// Start hovering
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.moveTo(tester.getCenter(find.byType(Checkbox)));
await tester.pumpWidget(buildApp());
await tester.pumpAndSettle();
expect(
Material.of(tester.element(find.byType(Checkbox))),
paints
..rrect(
color: const Color(0xff1e88e5),
rrect: RRect.fromLTRBR(
391.0, 291.0, 409.0, 309.0, const Radius.circular(1.0)))
..path(color: const Color(0xffffffff), style: PaintingStyle.stroke, strokeWidth: 2.0),
);
// Check what happens when disabled.
await tester.pumpWidget(buildApp(enabled: false));
await tester.pumpAndSettle();
expect(
Material.of(tester.element(find.byType(Checkbox))),
paints
..rrect(
color: const Color(0x61000000),
rrect: RRect.fromLTRBR(
391.0, 291.0, 409.0, 309.0, const Radius.circular(1.0)))
..path(color: const Color(0xffffffff), style: PaintingStyle.stroke, strokeWidth: 2.0),
);
});
testWidgets('Checkbox can be toggled by keyboard shortcuts', (WidgetTester tester) async {
tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
bool value = true;
Widget buildApp({bool enabled = true}) {
return MaterialApp(
home: Material(
child: Center(
child: StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
return Checkbox(
value: value,
onChanged: enabled ? (bool newValue) {
setState(() {
value = newValue;
});
} : null,
focusColor: Colors.orange[500],
autofocus: true,
);
}),
),
),
);
}
await tester.pumpWidget(buildApp());
await tester.pumpAndSettle();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(value, isFalse);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(value, isTrue);
await tester.sendKeyEvent(LogicalKeyboardKey.space);
await tester.pumpAndSettle();
expect(value, isFalse);
await tester.sendKeyEvent(LogicalKeyboardKey.space);
await tester.pumpAndSettle();
expect(value, isTrue);
});
}

View file

@ -9,6 +9,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
import '../rendering/mock_canvas.dart';
import '../widgets/semantics_tester.dart';
void main() {
@ -131,6 +132,7 @@ void main() {
SemanticsFlag.hasCheckedState,
SemanticsFlag.hasEnabledState,
SemanticsFlag.isEnabled,
SemanticsFlag.isFocusable,
],
actions: <SemanticsAction>[
SemanticsAction.tap,
@ -157,6 +159,7 @@ void main() {
SemanticsFlag.isChecked,
SemanticsFlag.hasEnabledState,
SemanticsFlag.isEnabled,
SemanticsFlag.isFocusable,
],
actions: <SemanticsAction>[
SemanticsAction.tap,
@ -181,6 +184,7 @@ void main() {
SemanticsFlag.isInMutuallyExclusiveGroup,
SemanticsFlag.hasCheckedState,
SemanticsFlag.hasEnabledState,
SemanticsFlag.isFocusable,
],
),
],
@ -232,7 +236,7 @@ void main() {
));
await tester.tap(find.byKey(key));
final RenderObject object = tester.firstRenderObject(find.byKey(key));
final RenderObject object = tester.firstRenderObject(find.byType(Focus));
expect(radioValue, 1);
expect(semanticEvent, <String, dynamic>{
@ -282,5 +286,227 @@ void main() {
),
);
}, skip: isBrowser);
testWidgets('Radio is focusable and has correct focus color', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode(debugLabel: 'Radio');
tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
int groupValue = 0;
const Key radioKey = Key('radio');
Widget buildApp({bool enabled = true}) {
return MaterialApp(
home: Material(
child: Center(
child: StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
return Container(
width: 100,
height: 100,
color: Colors.white,
child: Radio<int>(
key: radioKey,
value: 0,
onChanged: enabled ? (int newValue) {
setState(() {
groupValue = newValue;
});
} : null,
focusColor: Colors.orange[500],
autofocus: true,
focusNode: focusNode,
groupValue: groupValue,
),
);
}),
),
),
);
}
await tester.pumpWidget(buildApp());
await tester.pumpAndSettle();
expect(focusNode.hasPrimaryFocus, isTrue);
expect(
Material.of(tester.element(find.byKey(radioKey))),
paints
..rect(
color: const Color(0xffffffff),
rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0))
..circle(color: Colors.orange[500])
..circle(color: const Color(0xff1e88e5))
..circle(color: const Color(0xff1e88e5)),
);
// Check when the radio isn't selected.
groupValue = 1;
await tester.pumpWidget(buildApp());
await tester.pumpAndSettle();
expect(focusNode.hasPrimaryFocus, isTrue);
expect(
Material.of(tester.element(find.byKey(radioKey))),
paints
..rect(
color: const Color(0xffffffff),
rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0))
..circle(color: Colors.orange[500])
..circle(color: const Color(0x8a000000), style: PaintingStyle.stroke, strokeWidth: 2.0)
);
// Check when the radio is selected, but disabled.
groupValue = 0;
await tester.pumpWidget(buildApp(enabled: false));
await tester.pumpAndSettle();
expect(focusNode.hasPrimaryFocus, isFalse);
expect(
Material.of(tester.element(find.byKey(radioKey))),
paints
..rect(
color: const Color(0xffffffff),
rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0))
..circle(color: const Color(0x61000000))
..circle(color: const Color(0x61000000)),
);
});
testWidgets('Radio can be hovered and has correct focus color', (WidgetTester tester) async {
tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
int groupValue = 0;
const Key radioKey = Key('radio');
Widget buildApp({bool enabled = true}) {
return MaterialApp(
home: Material(
child: Center(
child: StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
return Container(
width: 100,
height: 100,
color: Colors.white,
child: Radio<int>(
key: radioKey,
value: 0,
onChanged: enabled ? (int newValue) {
setState(() {
groupValue = newValue;
});
} : null,
hoverColor: Colors.orange[500],
groupValue: groupValue,
),
);
}),
),
),
);
}
await tester.pumpWidget(buildApp());
await tester.pumpAndSettle();
expect(
Material.of(tester.element(find.byKey(radioKey))),
paints
..rect(
color: const Color(0xffffffff),
rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0))
..circle(color: const Color(0xff1e88e5))
..circle(color: const Color(0xff1e88e5)),
);
// Start hovering
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.moveTo(tester.getCenter(find.byKey(radioKey)));
// Check when the radio isn't selected.
groupValue = 1;
await tester.pumpWidget(buildApp());
await tester.pumpAndSettle();
expect(
Material.of(tester.element(find.byKey(radioKey))),
paints
..rect(
color: const Color(0xffffffff),
rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0))
..circle(color: Colors.orange[500])
..circle(color: const Color(0x8a000000), style: PaintingStyle.stroke, strokeWidth: 2.0)
);
// Check when the radio is selected, but disabled.
groupValue = 0;
await tester.pumpWidget(buildApp(enabled: false));
await tester.pumpAndSettle();
expect(
Material.of(tester.element(find.byKey(radioKey))),
paints
..rect(
color: const Color(0xffffffff),
rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0))
..circle(color: const Color(0x61000000))
..circle(color: const Color(0x61000000)),
);
});
testWidgets('Radio can be toggled by keyboard shortcuts', (WidgetTester tester) async {
tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
int groupValue = 1;
const Key radioKey0 = Key('radio0');
const Key radioKey1 = Key('radio1');
final FocusNode focusNode1 = FocusNode(debugLabel: 'radio1');
Widget buildApp({bool enabled = true}) {
return MaterialApp(
home: Material(
child: Center(
child: StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
return Container(
width: 100,
height: 100,
color: Colors.white,
child: Row(
children: <Widget>[
Radio<int>(
key: radioKey0,
value: 0,
onChanged: enabled ? (int newValue) {
setState(() {
groupValue = newValue;
});
} : null,
hoverColor: Colors.orange[500],
groupValue: groupValue,
autofocus: true,
),
Radio<int>(
key: radioKey1,
value: 1,
onChanged: enabled ? (int newValue) {
setState(() {
groupValue = newValue;
});
} : null,
hoverColor: Colors.orange[500],
groupValue: groupValue,
focusNode: focusNode1,
),
],
),
);
}),
),
),
);
}
await tester.pumpWidget(buildApp());
await tester.pumpAndSettle();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(groupValue, equals(0));
focusNode1.requestFocus();
await tester.pumpAndSettle();
await tester.sendKeyEvent(LogicalKeyboardKey.space);
await tester.pumpAndSettle();
expect(groupValue, equals(1));
});
}

View file

@ -2,6 +2,7 @@
// 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/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
@ -47,6 +48,7 @@ void main() {
Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.enter): const Intent(ActivateAction.key),
LogicalKeySet(LogicalKeyboardKey.space): const Intent(SelectAction.key),
},
child: Directionality(
textDirection: TextDirection.ltr,
@ -74,6 +76,12 @@ void main() {
await tester.pumpAndSettle();
expect(pressed, isTrue);
pressed = false;
await tester.sendKeyEvent(LogicalKeyboardKey.space);
await tester.pumpAndSettle();
expect(pressed, kIsWeb ? isFalse : isTrue);
});
testWidgets('materialTapTargetSize.padded expands hit test area', (WidgetTester tester) async {

View file

@ -518,7 +518,7 @@ void main() {
),
);
await tester.tap(find.byType(Switch));
final RenderObject object = tester.firstRenderObject(find.byType(Switch));
final RenderObject object = tester.firstRenderObject(find.byType(Focus));
expect(value, true);
expect(semanticEvent, <String, dynamic>{
@ -623,4 +623,198 @@ void main() {
});
testWidgets('Switch is focusable and has correct focus color', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode(debugLabel: 'Switch');
tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
bool value = true;
Widget buildApp({bool enabled = true}) {
return MaterialApp(
home: Material(
child: Center(
child: StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
return Switch(
value: value,
onChanged: enabled ? (bool newValue) {
setState(() {
value = newValue;
});
} : null,
focusColor: Colors.orange[500],
autofocus: true,
focusNode: focusNode,
);
}),
),
),
);
}
await tester.pumpWidget(buildApp());
await tester.pumpAndSettle();
expect(focusNode.hasPrimaryFocus, isTrue);
expect(
Material.of(tester.element(find.byType(Switch))),
paints
..rrect(
color: const Color(0x801e88e5),
rrect: RRect.fromLTRBR(
383.5, 293.0, 416.5, 307.0, const Radius.circular(7.0)))
..circle(color: Colors.orange[500])
..circle(color: const Color(0x33000000))
..circle(color: const Color(0x24000000))
..circle(color: const Color(0x1f000000))
..circle(color: const Color(0xff1e88e5)),
);
// Check the false value.
value = false;
await tester.pumpWidget(buildApp());
await tester.pumpAndSettle();
expect(focusNode.hasPrimaryFocus, isTrue);
expect(
Material.of(tester.element(find.byType(Switch))),
paints
..rrect(
color: const Color(0x52000000),
rrect: RRect.fromLTRBR(
383.5, 293.0, 416.5, 307.0, const Radius.circular(7.0)))
..circle(color: Colors.orange[500])
..circle(color: const Color(0x33000000))
..circle(color: const Color(0x24000000))
..circle(color: const Color(0x1f000000))
..circle(color: const Color(0xfffafafa)),
);
// Check what happens when disabled.
value = false;
await tester.pumpWidget(buildApp(enabled: false));
await tester.pumpAndSettle();
expect(focusNode.hasPrimaryFocus, isFalse);
expect(
Material.of(tester.element(find.byType(Switch))),
paints
..rrect(
color: const Color(0x1f000000),
rrect: RRect.fromLTRBR(
383.5, 293.0, 416.5, 307.0, const Radius.circular(7.0)))
..circle(color: const Color(0x33000000))
..circle(color: const Color(0x24000000))
..circle(color: const Color(0x1f000000))
..circle(color: const Color(0xffbdbdbd)),
);
});
testWidgets('Switch can be hovered and has correct hover color', (WidgetTester tester) async {
tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
bool value = true;
Widget buildApp({bool enabled = true}) {
return MaterialApp(
home: Material(
child: Center(
child: StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
return Switch(
value: value,
onChanged: enabled ? (bool newValue) {
setState(() {
value = newValue;
});
} : null,
hoverColor: Colors.orange[500],
);
}),
),
),
);
}
await tester.pumpWidget(buildApp());
await tester.pumpAndSettle();
expect(
Material.of(tester.element(find.byType(Switch))),
paints
..rrect(
color: const Color(0x801e88e5),
rrect: RRect.fromLTRBR(
383.5, 293.0, 416.5, 307.0, const Radius.circular(7.0)))
..circle(color: const Color(0x33000000))
..circle(color: const Color(0x24000000))
..circle(color: const Color(0x1f000000))
..circle(color: const Color(0xff1e88e5)),
);
// Start hovering
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.moveTo(tester.getCenter(find.byType(Switch)));
await tester.pumpWidget(buildApp());
await tester.pumpAndSettle();
expect(
Material.of(tester.element(find.byType(Switch))),
paints
..rrect(
color: const Color(0x801e88e5),
rrect: RRect.fromLTRBR(
383.5, 293.0, 416.5, 307.0, const Radius.circular(7.0)))
..circle(color: Colors.orange[500])
..circle(color: const Color(0x33000000))
..circle(color: const Color(0x24000000))
..circle(color: const Color(0x1f000000))
..circle(color: const Color(0xff1e88e5)),
);
// Check what happens when disabled.
await tester.pumpWidget(buildApp(enabled: false));
await tester.pumpAndSettle();
expect(
Material.of(tester.element(find.byType(Switch))),
paints
..rrect(
color: const Color(0x1f000000),
rrect: RRect.fromLTRBR(
383.5, 293.0, 416.5, 307.0, const Radius.circular(7.0)))
..circle(color: const Color(0x33000000))
..circle(color: const Color(0x24000000))
..circle(color: const Color(0x1f000000))
..circle(color: const Color(0xffbdbdbd)),
);
});
testWidgets('Switch can be toggled by keyboard shortcuts', (WidgetTester tester) async {
tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
bool value = true;
Widget buildApp({bool enabled = true}) {
return MaterialApp(
home: Material(
child: Center(
child: StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
return Switch(
value: value,
onChanged: enabled ? (bool newValue) {
setState(() {
value = newValue;
});
} : null,
focusColor: Colors.orange[500],
autofocus: true,
);
}),
),
),
);
}
await tester.pumpWidget(buildApp());
await tester.pumpAndSettle();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(value, isFalse);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(value, isTrue);
await tester.sendKeyEvent(LogicalKeyboardKey.space);
await tester.pumpAndSettle();
expect(value, isFalse);
await tester.sendKeyEvent(LogicalKeyboardKey.space);
await tester.pumpAndSettle();
expect(value, isTrue);
});
}

View file

@ -66,9 +66,12 @@ void main() {
FocusNode(
debugLabel: 'Label',
).debugFillProperties(builder);
final List<String> description = builder.properties.where((DiagnosticsNode n) => !n.isFiltered(DiagnosticLevel.info)).map((DiagnosticsNode n) => n.toString()).toList();
final List<String> description = builder.properties.map((DiagnosticsNode n) => n.toString()).toList();
expect(description, <String>[
'debugLabel: "Label"',
'context: null',
'canRequestFocus: true',
'hasFocus: false',
'hasPrimaryFocus: false'
]);
});
});
@ -621,9 +624,12 @@ void main() {
FocusScopeNode(
debugLabel: 'Scope Label',
).debugFillProperties(builder);
final List<String> description = builder.properties.where((DiagnosticsNode n) => !n.isFiltered(DiagnosticLevel.info)).map((DiagnosticsNode n) => n.toString()).toList();
final List<String> description = builder.properties.map((DiagnosticsNode n) => n.toString()).toList();
expect(description, <String>[
'debugLabel: "Scope Label"',
'context: null',
'canRequestFocus: true',
'hasFocus: false',
'hasPrimaryFocus: false'
]);
});
testWidgets('debugDescribeFocusTree produces correct output', (WidgetTester tester) async {
@ -663,43 +669,36 @@ void main() {
' │ primaryFocusCreator: Container-[GlobalKey#00000] ← [root]\n'
'\n'
' └─rootScope: FocusScopeNode#00000(Root Focus Scope)\n'
' │ FOCUSED\n'
' │ debugLabel: "Root Focus Scope"\n'
' │ IN FOCUS PATH\n'
' │ focusedChildren: FocusScopeNode#00000\n'
'\n'
' ├─Child 1: FocusScopeNode#00000(Scope 1)\n'
' │ │ context: Container-[GlobalKey#00000]\n'
' │ │ debugLabel: "Scope 1"\n'
' │ │\n'
' │ └─Child 1: FocusNode#00000(Parent 1)\n'
' │ │ context: Container-[GlobalKey#00000]\n'
' │ │ debugLabel: "Parent 1"\n'
' │ │\n'
' │ ├─Child 1: FocusNode#00000(Child 1)\n'
' │ │ context: Container-[GlobalKey#00000]\n'
' │ │ debugLabel: "Child 1"\n'
' │ │\n'
' │ └─Child 2: FocusNode#00000\n'
' │ context: Container-[GlobalKey#00000]\n'
'\n'
' └─Child 2: FocusScopeNode#00000\n'
' │ context: Container-[GlobalKey#00000]\n'
'FOCUSED\n'
'IN FOCUS PATH\n'
' │ focusedChildren: FocusNode#00000(Child 4)\n'
'\n'
' └─Child 1: FocusNode#00000(Parent 2)\n'
' │ context: Container-[GlobalKey#00000]\n'
' │ FOCUSED\n'
' │ debugLabel: "Parent 2"\n'
' │ IN FOCUS PATH\n'
'\n'
' ├─Child 1: FocusNode#00000(Child 3)\n'
' │ context: Container-[GlobalKey#00000]\n'
' │ debugLabel: "Child 3"\n'
'\n'
' └─Child 2: FocusNode#00000(Child 4)\n'
' context: Container-[GlobalKey#00000]\n'
' FOCUSED\n'
' debugLabel: "Child 4"\n'
' PRIMARY FOCUS\n'
));
});
});

View file

@ -229,34 +229,29 @@ void main() {
parentFocusScope.toStringDeep(),
equalsIgnoringHashCodes('FocusScopeNode#00000(Parent Scope Node)\n'
' │ context: FocusScope\n'
' │ FOCUSED\n'
' │ debugLabel: "Parent Scope Node"\n'
' │ IN FOCUS PATH\n'
' │ focusedChildren: FocusNode#00000(Child)\n'
'\n'
' └─Child 1: FocusNode#00000(Child)\n'
' context: Focus\n'
' FOCUSED\n'
' debugLabel: "Child"\n'),
' PRIMARY FOCUS\n'),
);
expect(WidgetsBinding.instance.focusManager.rootScope, hasAGoodToStringDeep);
expect(
WidgetsBinding.instance.focusManager.rootScope.toStringDeep(minLevel: DiagnosticLevel.info),
equalsIgnoringHashCodes('FocusScopeNode#00000(Root Focus Scope)\n'
' │ FOCUSED\n'
' │ debugLabel: "Root Focus Scope"\n'
' │ IN FOCUS PATH\n'
' │ focusedChildren: FocusScopeNode#00000(Parent Scope Node)\n'
'\n'
' └─Child 1: FocusScopeNode#00000(Parent Scope Node)\n'
' │ context: FocusScope\n'
' │ FOCUSED\n'
' │ debugLabel: "Parent Scope Node"\n'
' │ IN FOCUS PATH\n'
' │ focusedChildren: FocusNode#00000(Child)\n'
'\n'
' └─Child 1: FocusNode#00000(Child)\n'
' context: Focus\n'
' FOCUSED\n'
' debugLabel: "Child"\n'),
' PRIMARY FOCUS\n'),
);
// Add the child focus scope to the focus tree.