Fix dual focus issue in CheckboxListTile, RadioListTile and SwitchListTile (#143213)

These widgets can now only receive focus once when tabbing through the focus tree.
This commit is contained in:
Nitesh Sharma 2024-02-12 23:37:51 +05:30 committed by GitHub
parent ace3e58f0a
commit 49f620d8ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 251 additions and 108 deletions

View file

@ -475,42 +475,46 @@ class CheckboxListTile extends StatelessWidget {
switch (_checkboxType) {
case _CheckboxType.material:
control = Checkbox(
value: value,
onChanged: enabled ?? true ? onChanged : null,
mouseCursor: mouseCursor,
activeColor: activeColor,
fillColor: fillColor,
checkColor: checkColor,
hoverColor: hoverColor,
overlayColor: overlayColor,
splashRadius: splashRadius,
materialTapTargetSize: materialTapTargetSize ?? MaterialTapTargetSize.shrinkWrap,
autofocus: autofocus,
tristate: tristate,
shape: checkboxShape,
side: side,
isError: isError,
semanticLabel: checkboxSemanticLabel,
control = ExcludeFocus(
child: Checkbox(
value: value,
onChanged: enabled ?? true ? onChanged : null,
mouseCursor: mouseCursor,
activeColor: activeColor,
fillColor: fillColor,
checkColor: checkColor,
hoverColor: hoverColor,
overlayColor: overlayColor,
splashRadius: splashRadius,
materialTapTargetSize: materialTapTargetSize ?? MaterialTapTargetSize.shrinkWrap,
autofocus: autofocus,
tristate: tristate,
shape: checkboxShape,
side: side,
isError: isError,
semanticLabel: checkboxSemanticLabel,
),
);
case _CheckboxType.adaptive:
control = Checkbox.adaptive(
value: value,
onChanged: enabled ?? true ? onChanged : null,
mouseCursor: mouseCursor,
activeColor: activeColor,
fillColor: fillColor,
checkColor: checkColor,
hoverColor: hoverColor,
overlayColor: overlayColor,
splashRadius: splashRadius,
materialTapTargetSize: materialTapTargetSize ?? MaterialTapTargetSize.shrinkWrap,
autofocus: autofocus,
tristate: tristate,
shape: checkboxShape,
side: side,
isError: isError,
semanticLabel: checkboxSemanticLabel,
control = ExcludeFocus(
child: Checkbox.adaptive(
value: value,
onChanged: enabled ?? true ? onChanged : null,
mouseCursor: mouseCursor,
activeColor: activeColor,
fillColor: fillColor,
checkColor: checkColor,
hoverColor: hoverColor,
overlayColor: overlayColor,
splashRadius: splashRadius,
materialTapTargetSize: materialTapTargetSize ?? MaterialTapTargetSize.shrinkWrap,
autofocus: autofocus,
tristate: tristate,
shape: checkboxShape,
side: side,
isError: isError,
semanticLabel: checkboxSemanticLabel,
),
);
}

View file

@ -452,35 +452,39 @@ class RadioListTile<T> extends StatelessWidget {
final Widget control;
switch (_radioType) {
case _RadioType.material:
control = Radio<T>(
value: value,
groupValue: groupValue,
onChanged: onChanged,
toggleable: toggleable,
activeColor: activeColor,
materialTapTargetSize: materialTapTargetSize ?? MaterialTapTargetSize.shrinkWrap,
autofocus: autofocus,
fillColor: fillColor,
mouseCursor: mouseCursor,
hoverColor: hoverColor,
overlayColor: overlayColor,
splashRadius: splashRadius,
control = ExcludeFocus(
child: Radio<T>(
value: value,
groupValue: groupValue,
onChanged: onChanged,
toggleable: toggleable,
activeColor: activeColor,
materialTapTargetSize: materialTapTargetSize ?? MaterialTapTargetSize.shrinkWrap,
autofocus: autofocus,
fillColor: fillColor,
mouseCursor: mouseCursor,
hoverColor: hoverColor,
overlayColor: overlayColor,
splashRadius: splashRadius,
),
);
case _RadioType.adaptive:
control = Radio<T>.adaptive(
value: value,
groupValue: groupValue,
onChanged: onChanged,
toggleable: toggleable,
activeColor: activeColor,
materialTapTargetSize: materialTapTargetSize ?? MaterialTapTargetSize.shrinkWrap,
autofocus: autofocus,
fillColor: fillColor,
mouseCursor: mouseCursor,
hoverColor: hoverColor,
overlayColor: overlayColor,
splashRadius: splashRadius,
useCupertinoCheckmarkStyle: useCupertinoCheckmarkStyle,
control = ExcludeFocus(
child: Radio<T>.adaptive(
value: value,
groupValue: groupValue,
onChanged: onChanged,
toggleable: toggleable,
activeColor: activeColor,
materialTapTargetSize: materialTapTargetSize ?? MaterialTapTargetSize.shrinkWrap,
autofocus: autofocus,
fillColor: fillColor,
mouseCursor: mouseCursor,
hoverColor: hoverColor,
overlayColor: overlayColor,
splashRadius: splashRadius,
useCupertinoCheckmarkStyle: useCupertinoCheckmarkStyle,
),
);
}

View file

@ -511,54 +511,58 @@ class SwitchListTile extends StatelessWidget {
final Widget control;
switch (_switchListTileType) {
case _SwitchListTileType.adaptive:
control = Switch.adaptive(
value: value,
onChanged: onChanged,
activeColor: activeColor,
activeThumbImage: activeThumbImage,
inactiveThumbImage: inactiveThumbImage,
materialTapTargetSize: materialTapTargetSize ?? MaterialTapTargetSize.shrinkWrap,
activeTrackColor: activeTrackColor,
inactiveTrackColor: inactiveTrackColor,
inactiveThumbColor: inactiveThumbColor,
autofocus: autofocus,
onFocusChange: onFocusChange,
onActiveThumbImageError: onActiveThumbImageError,
onInactiveThumbImageError: onInactiveThumbImageError,
thumbColor: thumbColor,
trackColor: trackColor,
trackOutlineColor: trackOutlineColor,
thumbIcon: thumbIcon,
applyCupertinoTheme: applyCupertinoTheme,
dragStartBehavior: dragStartBehavior,
mouseCursor: mouseCursor,
splashRadius: splashRadius,
overlayColor: overlayColor,
control = ExcludeFocus(
child: Switch.adaptive(
value: value,
onChanged: onChanged,
activeColor: activeColor,
activeThumbImage: activeThumbImage,
inactiveThumbImage: inactiveThumbImage,
materialTapTargetSize: materialTapTargetSize ?? MaterialTapTargetSize.shrinkWrap,
activeTrackColor: activeTrackColor,
inactiveTrackColor: inactiveTrackColor,
inactiveThumbColor: inactiveThumbColor,
autofocus: autofocus,
onFocusChange: onFocusChange,
onActiveThumbImageError: onActiveThumbImageError,
onInactiveThumbImageError: onInactiveThumbImageError,
thumbColor: thumbColor,
trackColor: trackColor,
trackOutlineColor: trackOutlineColor,
thumbIcon: thumbIcon,
applyCupertinoTheme: applyCupertinoTheme,
dragStartBehavior: dragStartBehavior,
mouseCursor: mouseCursor,
splashRadius: splashRadius,
overlayColor: overlayColor,
),
);
case _SwitchListTileType.material:
control = Switch(
value: value,
onChanged: onChanged,
activeColor: activeColor,
activeThumbImage: activeThumbImage,
inactiveThumbImage: inactiveThumbImage,
materialTapTargetSize: materialTapTargetSize ?? MaterialTapTargetSize.shrinkWrap,
activeTrackColor: activeTrackColor,
inactiveTrackColor: inactiveTrackColor,
inactiveThumbColor: inactiveThumbColor,
autofocus: autofocus,
onFocusChange: onFocusChange,
onActiveThumbImageError: onActiveThumbImageError,
onInactiveThumbImageError: onInactiveThumbImageError,
thumbColor: thumbColor,
trackColor: trackColor,
trackOutlineColor: trackOutlineColor,
thumbIcon: thumbIcon,
dragStartBehavior: dragStartBehavior,
mouseCursor: mouseCursor,
splashRadius: splashRadius,
overlayColor: overlayColor,
control = ExcludeFocus(
child: Switch(
value: value,
onChanged: onChanged,
activeColor: activeColor,
activeThumbImage: activeThumbImage,
inactiveThumbImage: inactiveThumbImage,
materialTapTargetSize: materialTapTargetSize ?? MaterialTapTargetSize.shrinkWrap,
activeTrackColor: activeTrackColor,
inactiveTrackColor: inactiveTrackColor,
inactiveThumbColor: inactiveThumbColor,
autofocus: autofocus,
onFocusChange: onFocusChange,
onActiveThumbImageError: onActiveThumbImageError,
onInactiveThumbImageError: onInactiveThumbImageError,
thumbColor: thumbColor,
trackColor: trackColor,
trackOutlineColor: trackOutlineColor,
thumbIcon: thumbIcon,
dragStartBehavior: dragStartBehavior,
mouseCursor: mouseCursor,
splashRadius: splashRadius,
overlayColor: overlayColor,
),
);
}

View file

@ -1194,6 +1194,41 @@ void main() {
handle.dispose();
});
testWidgets('CheckboxListTile.control widget should not request focus on traversal', (WidgetTester tester) async {
final GlobalKey firstChildKey = GlobalKey();
final GlobalKey secondChildKey = GlobalKey();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Column(
children: <Widget>[
CheckboxListTile(
value: true,
onChanged: (bool? value) {},
title: Text('Hey', key: firstChildKey),
),
CheckboxListTile(
value: true,
onChanged: (bool? value) {},
title: Text('There', key: secondChildKey),
),
],
),
),
),
);
await tester.pump();
Focus.of(firstChildKey.currentContext!).requestFocus();
await tester.pump();
expect(Focus.of(firstChildKey.currentContext!).hasPrimaryFocus, isTrue);
Focus.of(firstChildKey.currentContext!).nextFocus();
await tester.pump();
expect(Focus.of(firstChildKey.currentContext!).hasPrimaryFocus, isFalse);
expect(Focus.of(secondChildKey.currentContext!).hasPrimaryFocus, isTrue);
});
}
class _SelectedGrabMouseCursor extends MaterialStateMouseCursor {

View file

@ -1260,6 +1260,43 @@ void main() {
expect(tester.getSize(find.byType(Radio<bool>)), const Size(48.0, 48.0));
});
testWidgets('RadioListTile.control widget should not request focus on traversal', (WidgetTester tester) async {
final GlobalKey firstChildKey = GlobalKey();
final GlobalKey secondChildKey = GlobalKey();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Column(
children: <Widget>[
RadioListTile<bool>(
value: true,
groupValue: true,
onChanged: (bool? value) {},
title: Text('Hey', key: firstChildKey),
),
RadioListTile<bool>(
value: true,
groupValue: true,
onChanged: (bool? value) {},
title: Text('There', key: secondChildKey),
),
],
),
),
),
);
await tester.pump();
Focus.of(firstChildKey.currentContext!).requestFocus();
await tester.pump();
expect(Focus.of(firstChildKey.currentContext!).hasPrimaryFocus, isTrue);
Focus.of(firstChildKey.currentContext!).nextFocus();
await tester.pump();
expect(Focus.of(firstChildKey.currentContext!).hasPrimaryFocus, isFalse);
expect(Focus.of(secondChildKey.currentContext!).hasPrimaryFocus, isTrue);
});
testWidgets('RadioListTile.adaptive shows the correct radio platform widget', (WidgetTester tester) async {
Widget buildApp(TargetPlatform platform) {
return MaterialApp(

View file

@ -359,7 +359,19 @@ void main() {
final ListTile listTile = tester.widget(find.byType(ListTile));
// When controlAffinity is ListTileControlAffinity.leading, the position of
// Switch is at leading edge and SwitchListTile.secondary at trailing edge.
expect(listTile.leading.runtimeType, Switch);
// Find the ExcludeFocus widget within the ListTile's leading
final ExcludeFocus excludeFocusWidget = tester.widget(
find.byWidgetPredicate((Widget widget) => listTile.leading == widget && widget is ExcludeFocus),
);
// Assert that the ExcludeFocus widget is not null
expect(excludeFocusWidget, isNotNull);
// Assert that the child of ExcludeFocus is Switch
expect(excludeFocusWidget.child.runtimeType, Switch);
// Assert that the trailing is Icon
expect(listTile.trailing.runtimeType, Icon);
});
@ -379,8 +391,20 @@ void main() {
// By default, value of controlAffinity is ListTileControlAffinity.platform,
// where the position of SwitchListTile.secondary is at leading edge and Switch
// at trailing edge. This also covers test for ListTileControlAffinity.trailing.
// Find the ExcludeFocus widget within the ListTile's trailing
final ExcludeFocus excludeFocusWidget = tester.widget(
find.byWidgetPredicate((Widget widget) => listTile.trailing == widget && widget is ExcludeFocus),
);
// Assert that the ExcludeFocus widget is not null
expect(excludeFocusWidget, isNotNull);
// Assert that the child of ExcludeFocus is Switch
expect(excludeFocusWidget.child.runtimeType, Switch);
// Assert that the leading is Icon
expect(listTile.leading.runtimeType, Icon);
expect(listTile.trailing.runtimeType, Switch);
});
testWidgets('SwitchListTile respects shape', (WidgetTester tester) async {
@ -1632,4 +1656,39 @@ void main() {
paints..rrect()..rrect(color: hoveredTrackColor, style: PaintingStyle.stroke)
);
});
testWidgets('SwitchListTile.control widget should not request focus on traversal', (WidgetTester tester) async {
final GlobalKey firstChildKey = GlobalKey();
final GlobalKey secondChildKey = GlobalKey();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Column(
children: <Widget>[
SwitchListTile(
value: true,
onChanged: (bool? value) {},
title: Text('Hey', key: firstChildKey),
),
SwitchListTile(
value: true,
onChanged: (bool? value) {},
title: Text('There', key: secondChildKey),
),
],
),
),
),
);
await tester.pump();
Focus.of(firstChildKey.currentContext!).requestFocus();
await tester.pump();
expect(Focus.of(firstChildKey.currentContext!).hasPrimaryFocus, isTrue);
Focus.of(firstChildKey.currentContext!).nextFocus();
await tester.pump();
expect(Focus.of(firstChildKey.currentContext!).hasPrimaryFocus, isFalse);
expect(Focus.of(secondChildKey.currentContext!).hasPrimaryFocus, isTrue);
});
}