Skip to content

Commit 77c42fb

Browse files
authored
Persistent CupertinoListTile leading and trailing (flutter#166799)
The bug occurs because the background color passed into the underlying `Container` changes from null to `backgroundColorActivated`. Under the hood, the `Container` wraps its child in a `ColoredBox` only if the color is non-null. So the extra wrapping with a `ColoredBox` happening mid-animation breaks reparenting, causing the widgets to be replaced/inflated instead of updated. ### Before https://github.com/user-attachments/assets/ca0b657a-1340-405f-8c1d-34b34366b994 ### After https://github.com/user-attachments/assets/8445c55c-0d5d-4b5f-96d2-4f12d908bdec Fixes [CupertinoListTile animations are not running when pressing longer](flutter#153225) <details> <summary>Sample code</summary> ```dart import 'package:flutter/cupertino.dart'; void main() => runApp(const ListTileApp()); class ListTileApp extends StatelessWidget { const ListTileApp({super.key}); @OverRide Widget build(BuildContext context) { return CupertinoApp(home: const ListTileExample()); } } class ListTileExample extends StatefulWidget { const ListTileExample({super.key}); @OverRide State<ListTileExample> createState() => _ListTileExampleState(); } class _ListTileExampleState extends State<ListTileExample> { bool _pushedToggle = false; void _toggle() { setState(() { _pushedToggle = !_pushedToggle; }); } @OverRide Widget build(BuildContext context) { return CupertinoPageScaffold( child: Center( child: SizedBox( height: 40, child: CupertinoListTile( onTap: _toggle, title: Center( child: Text( 'Toggle', ), ), leading: CupertinoSwitch( value: _pushedToggle, onChanged: (_) {}, ), trailing: CupertinoSwitch( value: _pushedToggle, onChanged: (_) {}, )), ), ), ); } } ``` </details>
1 parent ca758ac commit 77c42fb

File tree

2 files changed

+101
-51
lines changed

2 files changed

+101
-51
lines changed

packages/flutter/lib/src/cupertino/list_tile.dart

Lines changed: 36 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ class _CupertinoListTileState extends State<CupertinoListTile> {
308308
// null and it will resolve to the correct color provided by context. But if
309309
// the tile was tapped, it is set to what user provided or if null to the
310310
// default color that matched the iOS-style.
311-
Color? backgroundColor = widget.backgroundColor;
311+
Color backgroundColor = widget.backgroundColor ?? CupertinoColors.transparent;
312312
if (_tapped) {
313313
backgroundColor =
314314
widget.backgroundColorActivated ?? CupertinoColors.systemGrey4.resolveFrom(context);
@@ -321,44 +321,46 @@ class _CupertinoListTileState extends State<CupertinoListTile> {
321321
_CupertinoListTileType.notched => _kNotchedMinHeightWithoutLeading,
322322
};
323323

324-
final Widget child = Container(
324+
final Widget child = ConstrainedBox(
325325
constraints: BoxConstraints(minWidth: double.infinity, minHeight: minHeight),
326-
color: backgroundColor,
327-
child: Padding(
328-
padding: padding,
329-
child: Row(
330-
children: <Widget>[
331-
if (widget.leading case final Widget leading) ...<Widget>[
332-
SizedBox.square(dimension: widget.leadingSize, child: Center(child: leading)),
333-
SizedBox(width: widget.leadingToTitle),
334-
] else
335-
SizedBox(height: widget.leadingSize),
336-
Expanded(
337-
child: Column(
338-
mainAxisAlignment: MainAxisAlignment.spaceBetween,
339-
crossAxisAlignment: CrossAxisAlignment.start,
340-
children: <Widget>[
341-
title,
342-
if (widget.subtitle case final Widget subtitle) ...<Widget>[
343-
const SizedBox(height: _kNotchedTitleToSubtitle),
344-
DefaultTextStyle(
345-
style: coloredStyle.copyWith(
346-
fontSize: baseType ? _kSubtitleFontSize : _kNotchedSubtitleFontSize,
326+
child: ColoredBox(
327+
color: backgroundColor,
328+
child: Padding(
329+
padding: padding,
330+
child: Row(
331+
children: <Widget>[
332+
if (widget.leading case final Widget leading) ...<Widget>[
333+
SizedBox.square(dimension: widget.leadingSize, child: Center(child: leading)),
334+
SizedBox(width: widget.leadingToTitle),
335+
] else
336+
SizedBox(height: widget.leadingSize),
337+
Expanded(
338+
child: Column(
339+
mainAxisAlignment: MainAxisAlignment.spaceBetween,
340+
crossAxisAlignment: CrossAxisAlignment.start,
341+
children: <Widget>[
342+
title,
343+
if (widget.subtitle case final Widget subtitle) ...<Widget>[
344+
const SizedBox(height: _kNotchedTitleToSubtitle),
345+
DefaultTextStyle(
346+
style: coloredStyle.copyWith(
347+
fontSize: baseType ? _kSubtitleFontSize : _kNotchedSubtitleFontSize,
348+
),
349+
maxLines: 1,
350+
overflow: TextOverflow.ellipsis,
351+
child: subtitle,
347352
),
348-
maxLines: 1,
349-
overflow: TextOverflow.ellipsis,
350-
child: subtitle,
351-
),
353+
],
352354
],
353-
],
355+
),
354356
),
355-
),
356-
if (widget.additionalInfo case final Widget additionalInfo) ...<Widget>[
357-
DefaultTextStyle(style: coloredStyle, maxLines: 1, child: additionalInfo),
358-
if (widget.trailing != null) const SizedBox(width: _kAdditionalInfoToTrailing),
357+
if (widget.additionalInfo case final Widget additionalInfo) ...<Widget>[
358+
DefaultTextStyle(style: coloredStyle, maxLines: 1, child: additionalInfo),
359+
if (widget.trailing != null) const SizedBox(width: _kAdditionalInfoToTrailing),
360+
],
361+
if (widget.trailing != null) widget.trailing!,
359362
],
360-
if (widget.trailing != null) widget.trailing!,
361-
],
363+
),
362364
),
363365
),
364366
);

packages/flutter/test/cupertino/list_tile_test.dart

Lines changed: 65 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,10 @@ void main() {
8989
),
9090
);
9191

92-
// Container inside CupertinoListTile is the second one in row.
93-
final Container container = tester.widgetList<Container>(find.byType(Container)).elementAt(1);
94-
expect(container.color, backgroundColor);
92+
final ColoredBox coloredBox = tester.widget<ColoredBox>(
93+
find.descendant(of: find.byType(CupertinoListTile), matching: find.byType(ColoredBox)),
94+
);
95+
expect(coloredBox.color, backgroundColor);
9596
});
9697

9798
testWidgets('does not change backgroundColor when tapped if onTap is not provided', (
@@ -121,9 +122,10 @@ void main() {
121122
await tester.tap(find.byType(CupertinoListTile));
122123
await tester.pump();
123124

124-
// Container inside CupertinoListTile is the second one in row.
125-
final Container container = tester.widgetList<Container>(find.byType(Container)).elementAt(1);
126-
expect(container.color, backgroundColor);
125+
final ColoredBox coloredBox = tester.widget<ColoredBox>(
126+
find.descendant(of: find.byType(CupertinoListTile), matching: find.byType(ColoredBox)),
127+
);
128+
expect(coloredBox.color, backgroundColor);
127129
});
128130

129131
testWidgets('changes backgroundColor when tapped if onTap is provided', (
@@ -153,17 +155,19 @@ void main() {
153155
),
154156
);
155157

156-
// Container inside CupertinoListTile is the second one in row.
157-
Container container = tester.widgetList<Container>(find.byType(Container)).elementAt(1);
158-
expect(container.color, backgroundColor);
158+
ColoredBox coloredBox = tester.widget<ColoredBox>(
159+
find.descendant(of: find.byType(CupertinoListTile), matching: find.byType(ColoredBox)),
160+
);
161+
expect(coloredBox.color, backgroundColor);
159162

160163
// Pump only one frame so the color change persists.
161164
await tester.tap(find.byType(CupertinoListTile));
162165
await tester.pump();
163166

164-
// Container inside CupertinoListTile is the second one in row.
165-
container = tester.widgetList<Container>(find.byType(Container)).elementAt(1);
166-
expect(container.color, backgroundColorActivated);
167+
coloredBox = tester.widget<ColoredBox>(
168+
find.descendant(of: find.byType(CupertinoListTile), matching: find.byType(ColoredBox)),
169+
);
170+
expect(coloredBox.color, backgroundColorActivated);
167171

168172
// Pump the rest of the frames to complete the test.
169173
await tester.pumpAndSettle();
@@ -184,7 +188,6 @@ void main() {
184188
),
185189
);
186190

187-
// Container inside CupertinoListTile is the second one in row.
188191
expect(find.byType(GestureDetector), findsNothing);
189192
});
190193

@@ -203,7 +206,6 @@ void main() {
203206
),
204207
);
205208

206-
// Container inside CupertinoListTile is the second one in row.
207209
expect(find.byType(GestureDetector), findsOneWidget);
208210
});
209211

@@ -251,9 +253,10 @@ void main() {
251253
await tester.tap(find.byType(CupertinoButton));
252254
await tester.pumpAndSettle();
253255

254-
// Container inside CupertinoListTile is the second one in row.
255-
final Container container = tester.widget<Container>(find.byType(Container));
256-
expect(container.color, backgroundColor);
256+
final ColoredBox coloredBox = tester.widget<ColoredBox>(
257+
find.descendant(of: find.byType(CupertinoListTile), matching: find.byType(ColoredBox)),
258+
);
259+
expect(coloredBox.color, backgroundColor);
257260
});
258261

259262
group('alignment of widgets for left-to-right', () {
@@ -494,4 +497,49 @@ void main() {
494497

495498
expect(tester.takeException(), null);
496499
});
500+
501+
testWidgets('Leading and trailing animate on listtile long press', (WidgetTester tester) async {
502+
bool value = false;
503+
await tester.pumpWidget(
504+
CupertinoApp(
505+
home: CupertinoPageScaffold(
506+
child: StatefulBuilder(
507+
builder: (BuildContext context, StateSetter setState) {
508+
return CupertinoListTile(
509+
title: const Text(''),
510+
onTap:
511+
() => setState(() {
512+
value = !value;
513+
}),
514+
leading: CupertinoSwitch(value: value, onChanged: (_) {}),
515+
trailing: CupertinoSwitch(value: value, onChanged: (_) {}),
516+
);
517+
},
518+
),
519+
),
520+
),
521+
);
522+
523+
final CurvedAnimation firstPosition =
524+
(tester.state(find.byType(CupertinoSwitch).first) as dynamic).position as CurvedAnimation;
525+
final CurvedAnimation lastPosition =
526+
(tester.state(find.byType(CupertinoSwitch).last) as dynamic).position as CurvedAnimation;
527+
528+
expect(firstPosition.value, 0.0);
529+
expect(lastPosition.value, 0.0);
530+
531+
await tester.longPress(find.byType(CupertinoListTile));
532+
await tester.pump();
533+
await tester.pump(const Duration(milliseconds: 65));
534+
535+
expect(firstPosition.value, greaterThan(0.0));
536+
expect(lastPosition.value, greaterThan(0.0));
537+
538+
expect(firstPosition.value, lessThan(1.0));
539+
expect(lastPosition.value, lessThan(1.0));
540+
541+
await tester.pumpAndSettle();
542+
expect(firstPosition.value, 1.0);
543+
expect(lastPosition.value, 1.0);
544+
});
497545
}

0 commit comments

Comments
 (0)