From 77c42fbd22fe97ea4ae0524cd3d6641d2e2f5ccb Mon Sep 17 00:00:00 2001 From: Victor Sanni Date: Mon, 14 Apr 2025 21:04:00 -0400 Subject: [PATCH] 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)
Sample code ```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 createState() => _ListTileExampleState(); } class _ListTileExampleState extends State { 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: (_) {}, )), ), ), ); } } ```
--- .../flutter/lib/src/cupertino/list_tile.dart | 70 ++++++++-------- .../test/cupertino/list_tile_test.dart | 82 +++++++++++++++---- 2 files changed, 101 insertions(+), 51 deletions(-) diff --git a/packages/flutter/lib/src/cupertino/list_tile.dart b/packages/flutter/lib/src/cupertino/list_tile.dart index cf0be02ce3e..e14f656da71 100644 --- a/packages/flutter/lib/src/cupertino/list_tile.dart +++ b/packages/flutter/lib/src/cupertino/list_tile.dart @@ -308,7 +308,7 @@ class _CupertinoListTileState extends State { // 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 @@ class _CupertinoListTileState extends State { _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: [ - if (widget.leading case final Widget leading) ...[ - 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: [ - title, - if (widget.subtitle case final Widget subtitle) ...[ - const SizedBox(height: _kNotchedTitleToSubtitle), - DefaultTextStyle( - style: coloredStyle.copyWith( - fontSize: baseType ? _kSubtitleFontSize : _kNotchedSubtitleFontSize, + child: ColoredBox( + color: backgroundColor, + child: Padding( + padding: padding, + child: Row( + children: [ + if (widget.leading case final Widget leading) ...[ + 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: [ + title, + if (widget.subtitle case final Widget subtitle) ...[ + 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) ...[ - DefaultTextStyle(style: coloredStyle, maxLines: 1, child: additionalInfo), - if (widget.trailing != null) const SizedBox(width: _kAdditionalInfoToTrailing), + if (widget.additionalInfo case final Widget additionalInfo) ...[ + 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 559b619781a..3e572cdfa42 100644 --- a/packages/flutter/test/cupertino/list_tile_test.dart +++ b/packages/flutter/test/cupertino/list_tile_test.dart @@ -89,9 +89,10 @@ void main() { ), ); - // Container inside CupertinoListTile is the second one in row. - final Container container = tester.widgetList(find.byType(Container)).elementAt(1); - expect(container.color, backgroundColor); + final ColoredBox coloredBox = tester.widget( + 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 @@ void main() { await tester.tap(find.byType(CupertinoListTile)); await tester.pump(); - // Container inside CupertinoListTile is the second one in row. - final Container container = tester.widgetList(find.byType(Container)).elementAt(1); - expect(container.color, backgroundColor); + final ColoredBox coloredBox = tester.widget( + 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 @@ void main() { ), ); - // Container inside CupertinoListTile is the second one in row. - Container container = tester.widgetList(find.byType(Container)).elementAt(1); - expect(container.color, backgroundColor); + ColoredBox coloredBox = tester.widget( + 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(find.byType(Container)).elementAt(1); - expect(container.color, backgroundColorActivated); + coloredBox = tester.widget( + 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 @@ void main() { ), ); - // Container inside CupertinoListTile is the second one in row. expect(find.byType(GestureDetector), findsNothing); }); @@ -203,7 +206,6 @@ void main() { ), ); - // Container inside CupertinoListTile is the second one in row. expect(find.byType(GestureDetector), findsOneWidget); }); @@ -251,9 +253,10 @@ void main() { await tester.tap(find.byType(CupertinoButton)); await tester.pumpAndSettle(); - // Container inside CupertinoListTile is the second one in row. - final Container container = tester.widget(find.byType(Container)); - expect(container.color, backgroundColor); + final ColoredBox coloredBox = tester.widget( + 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 @@ void main() { 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); + }); }