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>
This commit is contained in:
Victor Sanni 2025-04-14 21:04:00 -04:00 committed by GitHub
parent ca758ac49b
commit 77c42fbd22
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 101 additions and 51 deletions

View File

@ -308,7 +308,7 @@ class _CupertinoListTileState extends State<CupertinoListTile> {
// 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<CupertinoListTile> {
_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!,
],
),
),
),
);

View File

@ -89,9 +89,10 @@ void main() {
),
);
// 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 @@ 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<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 @@ void main() {
),
);
// 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 @@ 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<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 @@ 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);
});
}