Persistent CupertinoListTile leading and trailing (#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](https://github.com/flutter/flutter/issues/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>diff --git a/packages/flutter/lib/src/cupertino/list_tile.dart b/packages/flutter/lib/src/cupertino/list_tile.dart
index cf0be02..e14f656 100644
--- a/packages/flutter/lib/src/cupertino/list_tile.dart
+++ b/packages/flutter/lib/src/cupertino/list_tile.dart
@@ -308,7 +308,7 @@
// null and it will resolve to the correct color provided by context. But if
// the tile was tapped, it is set to what user provided or if null to the
// default color that matched the iOS-style.
- Color? backgroundColor = widget.backgroundColor;
+ Color backgroundColor = widget.backgroundColor ?? CupertinoColors.transparent;
if (_tapped) {
backgroundColor =
widget.backgroundColorActivated ?? CupertinoColors.systemGrey4.resolveFrom(context);
@@ -321,44 +321,46 @@
_CupertinoListTileType.notched => _kNotchedMinHeightWithoutLeading,
};
- final Widget child = Container(
+ final Widget child = ConstrainedBox(
constraints: BoxConstraints(minWidth: double.infinity, minHeight: minHeight),
- color: backgroundColor,
- child: Padding(
- padding: padding,
- child: Row(
- children: <Widget>[
- if (widget.leading case final Widget leading) ...<Widget>[
- SizedBox.square(dimension: widget.leadingSize, child: Center(child: leading)),
- SizedBox(width: widget.leadingToTitle),
- ] else
- SizedBox(height: widget.leadingSize),
- Expanded(
- child: Column(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- crossAxisAlignment: CrossAxisAlignment.start,
- children: <Widget>[
- title,
- if (widget.subtitle case final Widget subtitle) ...<Widget>[
- const SizedBox(height: _kNotchedTitleToSubtitle),
- DefaultTextStyle(
- style: coloredStyle.copyWith(
- fontSize: baseType ? _kSubtitleFontSize : _kNotchedSubtitleFontSize,
+ child: ColoredBox(
+ color: backgroundColor,
+ child: Padding(
+ padding: padding,
+ child: Row(
+ children: <Widget>[
+ if (widget.leading case final Widget leading) ...<Widget>[
+ SizedBox.square(dimension: widget.leadingSize, child: Center(child: leading)),
+ SizedBox(width: widget.leadingToTitle),
+ ] else
+ SizedBox(height: widget.leadingSize),
+ Expanded(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: <Widget>[
+ title,
+ if (widget.subtitle case final Widget subtitle) ...<Widget>[
+ const SizedBox(height: _kNotchedTitleToSubtitle),
+ DefaultTextStyle(
+ style: coloredStyle.copyWith(
+ fontSize: baseType ? _kSubtitleFontSize : _kNotchedSubtitleFontSize,
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ child: subtitle,
),
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- child: subtitle,
- ),
+ ],
],
- ],
+ ),
),
- ),
- if (widget.additionalInfo case final Widget additionalInfo) ...<Widget>[
- DefaultTextStyle(style: coloredStyle, maxLines: 1, child: additionalInfo),
- if (widget.trailing != null) const SizedBox(width: _kAdditionalInfoToTrailing),
+ if (widget.additionalInfo case final Widget additionalInfo) ...<Widget>[
+ DefaultTextStyle(style: coloredStyle, maxLines: 1, child: additionalInfo),
+ if (widget.trailing != null) const SizedBox(width: _kAdditionalInfoToTrailing),
+ ],
+ if (widget.trailing != null) widget.trailing!,
],
- if (widget.trailing != null) widget.trailing!,
- ],
+ ),
),
),
);
diff --git a/packages/flutter/test/cupertino/list_tile_test.dart b/packages/flutter/test/cupertino/list_tile_test.dart
index 559b619..3e572cd 100644
--- a/packages/flutter/test/cupertino/list_tile_test.dart
+++ b/packages/flutter/test/cupertino/list_tile_test.dart
@@ -89,9 +89,10 @@
),
);
- // Container inside CupertinoListTile is the second one in row.
- final Container container = tester.widgetList<Container>(find.byType(Container)).elementAt(1);
- expect(container.color, backgroundColor);
+ final ColoredBox coloredBox = tester.widget<ColoredBox>(
+ find.descendant(of: find.byType(CupertinoListTile), matching: find.byType(ColoredBox)),
+ );
+ expect(coloredBox.color, backgroundColor);
});
testWidgets('does not change backgroundColor when tapped if onTap is not provided', (
@@ -121,9 +122,10 @@
await tester.tap(find.byType(CupertinoListTile));
await tester.pump();
- // Container inside CupertinoListTile is the second one in row.
- final Container container = tester.widgetList<Container>(find.byType(Container)).elementAt(1);
- expect(container.color, backgroundColor);
+ final ColoredBox coloredBox = tester.widget<ColoredBox>(
+ find.descendant(of: find.byType(CupertinoListTile), matching: find.byType(ColoredBox)),
+ );
+ expect(coloredBox.color, backgroundColor);
});
testWidgets('changes backgroundColor when tapped if onTap is provided', (
@@ -153,17 +155,19 @@
),
);
- // Container inside CupertinoListTile is the second one in row.
- Container container = tester.widgetList<Container>(find.byType(Container)).elementAt(1);
- expect(container.color, backgroundColor);
+ ColoredBox coloredBox = tester.widget<ColoredBox>(
+ find.descendant(of: find.byType(CupertinoListTile), matching: find.byType(ColoredBox)),
+ );
+ expect(coloredBox.color, backgroundColor);
// Pump only one frame so the color change persists.
await tester.tap(find.byType(CupertinoListTile));
await tester.pump();
- // Container inside CupertinoListTile is the second one in row.
- container = tester.widgetList<Container>(find.byType(Container)).elementAt(1);
- expect(container.color, backgroundColorActivated);
+ coloredBox = tester.widget<ColoredBox>(
+ find.descendant(of: find.byType(CupertinoListTile), matching: find.byType(ColoredBox)),
+ );
+ expect(coloredBox.color, backgroundColorActivated);
// Pump the rest of the frames to complete the test.
await tester.pumpAndSettle();
@@ -184,7 +188,6 @@
),
);
- // Container inside CupertinoListTile is the second one in row.
expect(find.byType(GestureDetector), findsNothing);
});
@@ -203,7 +206,6 @@
),
);
- // Container inside CupertinoListTile is the second one in row.
expect(find.byType(GestureDetector), findsOneWidget);
});
@@ -251,9 +253,10 @@
await tester.tap(find.byType(CupertinoButton));
await tester.pumpAndSettle();
- // Container inside CupertinoListTile is the second one in row.
- final Container container = tester.widget<Container>(find.byType(Container));
- expect(container.color, backgroundColor);
+ final ColoredBox coloredBox = tester.widget<ColoredBox>(
+ find.descendant(of: find.byType(CupertinoListTile), matching: find.byType(ColoredBox)),
+ );
+ expect(coloredBox.color, backgroundColor);
});
group('alignment of widgets for left-to-right', () {
@@ -494,4 +497,49 @@
expect(tester.takeException(), null);
});
+
+ testWidgets('Leading and trailing animate on listtile long press', (WidgetTester tester) async {
+ bool value = false;
+ await tester.pumpWidget(
+ CupertinoApp(
+ home: CupertinoPageScaffold(
+ child: StatefulBuilder(
+ builder: (BuildContext context, StateSetter setState) {
+ return CupertinoListTile(
+ title: const Text(''),
+ onTap:
+ () => setState(() {
+ value = !value;
+ }),
+ leading: CupertinoSwitch(value: value, onChanged: (_) {}),
+ trailing: CupertinoSwitch(value: value, onChanged: (_) {}),
+ );
+ },
+ ),
+ ),
+ ),
+ );
+
+ final CurvedAnimation firstPosition =
+ (tester.state(find.byType(CupertinoSwitch).first) as dynamic).position as CurvedAnimation;
+ final CurvedAnimation lastPosition =
+ (tester.state(find.byType(CupertinoSwitch).last) as dynamic).position as CurvedAnimation;
+
+ expect(firstPosition.value, 0.0);
+ expect(lastPosition.value, 0.0);
+
+ await tester.longPress(find.byType(CupertinoListTile));
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 65));
+
+ expect(firstPosition.value, greaterThan(0.0));
+ expect(lastPosition.value, greaterThan(0.0));
+
+ expect(firstPosition.value, lessThan(1.0));
+ expect(lastPosition.value, lessThan(1.0));
+
+ await tester.pumpAndSettle();
+ expect(firstPosition.value, 1.0);
+ expect(lastPosition.value, 1.0);
+ });
}