Add ListTileControlAffinity to ListTileTheme (#150695)

fixes https://github.com/flutter/flutter/issues/146414

--- 

I saw @abikko submitted a PR https://github.com/flutter/flutter/pull/146630, but it was not completed due to CLA and lack of test cases.
I am willing to complete this PR in combination with @abikko's code (I don't know if this is allowed) 

 This PR adds contorlAffinity to ListTileTheme so that [CheckboxListTile], [RadioListTile], [SwitchListTile], and [ExpansionTile] can read and use it 

 For example: If ListTileTheme in Theme sets contorlAffinity, then [CheckboxListTile] can directly use contorlAffinity in ListTileTheme. Of course, if contorlAffinity is also set in [CheckboxListTile], the property in [CheckboxListTile] will be used first.
This commit is contained in:
flyboy 2024-07-17 22:06:13 +08:00 committed by GitHub
parent e497ed7db0
commit 0a1d550e5f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 216 additions and 41 deletions

View File

@ -185,7 +185,7 @@ class CheckboxListTile extends StatelessWidget {
this.dense,
this.secondary,
this.selected = false,
this.controlAffinity = ListTileControlAffinity.platform,
this.controlAffinity,
this.contentPadding,
this.tristate = false,
this.checkboxShape,
@ -229,7 +229,7 @@ class CheckboxListTile extends StatelessWidget {
this.dense,
this.secondary,
this.selected = false,
this.controlAffinity = ListTileControlAffinity.platform,
this.controlAffinity,
this.contentPadding,
this.tristate = false,
this.checkboxShape,
@ -403,7 +403,7 @@ class CheckboxListTile extends StatelessWidget {
final bool selected;
/// Where to place the control relative to the text.
final ListTileControlAffinity controlAffinity;
final ListTileControlAffinity? controlAffinity;
/// Defines insets surrounding the tile's contents.
///
@ -518,10 +518,14 @@ class CheckboxListTile extends StatelessWidget {
);
}
final (Widget? leading, Widget? trailing) = switch (controlAffinity) {
final ListTileThemeData listTileTheme = ListTileTheme.of(context);
final ListTileControlAffinity effectiveControlAffinity =
controlAffinity ?? listTileTheme.controlAffinity ?? ListTileControlAffinity.platform;
final (Widget? leading, Widget? trailing) = switch (effectiveControlAffinity) {
ListTileControlAffinity.leading => (control, secondary),
ListTileControlAffinity.trailing || ListTileControlAffinity.platform => (secondary, control),
};
final ThemeData theme = Theme.of(context);
final CheckboxThemeData checkboxTheme = CheckboxTheme.of(context);
final Set<MaterialState> states = <MaterialState>{

View File

@ -660,8 +660,11 @@ class _ExpansionTileState extends State<ExpansionTile> with SingleTickerProvider
}
// Platform or null affinity defaults to trailing.
ListTileControlAffinity _effectiveAffinity(ListTileControlAffinity? affinity) {
switch (affinity ?? ListTileControlAffinity.trailing) {
ListTileControlAffinity _effectiveAffinity() {
final ListTileThemeData listTileTheme = ListTileTheme.of(context);
final ListTileControlAffinity affinity =
widget.controlAffinity ?? listTileTheme.controlAffinity ?? ListTileControlAffinity.trailing;
switch (affinity) {
case ListTileControlAffinity.leading:
return ListTileControlAffinity.leading;
case ListTileControlAffinity.trailing:
@ -678,14 +681,14 @@ class _ExpansionTileState extends State<ExpansionTile> with SingleTickerProvider
}
Widget? _buildLeadingIcon(BuildContext context) {
if (_effectiveAffinity(widget.controlAffinity) != ListTileControlAffinity.leading) {
if (_effectiveAffinity() != ListTileControlAffinity.leading) {
return null;
}
return _buildIcon(context);
}
Widget? _buildTrailingIcon(BuildContext context) {
if (_effectiveAffinity(widget.controlAffinity) != ListTileControlAffinity.trailing) {
if (_effectiveAffinity() != ListTileControlAffinity.trailing) {
return null;
}
return _buildIcon(context);

View File

@ -65,6 +65,7 @@ class ListTileThemeData with Diagnosticable {
this.visualDensity,
this.minTileHeight,
this.titleAlignment,
this.controlAffinity,
});
/// Overrides the default value of [ListTile.dense].
@ -127,6 +128,10 @@ class ListTileThemeData with Diagnosticable {
/// If specified, overrides the default value of [ListTile.titleAlignment].
final ListTileTitleAlignment? titleAlignment;
/// If specified, overrides the default value of [CheckboxListTile.controlAffinity]
/// or [ExpansionTile.controlAffinity] or [SwitchListTile.controlAffinity] or [RadioListTile.controlAffinity].
final ListTileControlAffinity? controlAffinity;
/// Creates a copy of this object with the given fields replaced with the
/// new values.
ListTileThemeData copyWith({
@ -151,6 +156,7 @@ class ListTileThemeData with Diagnosticable {
bool? isThreeLine,
VisualDensity? visualDensity,
ListTileTitleAlignment? titleAlignment,
ListTileControlAffinity? controlAffinity,
}) {
return ListTileThemeData(
dense: dense ?? this.dense,
@ -173,6 +179,7 @@ class ListTileThemeData with Diagnosticable {
mouseCursor: mouseCursor ?? this.mouseCursor,
visualDensity: visualDensity ?? this.visualDensity,
titleAlignment: titleAlignment ?? this.titleAlignment,
controlAffinity: controlAffinity ?? this.controlAffinity,
);
}
@ -202,32 +209,36 @@ class ListTileThemeData with Diagnosticable {
mouseCursor: t < 0.5 ? a?.mouseCursor : b?.mouseCursor,
visualDensity: t < 0.5 ? a?.visualDensity : b?.visualDensity,
titleAlignment: t < 0.5 ? a?.titleAlignment : b?.titleAlignment,
controlAffinity: t < 0.5 ? a?.controlAffinity : b?.controlAffinity,
);
}
@override
int get hashCode => Object.hash(
dense,
shape,
style,
selectedColor,
iconColor,
textColor,
titleTextStyle,
subtitleTextStyle,
leadingAndTrailingTextStyle,
contentPadding,
tileColor,
selectedTileColor,
horizontalTitleGap,
minVerticalPadding,
minLeadingWidth,
minTileHeight,
enableFeedback,
mouseCursor,
visualDensity,
titleAlignment,
);
int get hashCode => Object.hashAll(
<Object?>[
dense,
shape,
style,
selectedColor,
iconColor,
textColor,
titleTextStyle,
subtitleTextStyle,
leadingAndTrailingTextStyle,
contentPadding,
tileColor,
selectedTileColor,
horizontalTitleGap,
minVerticalPadding,
minLeadingWidth,
minTileHeight,
enableFeedback,
mouseCursor,
visualDensity,
titleAlignment,
controlAffinity,
],
);
@override
bool operator ==(Object other) {
@ -257,7 +268,8 @@ class ListTileThemeData with Diagnosticable {
&& other.enableFeedback == enableFeedback
&& other.mouseCursor == mouseCursor
&& other.visualDensity == visualDensity
&& other.titleAlignment == titleAlignment;
&& other.titleAlignment == titleAlignment
&& other.controlAffinity == controlAffinity;
}
@override
@ -283,6 +295,7 @@ class ListTileThemeData with Diagnosticable {
properties.add(DiagnosticsProperty<MaterialStateProperty<MouseCursor?>>('mouseCursor', mouseCursor, defaultValue: null));
properties.add(DiagnosticsProperty<VisualDensity>('visualDensity', visualDensity, defaultValue: null));
properties.add(DiagnosticsProperty<ListTileTitleAlignment>('titleAlignment', titleAlignment, defaultValue: null));
properties.add(DiagnosticsProperty<ListTileControlAffinity>('controlAffinity', controlAffinity, defaultValue: null));
}
}
@ -317,6 +330,7 @@ class ListTileTheme extends InheritedTheme {
double? horizontalTitleGap,
double? minVerticalPadding,
double? minLeadingWidth,
ListTileControlAffinity? controlAffinity,
required super.child,
}) : assert(
data == null ||
@ -331,7 +345,8 @@ class ListTileTheme extends InheritedTheme {
mouseCursor ??
horizontalTitleGap ??
minVerticalPadding ??
minLeadingWidth) == null),
minLeadingWidth ??
controlAffinity) == null),
_data = data,
_dense = dense,
_shape = shape,
@ -346,7 +361,8 @@ class ListTileTheme extends InheritedTheme {
_mouseCursor = mouseCursor,
_horizontalTitleGap = horizontalTitleGap,
_minVerticalPadding = minVerticalPadding,
_minLeadingWidth = minLeadingWidth;
_minLeadingWidth = minLeadingWidth,
_controlAffinity = controlAffinity;
final ListTileThemeData? _data;
final bool? _dense;
@ -363,6 +379,7 @@ class ListTileTheme extends InheritedTheme {
final double? _minLeadingWidth;
final bool? _enableFeedback;
final MaterialStateProperty<MouseCursor?>? _mouseCursor;
final ListTileControlAffinity? _controlAffinity;
/// The configuration of this theme.
ListTileThemeData get data {
@ -381,6 +398,7 @@ class ListTileTheme extends InheritedTheme {
horizontalTitleGap: _horizontalTitleGap,
minVerticalPadding: _minVerticalPadding,
minLeadingWidth: _minLeadingWidth,
controlAffinity: _controlAffinity,
);
}
@ -462,6 +480,13 @@ class ListTileTheme extends InheritedTheme {
/// [ListTileThemeData.enableFeedback] property instead.
bool? get enableFeedback => _data != null ? _data.enableFeedback : _enableFeedback;
/// Overrides the default value of [CheckboxListTile.controlAffinity]
/// or [ExpansionTile.controlAffinity] or [SwitchListTile.controlAffinity] or [RadioListTile.controlAffinity]
///
/// This property is obsolete: please use the
/// [ListTileThemeData.controlAffinity] property instead.
ListTileControlAffinity? get controlAffinity => _data != null ? _data.controlAffinity : _controlAffinity;
/// The [data] property of the closest instance of this class that
/// encloses the given context.
///
@ -502,6 +527,7 @@ class ListTileTheme extends InheritedTheme {
ListTileTitleAlignment? titleAlignment,
MaterialStateProperty<MouseCursor?>? mouseCursor,
VisualDensity? visualDensity,
ListTileControlAffinity? controlAffinity,
required Widget child,
}) {
return Builder(
@ -530,6 +556,7 @@ class ListTileTheme extends InheritedTheme {
titleAlignment: titleAlignment ?? parent.titleAlignment,
mouseCursor: mouseCursor ?? parent.mouseCursor,
visualDensity: visualDensity ?? parent.visualDensity,
controlAffinity: controlAffinity ?? parent.controlAffinity,
),
child: child,
);

View File

@ -178,7 +178,7 @@ class RadioListTile<T> extends StatelessWidget {
this.dense,
this.secondary,
this.selected = false,
this.controlAffinity = ListTileControlAffinity.platform,
this.controlAffinity,
this.autofocus = false,
this.contentPadding,
this.shape,
@ -217,7 +217,7 @@ class RadioListTile<T> extends StatelessWidget {
this.dense,
this.secondary,
this.selected = false,
this.controlAffinity = ListTileControlAffinity.platform,
this.controlAffinity,
this.autofocus = false,
this.contentPadding,
this.shape,
@ -389,7 +389,7 @@ class RadioListTile<T> extends StatelessWidget {
final bool selected;
/// Where to place the control relative to the text.
final ListTileControlAffinity controlAffinity;
final ListTileControlAffinity? controlAffinity;
/// {@macro flutter.widgets.Focus.autofocus}
final bool autofocus;
@ -488,11 +488,15 @@ class RadioListTile<T> extends StatelessWidget {
);
}
final ListTileThemeData listTileTheme = ListTileTheme.of(context);
final ListTileControlAffinity effectiveControlAffinity =
controlAffinity ?? listTileTheme.controlAffinity ?? ListTileControlAffinity.platform;
Widget? leading, trailing;
(leading, trailing) = switch (controlAffinity) {
(leading, trailing) = switch (effectiveControlAffinity) {
ListTileControlAffinity.leading || ListTileControlAffinity.platform => (control, secondary),
ListTileControlAffinity.trailing => (secondary, control),
};
final ThemeData theme = Theme.of(context);
final RadioThemeData radioThemeData = RadioTheme.of(context);
final Set<MaterialState> states = <MaterialState>{

View File

@ -192,7 +192,7 @@ class SwitchListTile extends StatelessWidget {
this.contentPadding,
this.secondary,
this.selected = false,
this.controlAffinity = ListTileControlAffinity.platform,
this.controlAffinity,
this.shape,
this.selectedTileColor,
this.visualDensity,
@ -249,7 +249,7 @@ class SwitchListTile extends StatelessWidget {
this.contentPadding,
this.secondary,
this.selected = false,
this.controlAffinity = ListTileControlAffinity.platform,
this.controlAffinity,
this.shape,
this.selectedTileColor,
this.visualDensity,
@ -480,7 +480,7 @@ class SwitchListTile extends StatelessWidget {
/// Defines the position of control and [secondary], relative to text.
///
/// By default, the value of [controlAffinity] is [ListTileControlAffinity.platform].
final ListTileControlAffinity controlAffinity;
final ListTileControlAffinity? controlAffinity;
/// {@macro flutter.material.ListTile.shape}
final ShapeBorder? shape;
@ -566,8 +566,11 @@ class SwitchListTile extends StatelessWidget {
);
}
final ListTileThemeData listTileTheme = ListTileTheme.of(context);
final ListTileControlAffinity effectiveControlAffinity =
controlAffinity ?? listTileTheme.controlAffinity ?? ListTileControlAffinity.platform;
Widget? leading, trailing;
(leading, trailing) = switch (controlAffinity) {
(leading, trailing) = switch (effectiveControlAffinity) {
ListTileControlAffinity.leading => (control, secondary),
ListTileControlAffinity.trailing || ListTileControlAffinity.platform => (secondary, control),
};

View File

@ -1231,6 +1231,39 @@ void main() {
expect(Focus.of(firstChildKey.currentContext!).hasPrimaryFocus, isFalse);
expect(Focus.of(secondChildKey.currentContext!).hasPrimaryFocus, isTrue);
});
testWidgets('CheckboxListTile uses ListTileTheme controlAffinity', (WidgetTester tester) async {
Widget buildListTile(ListTileControlAffinity controlAffinity) {
return MaterialApp(
home: Material(
child: ListTileTheme(
data: ListTileThemeData(
controlAffinity: controlAffinity,
),
child: CheckboxListTile(
value: false,
onChanged: (bool? value) {},
),
),
),
);
}
await tester.pumpWidget(buildListTile(ListTileControlAffinity.trailing));
final Finder trailing = find.byType(Checkbox);
final Offset offsetTrailing = tester.getTopLeft(trailing);
expect(offsetTrailing, const Offset(736.0, 8.0));
await tester.pumpWidget(buildListTile(ListTileControlAffinity.leading));
final Finder leading = find.byType(Checkbox);
final Offset offsetLeading = tester.getTopLeft(leading);
expect(offsetLeading, const Offset(16.0, 8.0));
await tester.pumpWidget(buildListTile(ListTileControlAffinity.platform));
final Finder platform = find.byType(Checkbox);
final Offset offsetPlatform = tester.getTopLeft(platform);
expect(offsetPlatform, const Offset(736.0, 8.0));
});
}
class _SelectedGrabMouseCursor extends MaterialStateMouseCursor {

View File

@ -1585,4 +1585,36 @@ void main() {
expect(titleSize.width, materialAppSize.width - 32.0);
});
testWidgets('ExpansionTile uses ListTileTheme controlAffinity', (WidgetTester tester) async {
Widget buildView(ListTileControlAffinity controlAffinity) {
return MaterialApp(
home: ListTileTheme(
data: ListTileThemeData(
controlAffinity: controlAffinity,
),
child: const Material(
child: ExpansionTile(
title: Text('ExpansionTile'),
),
),
),
);
}
await tester.pumpWidget(buildView(ListTileControlAffinity.leading));
final Finder leading = find.text('ExpansionTile');
final Offset offsetLeading = tester.getTopLeft(leading);
expect(offsetLeading, const Offset(56.0, 17.0));
await tester.pumpWidget(buildView(ListTileControlAffinity.trailing));
final Finder trailing = find.text('ExpansionTile');
final Offset offsetTrailing = tester.getTopLeft(trailing);
expect(offsetTrailing, const Offset(16.0, 17.0));
await tester.pumpWidget(buildView(ListTileControlAffinity.platform));
final Finder platform = find.text('ExpansionTile');
final Offset offsetPlatform = tester.getTopLeft(platform);
expect(offsetPlatform, const Offset(16.0, 17.0));
});
}

View File

@ -1547,4 +1547,39 @@ void main() {
);
});
});
testWidgets('RadioListTile uses ListTileTheme controlAffinity', (WidgetTester tester) async {
Widget buildListTile(ListTileControlAffinity controlAffinity) {
return MaterialApp(
home: Material(
child: ListTileTheme(
data: ListTileThemeData(
controlAffinity: controlAffinity,
),
child: RadioListTile<double>(
value: 0.5,
groupValue: 1.0,
title: const Text('RadioListTile'),
onChanged: (double? value) {},
),
),
),
);
}
await tester.pumpWidget(buildListTile(ListTileControlAffinity.leading));
final Finder leading = find.text('RadioListTile');
final Offset offsetLeading = tester.getTopLeft(leading);
expect(offsetLeading, const Offset(72.0, 16.0));
await tester.pumpWidget(buildListTile(ListTileControlAffinity.trailing));
final Finder trailing = find.text('RadioListTile');
final Offset offsetTrailing = tester.getTopLeft(trailing);
expect(offsetTrailing, const Offset(16.0, 16.0));
await tester.pumpWidget(buildListTile(ListTileControlAffinity.platform));
final Finder platform = find.text('RadioListTile');
final Offset offsetPlatform = tester.getTopLeft(platform);
expect(offsetPlatform, const Offset(72.0, 16.0));
});
}

View File

@ -1691,4 +1691,38 @@ void main() {
expect(Focus.of(firstChildKey.currentContext!).hasPrimaryFocus, isFalse);
expect(Focus.of(secondChildKey.currentContext!).hasPrimaryFocus, isTrue);
});
testWidgets('SwitchListTile uses ListTileTheme controlAffinity', (WidgetTester tester) async {
Widget buildView(ListTileControlAffinity controlAffinity) {
return MaterialApp(
home: Material(
child: ListTileTheme(
data: ListTileThemeData(
controlAffinity: controlAffinity,
),
child: SwitchListTile(
value: true,
title: const Text('SwitchListTile'),
onChanged: (bool value) {},
),
),
),
);
}
await tester.pumpWidget(buildView(ListTileControlAffinity.leading));
final Finder leading = find.text('SwitchListTile');
final Offset offsetLeading = tester.getTopLeft(leading);
expect(offsetLeading, const Offset(92.0, 16.0));
await tester.pumpWidget(buildView(ListTileControlAffinity.trailing));
final Finder trailing = find.text('SwitchListTile');
final Offset offsetTrailing = tester.getTopLeft(trailing);
expect(offsetTrailing, const Offset(16.0, 16.0));
await tester.pumpWidget(buildView(ListTileControlAffinity.platform));
final Finder platform = find.text('SwitchListTile');
final Offset offsetPlatform = tester.getTopLeft(platform);
expect(offsetPlatform, const Offset(16.0, 16.0));
});
}