Add titleAlignment to CheckboxListTile and RadioListTile (#168666)

Fixes https://github.com/flutter/flutter/issues/168596#issue-3052291792

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
This commit is contained in:
Valentin Vignal 2025-05-14 10:07:34 +08:00 committed by GitHub
parent e425d4348a
commit 4d037cfdf5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 580 additions and 0 deletions

View File

@ -206,6 +206,7 @@ class CheckboxListTile extends StatelessWidget {
this.enableFeedback,
this.checkboxSemanticLabel,
this.checkboxScaleFactor = 1.0,
this.titleAlignment,
this.internalAddSemanticForOnTap = false,
}) : _checkboxType = _CheckboxType.material,
assert(tristate || value != null),
@ -252,6 +253,7 @@ class CheckboxListTile extends StatelessWidget {
this.enableFeedback,
this.checkboxSemanticLabel,
this.checkboxScaleFactor = 1.0,
this.titleAlignment,
this.internalAddSemanticForOnTap = false,
}) : _checkboxType = _CheckboxType.adaptive,
assert(tristate || value != null),
@ -468,6 +470,20 @@ class CheckboxListTile extends StatelessWidget {
/// inoperative.
final bool? enabled;
/// Defines how [ListTile.leading] and [ListTile.trailing] are
/// vertically aligned relative to the [ListTile]'s titles
/// ([ListTile.title] and [ListTile.subtitle]).
///
/// If this property is null then [ListTileThemeData.titleAlignment]
/// is used. If that is also null then [ListTileTitleAlignment.threeLine]
/// is used.
///
/// See also:
///
/// * [ListTileTheme.of], which returns the nearest [ListTileTheme]'s
/// [ListTileThemeData].
final ListTileTitleAlignment? titleAlignment;
/// Whether to add button:true to the semantics if onTap is provided.
/// This is a temporary flag to help changing the behavior of ListTile onTap semantics.
///
@ -583,6 +599,7 @@ class CheckboxListTile extends StatelessWidget {
focusNode: focusNode,
onFocusChange: onFocusChange,
enableFeedback: enableFeedback,
titleAlignment: titleAlignment,
internalAddSemanticForOnTap: internalAddSemanticForOnTap,
),
);

View File

@ -201,6 +201,7 @@ class RadioListTile<T> extends StatelessWidget {
this.onFocusChange,
this.enableFeedback,
this.radioScaleFactor = 1.0,
this.titleAlignment,
this.internalAddSemanticForOnTap = false,
}) : _radioType = _RadioType.material,
useCupertinoCheckmarkStyle = false,
@ -243,6 +244,7 @@ class RadioListTile<T> extends StatelessWidget {
this.enableFeedback,
this.radioScaleFactor = 1.0,
this.useCupertinoCheckmarkStyle = false,
this.titleAlignment,
this.internalAddSemanticForOnTap = false,
}) : _radioType = _RadioType.adaptive,
assert(isThreeLine != true || subtitle != null);
@ -454,6 +456,20 @@ class RadioListTile<T> extends StatelessWidget {
final _RadioType _radioType;
/// Defines how [ListTile.leading] and [ListTile.trailing] are
/// vertically aligned relative to the [ListTile]'s titles
/// ([ListTile.title] and [ListTile.subtitle]).
///
/// If this property is null then [ListTileThemeData.titleAlignment]
/// is used. If that is also null then [ListTileTitleAlignment.threeLine]
/// is used.
///
/// See also:
///
/// * [ListTileTheme.of], which returns the nearest [ListTileTheme]'s
/// [ListTileThemeData].
final ListTileTitleAlignment? titleAlignment;
/// Whether to add button:true to the semantics if onTap is provided.
/// This is a temporary flag to help changing the behavior of ListTile onTap semantics.
///
@ -567,6 +583,7 @@ class RadioListTile<T> extends StatelessWidget {
focusNode: focusNode,
onFocusChange: onFocusChange,
enableFeedback: enableFeedback,
titleAlignment: titleAlignment,
internalAddSemanticForOnTap: internalAddSemanticForOnTap,
),
);

View File

@ -1569,6 +1569,278 @@ void main() {
);
expectThreeLine();
});
testWidgets('titleAlignment position with title widget', (WidgetTester tester) async {
const Key secondaryKey = Key('secondary');
const double titleHeight = 50.0;
const double secondaryHeight = 24.0;
// The default vertical padding for material 3 is 8.0.
const double minVerticalPadding = 8.0;
Widget buildFrame({ListTileTitleAlignment? titleAlignment}) {
return MaterialApp(
home: Material(
child: Center(
child: CheckboxListTile(
titleAlignment: titleAlignment,
controlAffinity: ListTileControlAffinity.leading,
value: true,
onChanged: (bool? newValue) {},
title: const SizedBox(width: 20.0, height: titleHeight),
secondary: const SizedBox(key: secondaryKey, width: 24.0, height: secondaryHeight),
),
),
),
);
}
// If [ThemeData.useMaterial3] is true, the default title alignment is
// [ListTileTitleAlignment.threeLine], which positions the leading and
// trailing widgets center vertically in the tile if the [ListTile.isThreeLine]
// property is false.
await tester.pumpWidget(buildFrame());
final double checkboxHeight = tester.getSize(find.byType(Checkbox)).height;
final double tileHeight = tester.getSize(find.byType(ListTile)).height;
expect(
find.byWidgetPredicate((Widget widget) {
return widget is ListTile && widget.titleAlignment == null;
}),
findsOne,
);
Offset tileOffset = tester.getTopLeft(find.byType(ListTile));
Offset checkboxOffset = tester.getTopLeft(find.byType(Checkbox));
Offset secondaryOffset = tester.getTopRight(find.byKey(secondaryKey));
// Leading and trailing widgets are centered vertically in the tile.
final double centerPositionCheckbox = (tileHeight / 2) - (checkboxHeight / 2);
final double centerPositionSecondary = (tileHeight / 2) - (secondaryHeight / 2);
expect(checkboxOffset.dy - tileOffset.dy, centerPositionCheckbox);
expect(secondaryOffset.dy - tileOffset.dy, centerPositionSecondary);
// Test [ListTileTitleAlignment.threeLine] alignment.
await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.threeLine));
expect(
find.byWidgetPredicate((Widget widget) {
return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.threeLine;
}),
findsOne,
);
tileOffset = tester.getTopLeft(find.byType(ListTile));
checkboxOffset = tester.getTopLeft(find.byType(Checkbox));
secondaryOffset = tester.getTopRight(find.byKey(secondaryKey));
// Leading and trailing widgets are centered vertically in the tile,
// If the [ListTile.isThreeLine] property is false.
expect(checkboxOffset.dy - tileOffset.dy, centerPositionCheckbox);
expect(secondaryOffset.dy - tileOffset.dy, centerPositionSecondary);
// Test [ListTileTitleAlignment.titleHeight] alignment.
await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.titleHeight));
expect(
find.byWidgetPredicate((Widget widget) {
return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.titleHeight;
}),
findsOne,
);
tileOffset = tester.getTopLeft(find.byType(ListTile));
checkboxOffset = tester.getTopLeft(find.byType(Checkbox));
secondaryOffset = tester.getTopRight(find.byKey(secondaryKey));
expect(checkboxOffset.dy - tileOffset.dy, (tileHeight - checkboxHeight) / 2);
expect(secondaryOffset.dy - tileOffset.dy, centerPositionSecondary);
// Test [ListTileTitleAlignment.top] alignment.
await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.top));
expect(
find.byWidgetPredicate((Widget widget) {
return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.top;
}),
findsOne,
);
tileOffset = tester.getTopLeft(find.byType(ListTile));
checkboxOffset = tester.getTopLeft(find.byType(Checkbox));
secondaryOffset = tester.getTopRight(find.byKey(secondaryKey));
// Leading and trailing widgets are placed minVerticalPadding below
// the top of the title widget. The default for material 3 is 8.0.
const double topPosition = minVerticalPadding;
expect(checkboxOffset.dy - tileOffset.dy, topPosition);
expect(secondaryOffset.dy - tileOffset.dy, topPosition);
// Test [ListTileTitleAlignment.center] alignment.
await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.center));
expect(
find.byWidgetPredicate((Widget widget) {
return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.center;
}),
findsOne,
);
tileOffset = tester.getTopLeft(find.byType(ListTile));
checkboxOffset = tester.getTopLeft(find.byType(Checkbox));
secondaryOffset = tester.getTopRight(find.byKey(secondaryKey));
// Leading and trailing widgets are centered vertically in the tile.
expect(checkboxOffset.dy - tileOffset.dy, centerPositionCheckbox);
expect(secondaryOffset.dy - tileOffset.dy, centerPositionSecondary);
// Test [ListTileTitleAlignment.bottom] alignment.
await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.bottom));
expect(
find.byWidgetPredicate((Widget widget) {
return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.bottom;
}),
findsOne,
);
tileOffset = tester.getTopLeft(find.byType(ListTile));
checkboxOffset = tester.getTopLeft(find.byType(Checkbox));
secondaryOffset = tester.getTopRight(find.byKey(secondaryKey));
// Leading and trailing widgets are placed minVerticalPadding above
// the bottom of the subtitle widget.
final double bottomPositionCheckbox = tileHeight - minVerticalPadding - checkboxHeight;
final double bottomPositionSecondary = tileHeight - minVerticalPadding - secondaryHeight;
expect(checkboxOffset.dy - tileOffset.dy, bottomPositionCheckbox);
expect(secondaryOffset.dy - tileOffset.dy, bottomPositionSecondary);
});
testWidgets('titleAlignment position with title and subtitle widgets', (
WidgetTester tester,
) async {
const Key secondaryKey = Key('secondary');
const double titleHeight = 50.0;
const double subtitleHeight = 50.0;
const double secondaryHeight = 24.0;
const double verticalPadding = 8.0;
Widget buildFrame({ListTileTitleAlignment? titleAlignment}) {
return MaterialApp(
home: Material(
child: Center(
child: CheckboxListTile(
titleAlignment: titleAlignment,
controlAffinity: ListTileControlAffinity.leading,
title: const SizedBox(width: 20.0, height: titleHeight),
subtitle: const SizedBox(width: 20.0, height: subtitleHeight),
secondary: const SizedBox(key: secondaryKey, width: 24.0, height: secondaryHeight),
value: true,
onChanged: (bool? newValue) {},
),
),
),
);
}
// If [ThemeData.useMaterial3] is true, the default title alignment is
// [ListTileTitleAlignment.threeLine], which positions the leading and
// trailing widgets center vertically in the tile if the [ListTile.isThreeLine]
// property is false.
await tester.pumpWidget(buildFrame());
final double tileHeight = tester.getSize(find.byType(ListTile)).height;
final double checkboxHeight = tester.getSize(find.byType(Checkbox)).height;
expect(
find.byWidgetPredicate((Widget widget) {
return widget is ListTile && widget.titleAlignment == null;
}),
findsOne,
);
Offset tileOffset = tester.getTopLeft(find.byType(ListTile));
Offset checkboxOffset = tester.getTopLeft(find.byType(Checkbox));
Offset secondaryOffset = tester.getTopRight(find.byKey(secondaryKey));
// Leading and trailing widgets are centered vertically in the tile.
final double centerPositionOffsetCheckbox = (tileHeight / 2) - (checkboxHeight / 2);
final double centerPositionOffsetSecondary = (tileHeight / 2) - (secondaryHeight / 2);
expect(checkboxOffset.dy - tileOffset.dy, centerPositionOffsetCheckbox);
expect(secondaryOffset.dy - tileOffset.dy, centerPositionOffsetSecondary);
// Test [ListTileTitleAlignment.threeLine] alignment.
await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.threeLine));
expect(
find.byWidgetPredicate((Widget widget) {
return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.threeLine;
}),
findsOne,
);
tileOffset = tester.getTopLeft(find.byType(ListTile));
checkboxOffset = tester.getTopLeft(find.byType(Checkbox));
secondaryOffset = tester.getTopRight(find.byKey(secondaryKey));
// Leading and trailing widgets are centered vertically in the tile,
// If the [ListTile.isThreeLine] property is false.
expect(checkboxOffset.dy - tileOffset.dy, centerPositionOffsetCheckbox);
expect(secondaryOffset.dy - tileOffset.dy, centerPositionOffsetSecondary);
// Test [ListTileTitleAlignment.titleHeight] alignment.
await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.titleHeight));
expect(
find.byWidgetPredicate((Widget widget) {
return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.titleHeight;
}),
findsOne,
);
tileOffset = tester.getTopLeft(find.byType(ListTile));
checkboxOffset = tester.getTopLeft(find.byType(Checkbox));
secondaryOffset = tester.getTopRight(find.byKey(secondaryKey));
// Leading and trailing widgets are positioned 16.0 pixels below the
// top of the title widget.
const double titlePosition = 16.0;
expect(checkboxOffset.dy - tileOffset.dy, titlePosition);
expect(secondaryOffset.dy - tileOffset.dy, titlePosition);
// Test [ListTileTitleAlignment.top] alignment.
await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.top));
expect(
find.byWidgetPredicate((Widget widget) {
return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.top;
}),
findsOne,
);
tileOffset = tester.getTopLeft(find.byType(ListTile));
checkboxOffset = tester.getTopLeft(find.byType(Checkbox));
secondaryOffset = tester.getTopRight(find.byKey(secondaryKey));
// Leading and trailing widgets are placed minVerticalPadding below
// the top of the title widget.
const double topPosition = verticalPadding;
expect(checkboxOffset.dy - tileOffset.dy, topPosition);
expect(secondaryOffset.dy - tileOffset.dy, topPosition);
// Test [ListTileTitleAlignment.center] alignment.
await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.center));
expect(
find.byWidgetPredicate((Widget widget) {
return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.center;
}),
findsOne,
);
tileOffset = tester.getTopLeft(find.byType(ListTile));
checkboxOffset = tester.getTopLeft(find.byType(Checkbox));
secondaryOffset = tester.getTopRight(find.byKey(secondaryKey));
// Leading and trailing widgets are centered vertically in the tile.
expect(checkboxOffset.dy - tileOffset.dy, centerPositionOffsetCheckbox);
expect(secondaryOffset.dy - tileOffset.dy, centerPositionOffsetSecondary);
// Test [ListTileTitleAlignment.bottom] alignment.
await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.bottom));
expect(
find.byWidgetPredicate((Widget widget) {
return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.bottom;
}),
findsOne,
);
tileOffset = tester.getTopLeft(find.byType(ListTile));
checkboxOffset = tester.getTopLeft(find.byType(Checkbox));
secondaryOffset = tester.getTopRight(find.byKey(secondaryKey));
// Leading and trailing widgets are placed minVerticalPadding above
// the bottom of the subtitle widget.
final double bottomPositionCheckbox = tileHeight - verticalPadding - checkboxHeight;
final double bottomPositionSecondary = tileHeight - verticalPadding - secondaryHeight;
expect(checkboxOffset.dy - tileOffset.dy, bottomPositionCheckbox);
expect(secondaryOffset.dy - tileOffset.dy, bottomPositionSecondary);
});
}
class _SelectedGrabMouseCursor extends MaterialStateMouseCursor {

View File

@ -1880,4 +1880,278 @@ void main() {
);
expectThreeLine();
});
testWidgets('titleAlignment position with title widget', (WidgetTester tester) async {
const Key secondaryKey = Key('secondary');
const double titleHeight = 50.0;
const double secondaryHeight = 24.0;
// The default vertical padding for material 3 is 8.0.
const double minVerticalPadding = 8.0;
Widget buildFrame({ListTileTitleAlignment? titleAlignment}) {
return MaterialApp(
home: Material(
child: Center(
child: RadioListTile<bool>(
titleAlignment: titleAlignment,
controlAffinity: ListTileControlAffinity.leading,
value: true,
groupValue: true,
onChanged: (bool? newValue) {},
title: const SizedBox(width: 20.0, height: titleHeight),
secondary: const SizedBox(key: secondaryKey, width: 24.0, height: secondaryHeight),
),
),
),
);
}
// If [ThemeData.useMaterial3] is true, the default title alignment is
// [ListTileTitleAlignment.threeLine], which positions the leading and
// trailing widgets center vertically in the tile if the [ListTile.isThreeLine]
// property is false.
await tester.pumpWidget(buildFrame());
final double radioHeight = tester.getSize(find.byType(Radio<bool>)).height;
final double tileHeight = tester.getSize(find.byType(ListTile)).height;
expect(
find.byWidgetPredicate((Widget widget) {
return widget is ListTile && widget.titleAlignment == null;
}),
findsOne,
);
Offset tileOffset = tester.getTopLeft(find.byType(ListTile));
Offset radioOffset = tester.getTopLeft(find.byType(Radio<bool>));
Offset secondaryOffset = tester.getTopRight(find.byKey(secondaryKey));
// Leading and trailing widgets are centered vertically in the tile.
final double centerPositionRadio = (tileHeight / 2) - (radioHeight / 2);
final double centerPositionSecondary = (tileHeight / 2) - (secondaryHeight / 2);
expect(radioOffset.dy - tileOffset.dy, centerPositionRadio);
expect(secondaryOffset.dy - tileOffset.dy, centerPositionSecondary);
// Test [ListTileTitleAlignment.threeLine] alignment.
await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.threeLine));
expect(
find.byWidgetPredicate((Widget widget) {
return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.threeLine;
}),
findsOne,
);
tileOffset = tester.getTopLeft(find.byType(ListTile));
radioOffset = tester.getTopLeft(find.byType(Radio<bool>));
secondaryOffset = tester.getTopRight(find.byKey(secondaryKey));
// Leading and trailing widgets are centered vertically in the tile,
// If the [ListTile.isThreeLine] property is false.
expect(radioOffset.dy - tileOffset.dy, centerPositionRadio);
expect(secondaryOffset.dy - tileOffset.dy, centerPositionSecondary);
// Test [ListTileTitleAlignment.titleHeight] alignment.
await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.titleHeight));
expect(
find.byWidgetPredicate((Widget widget) {
return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.titleHeight;
}),
findsOne,
);
tileOffset = tester.getTopLeft(find.byType(ListTile));
radioOffset = tester.getTopLeft(find.byType(Radio<bool>));
secondaryOffset = tester.getTopRight(find.byKey(secondaryKey));
expect(radioOffset.dy - tileOffset.dy, (tileHeight - radioHeight) / 2);
expect(secondaryOffset.dy - tileOffset.dy, centerPositionSecondary);
// Test [ListTileTitleAlignment.top] alignment.
await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.top));
expect(
find.byWidgetPredicate((Widget widget) {
return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.top;
}),
findsOne,
);
tileOffset = tester.getTopLeft(find.byType(ListTile));
radioOffset = tester.getTopLeft(find.byType(Radio<bool>));
secondaryOffset = tester.getTopRight(find.byKey(secondaryKey));
// Leading and trailing widgets are placed minVerticalPadding below
// the top of the title widget. The default for material 3 is 8.0.
const double topPosition = minVerticalPadding;
expect(radioOffset.dy - tileOffset.dy, topPosition);
expect(secondaryOffset.dy - tileOffset.dy, topPosition);
// Test [ListTileTitleAlignment.center] alignment.
await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.center));
expect(
find.byWidgetPredicate((Widget widget) {
return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.center;
}),
findsOne,
);
tileOffset = tester.getTopLeft(find.byType(ListTile));
radioOffset = tester.getTopLeft(find.byType(Radio<bool>));
secondaryOffset = tester.getTopRight(find.byKey(secondaryKey));
// Leading and trailing widgets are centered vertically in the tile.
expect(radioOffset.dy - tileOffset.dy, centerPositionRadio);
expect(secondaryOffset.dy - tileOffset.dy, centerPositionSecondary);
// Test [ListTileTitleAlignment.bottom] alignment.
await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.bottom));
expect(
find.byWidgetPredicate((Widget widget) {
return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.bottom;
}),
findsOne,
);
tileOffset = tester.getTopLeft(find.byType(ListTile));
radioOffset = tester.getTopLeft(find.byType(Radio<bool>));
secondaryOffset = tester.getTopRight(find.byKey(secondaryKey));
// Leading and trailing widgets are placed minVerticalPadding above
// the bottom of the subtitle widget.
final double bottomPositionRadio = tileHeight - minVerticalPadding - radioHeight;
final double bottomPositionSecondary = tileHeight - minVerticalPadding - secondaryHeight;
expect(radioOffset.dy - tileOffset.dy, bottomPositionRadio);
expect(secondaryOffset.dy - tileOffset.dy, bottomPositionSecondary);
});
testWidgets('titleAlignment position with title and subtitle widgets', (
WidgetTester tester,
) async {
const Key secondaryKey = Key('secondary');
const double titleHeight = 50.0;
const double subtitleHeight = 50.0;
const double secondaryHeight = 24.0;
const double verticalPadding = 8.0;
Widget buildFrame({ListTileTitleAlignment? titleAlignment}) {
return MaterialApp(
home: Material(
child: Center(
child: RadioListTile<bool>(
titleAlignment: titleAlignment,
controlAffinity: ListTileControlAffinity.leading,
title: const SizedBox(width: 20.0, height: titleHeight),
subtitle: const SizedBox(width: 20.0, height: subtitleHeight),
secondary: const SizedBox(key: secondaryKey, width: 24.0, height: secondaryHeight),
value: true,
groupValue: true,
onChanged: (bool? newValue) {},
),
),
),
);
}
// If [ThemeData.useMaterial3] is true, the default title alignment is
// [ListTileTitleAlignment.threeLine], which positions the leading and
// trailing widgets center vertically in the tile if the [ListTile.isThreeLine]
// property is false.
await tester.pumpWidget(buildFrame());
final double tileHeight = tester.getSize(find.byType(ListTile)).height;
final double radioHeight = tester.getSize(find.byType(Radio<bool>)).height;
expect(
find.byWidgetPredicate((Widget widget) {
return widget is ListTile && widget.titleAlignment == null;
}),
findsOne,
);
Offset tileOffset = tester.getTopLeft(find.byType(ListTile));
Offset radioOffset = tester.getTopLeft(find.byType(Radio<bool>));
Offset secondaryOffset = tester.getTopRight(find.byKey(secondaryKey));
// Leading and trailing widgets are centered vertically in the tile.
final double centerPositionOffsetRadio = (tileHeight / 2) - (radioHeight / 2);
final double centerPositionOffsetSecondary = (tileHeight / 2) - (secondaryHeight / 2);
expect(radioOffset.dy - tileOffset.dy, centerPositionOffsetRadio);
expect(secondaryOffset.dy - tileOffset.dy, centerPositionOffsetSecondary);
// Test [ListTileTitleAlignment.threeLine] alignment.
await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.threeLine));
expect(
find.byWidgetPredicate((Widget widget) {
return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.threeLine;
}),
findsOne,
);
tileOffset = tester.getTopLeft(find.byType(ListTile));
radioOffset = tester.getTopLeft(find.byType(Radio<bool>));
secondaryOffset = tester.getTopRight(find.byKey(secondaryKey));
// Leading and trailing widgets are centered vertically in the tile,
// If the [ListTile.isThreeLine] property is false.
expect(radioOffset.dy - tileOffset.dy, centerPositionOffsetRadio);
expect(secondaryOffset.dy - tileOffset.dy, centerPositionOffsetSecondary);
// Test [ListTileTitleAlignment.titleHeight] alignment.
await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.titleHeight));
expect(
find.byWidgetPredicate((Widget widget) {
return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.titleHeight;
}),
findsOne,
);
tileOffset = tester.getTopLeft(find.byType(ListTile));
radioOffset = tester.getTopLeft(find.byType(Radio<bool>));
secondaryOffset = tester.getTopRight(find.byKey(secondaryKey));
// Leading and trailing widgets are positioned 16.0 pixels below the
// top of the title widget.
const double titlePosition = 16.0;
expect(radioOffset.dy - tileOffset.dy, titlePosition);
expect(secondaryOffset.dy - tileOffset.dy, titlePosition);
// Test [ListTileTitleAlignment.top] alignment.
await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.top));
expect(
find.byWidgetPredicate((Widget widget) {
return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.top;
}),
findsOne,
);
tileOffset = tester.getTopLeft(find.byType(ListTile));
radioOffset = tester.getTopLeft(find.byType(Radio<bool>));
secondaryOffset = tester.getTopRight(find.byKey(secondaryKey));
// Leading and trailing widgets are placed minVerticalPadding below
// the top of the title widget.
const double topPosition = verticalPadding;
expect(radioOffset.dy - tileOffset.dy, topPosition);
expect(secondaryOffset.dy - tileOffset.dy, topPosition);
// Test [ListTileTitleAlignment.center] alignment.
await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.center));
expect(
find.byWidgetPredicate((Widget widget) {
return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.center;
}),
findsOne,
);
tileOffset = tester.getTopLeft(find.byType(ListTile));
radioOffset = tester.getTopLeft(find.byType(Radio<bool>));
secondaryOffset = tester.getTopRight(find.byKey(secondaryKey));
// Leading and trailing widgets are centered vertically in the tile.
expect(radioOffset.dy - tileOffset.dy, centerPositionOffsetRadio);
expect(secondaryOffset.dy - tileOffset.dy, centerPositionOffsetSecondary);
// Test [ListTileTitleAlignment.bottom] alignment.
await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.bottom));
expect(
find.byWidgetPredicate((Widget widget) {
return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.bottom;
}),
findsOne,
);
tileOffset = tester.getTopLeft(find.byType(ListTile));
radioOffset = tester.getTopLeft(find.byType(Radio<bool>));
secondaryOffset = tester.getTopRight(find.byKey(secondaryKey));
// Leading and trailing widgets are placed minVerticalPadding above
// the bottom of the subtitle widget.
final double bottomPositionRadio = tileHeight - verticalPadding - radioHeight;
final double bottomPositionSecondary = tileHeight - verticalPadding - secondaryHeight;
expect(radioOffset.dy - tileOffset.dy, bottomPositionRadio);
expect(secondaryOffset.dy - tileOffset.dy, bottomPositionSecondary);
});
}