diff --git a/dev/integration_tests/android_semantics_testing/test_driver/main_test.dart b/dev/integration_tests/android_semantics_testing/test_driver/main_test.dart index 051d90d293..242d487367 100644 --- a/dev/integration_tests/android_semantics_testing/test_driver/main_test.dart +++ b/dev/integration_tests/android_semantics_testing/test_driver/main_test.dart @@ -198,8 +198,16 @@ void main() { }); test('Checkbox has correct Android semantics', () async { + Future getCheckboxSemantics(String key) async { + return getSemantics( + find.descendant( + of: find.byValueKey(key), + matching: find.byType('_CheckboxRenderObjectWidget'), + ), + ); + } expect( - await getSemantics(find.byValueKey(checkboxKeyValue)), + await getCheckboxSemantics(checkboxKeyValue), hasAndroidSemantics( className: AndroidClassName.checkBox, isChecked: false, @@ -216,7 +224,7 @@ void main() { await driver.tap(find.byValueKey(checkboxKeyValue)); expect( - await getSemantics(find.byValueKey(checkboxKeyValue)), + await getCheckboxSemantics(checkboxKeyValue), hasAndroidSemantics( className: AndroidClassName.checkBox, isChecked: true, @@ -230,7 +238,7 @@ void main() { ), ); expect( - await getSemantics(find.byValueKey(disabledCheckboxKeyValue)), + await getCheckboxSemantics(disabledCheckboxKeyValue), hasAndroidSemantics( className: AndroidClassName.checkBox, isCheckable: true, @@ -242,8 +250,16 @@ void main() { ); }); test('Radio has correct Android semantics', () async { + Future getRadioSemantics(String key) async { + return getSemantics( + find.descendant( + of: find.byValueKey(key), + matching: find.byType('_RadioRenderObjectWidget'), + ), + ); + } expect( - await getSemantics(find.byValueKey(radio2KeyValue)), + await getRadioSemantics(radio2KeyValue), hasAndroidSemantics( className: AndroidClassName.radio, isChecked: false, @@ -260,7 +276,7 @@ void main() { await driver.tap(find.byValueKey(radio2KeyValue)); expect( - await getSemantics(find.byValueKey(radio2KeyValue)), + await getRadioSemantics(radio2KeyValue), hasAndroidSemantics( className: AndroidClassName.radio, isChecked: true, @@ -275,8 +291,16 @@ void main() { ); }); test('Switch has correct Android semantics', () async { + Future getSwitchSemantics(String key) async { + return getSemantics( + find.descendant( + of: find.byValueKey(key), + matching: find.byType('_SwitchRenderObjectWidget'), + ), + ); + } expect( - await getSemantics(find.byValueKey(switchKeyValue)), + await getSwitchSemantics(switchKeyValue), hasAndroidSemantics( className: AndroidClassName.toggleSwitch, isChecked: false, @@ -293,7 +317,7 @@ void main() { await driver.tap(find.byValueKey(switchKeyValue)); expect( - await getSemantics(find.byValueKey(switchKeyValue)), + await getSwitchSemantics(switchKeyValue), hasAndroidSemantics( className: AndroidClassName.toggleSwitch, isChecked: true, @@ -310,8 +334,16 @@ void main() { // Regression test for https://github.com/flutter/flutter/issues/20820. test('Switch can be labeled', () async { + Future getSwitchSemantics(String key) async { + return getSemantics( + find.descendant( + of: find.byValueKey(key), + matching: find.byType('_SwitchRenderObjectWidget'), + ), + ); + } expect( - await getSemantics(find.byValueKey(labeledSwitchKeyValue)), + await getSwitchSemantics(labeledSwitchKeyValue), hasAndroidSemantics( className: AndroidClassName.toggleSwitch, isChecked: false, diff --git a/packages/flutter/lib/src/material/checkbox.dart b/packages/flutter/lib/src/material/checkbox.dart index 7577366603..2d4124aeba 100644 --- a/packages/flutter/lib/src/material/checkbox.dart +++ b/packages/flutter/lib/src/material/checkbox.dart @@ -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 with TickerProviderStateMixin { + bool get enabled => widget.onChanged != null; + Map _actionMap; + bool _showHighlight = false; + + @override + void initState() { + super.initState(); + _actionMap = { + 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 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 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 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; diff --git a/packages/flutter/lib/src/material/ink_well.dart b/packages/flutter/lib/src/material/ink_well.dart index 99edbec94c..3acf8be51d 100644 --- a/packages/flutter/lib/src/material/ink_well.dart +++ b/packages/flutter/lib/src/material/ink_well.dart @@ -489,20 +489,25 @@ class _InkResponseState extends State 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 = { ActivateAction.key: _createAction }; + _actionMap = { + SelectAction.key: _createAction, + if (!kIsWeb) ActivateAction.key: _createAction, + }; WidgetsBinding.instance.focusManager.addHighlightModeListener(_handleFocusHighlightModeChange); } diff --git a/packages/flutter/lib/src/material/radio.dart b/packages/flutter/lib/src/material/radio.dart index 81a378f48b..457df4cfa3 100644 --- a/packages/flutter/lib/src/material/radio.dart +++ b/packages/flutter/lib/src/material/radio.dart @@ -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 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 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 createState() => _RadioState(); } class _RadioState extends State> with TickerProviderStateMixin { - bool get _enabled => widget.onChanged != null; + bool get enabled => widget.onChanged != null; + Map _actionMap; + bool _showHighlight = false; + + @override + void initState() { + super.initState(); + _actionMap = { + 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 extends State> 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 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 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 diff --git a/packages/flutter/lib/src/material/switch.dart b/packages/flutter/lib/src/material/switch.dart index 060ebe39c5..64d31ec2ed 100644 --- a/packages/flutter/lib/src/material/switch.dart +++ b/packages/flutter/lib/src/material/switch.dart @@ -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 with TickerProviderStateMixin { + Map _actionMap; + bool _showHighlight = false; + + @override + void initState() { + super.initState(); + _actionMap = { + 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 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 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 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 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() diff --git a/packages/flutter/lib/src/material/toggleable.dart b/packages/flutter/lib/src/material/toggleable.dart index 571b5fb68b..22b497bed4 100644 --- a/packages/flutter/lib/src/material/toggleable.dart +++ b/packages/flutter/lib/src/material/toggleable.dart @@ -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 _kRadialReactionRadiusTween = Tween(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 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 _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 _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 _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); } } diff --git a/packages/flutter/lib/src/widgets/actions.dart b/packages/flutter/lib/src/widgets/actions.dart index 1509f9d14d..9d1fed90f0 100644 --- a/packages/flutter/lib/src/widgets/actions.dart +++ b/packages/flutter/lib/src/widgets/actions.dart @@ -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(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(SelectAction); +} diff --git a/packages/flutter/lib/src/widgets/app.dart b/packages/flutter/lib/src/widgets/app.dart index 63f76beced..7af61b1c17 100644 --- a/packages/flutter/lib/src/widgets/app.dart +++ b/packages/flutter/lib/src/widgets/app.dart @@ -1046,6 +1046,7 @@ class _WidgetsAppState extends State 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 _actionMap = { diff --git a/packages/flutter/lib/src/widgets/focus_manager.dart b/packages/flutter/lib/src/widgets/focus_manager.dart index 97a098d0db..00481ac97c 100644 --- a/packages/flutter/lib/src/widgets/focus_manager.dart +++ b/packages/flutter/lib/src/widgets/focus_manager.dart @@ -897,8 +897,8 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { super.debugFillProperties(properties); properties.add(DiagnosticsProperty('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 diff --git a/packages/flutter/test/material/checkbox_test.dart b/packages/flutter/test/material/checkbox_test.dart index e4720c1188..6850a6dac0 100644 --- a/packages/flutter/test/material/checkbox_test.dart +++ b/packages/flutter/test/material/checkbox_test.dart @@ -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.tap], ), hasLength(1)); @@ -222,6 +227,7 @@ void main() { SemanticsFlag.hasEnabledState, SemanticsFlag.isEnabled, SemanticsFlag.isChecked, + SemanticsFlag.isFocusable, ], actions: [SemanticsAction.tap], ), hasLength(1)); @@ -241,6 +247,7 @@ void main() { SemanticsFlag.hasCheckedState, SemanticsFlag.hasEnabledState, SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, ], actions: [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, { @@ -304,7 +311,9 @@ void main() { } RenderToggleable getCheckboxRenderer() { - return tester.renderObject(find.byType(Checkbox)); + return tester.renderObject(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(find.byType(Checkbox)); + return tester.renderObject(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); + }); } diff --git a/packages/flutter/test/material/radio_test.dart b/packages/flutter/test/material/radio_test.dart index 3a1741f1a1..637fbf461d 100644 --- a/packages/flutter/test/material/radio_test.dart +++ b/packages/flutter/test/material/radio_test.dart @@ -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.tap, @@ -157,6 +159,7 @@ void main() { SemanticsFlag.isChecked, SemanticsFlag.hasEnabledState, SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, ], actions: [ 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, { @@ -279,5 +283,227 @@ void main() { matchesGoldenFile('radio.ink_ripple.png'), ); }, 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( + 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( + 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: [ + Radio( + key: radioKey0, + value: 0, + onChanged: enabled ? (int newValue) { + setState(() { + groupValue = newValue; + }); + } : null, + hoverColor: Colors.orange[500], + groupValue: groupValue, + autofocus: true, + ), + Radio( + 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)); + }); + } diff --git a/packages/flutter/test/material/raw_material_button_test.dart b/packages/flutter/test/material/raw_material_button_test.dart index a769865dfc..6bb93d2898 100644 --- a/packages/flutter/test/material/raw_material_button_test.dart +++ b/packages/flutter/test/material/raw_material_button_test.dart @@ -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(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 { diff --git a/packages/flutter/test/material/switch_test.dart b/packages/flutter/test/material/switch_test.dart index 7c0fc91451..05e00d37d2 100644 --- a/packages/flutter/test/material/switch_test.dart +++ b/packages/flutter/test/material/switch_test.dart @@ -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, { @@ -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); + }); } diff --git a/packages/flutter/test/widgets/focus_manager_test.dart b/packages/flutter/test/widgets/focus_manager_test.dart index 9c693f3a4b..81f0382ce5 100644 --- a/packages/flutter/test/widgets/focus_manager_test.dart +++ b/packages/flutter/test/widgets/focus_manager_test.dart @@ -66,9 +66,12 @@ void main() { FocusNode( debugLabel: 'Label', ).debugFillProperties(builder); - final List description = builder.properties.where((DiagnosticsNode n) => !n.isFiltered(DiagnosticLevel.info)).map((DiagnosticsNode n) => n.toString()).toList(); + final List description = builder.properties.map((DiagnosticsNode n) => n.toString()).toList(); expect(description, [ - 'debugLabel: "Label"', + 'context: null', + 'canRequestFocus: true', + 'hasFocus: false', + 'hasPrimaryFocus: false' ]); }); }); @@ -621,9 +624,12 @@ void main() { FocusScopeNode( debugLabel: 'Scope Label', ).debugFillProperties(builder); - final List description = builder.properties.where((DiagnosticsNode n) => !n.isFiltered(DiagnosticLevel.info)).map((DiagnosticsNode n) => n.toString()).toList(); + final List description = builder.properties.map((DiagnosticsNode n) => n.toString()).toList(); expect(description, [ - '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' )); }); }); diff --git a/packages/flutter/test/widgets/focus_scope_test.dart b/packages/flutter/test/widgets/focus_scope_test.dart index baefc7a4b6..84996f2f20 100644 --- a/packages/flutter/test/widgets/focus_scope_test.dart +++ b/packages/flutter/test/widgets/focus_scope_test.dart @@ -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.