Add warning when there is a widget with color between `Material` and `ListTile` (#181402)

Fixes https://github.com/flutter/flutter/issues/174366

This PR is to throw exception when there is a widget with a
non-transparent color (like a `Container(color: Colors.pink)`) between
`ListTile` and `Material`. The fix is based on
[comment](https://github.com/flutter/flutter/pull/174858#issuecomment-3375254554)
in the original fix PR which has been closed.

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

**Note**: The Flutter team is currently trialing the use of [Gemini Code
Assist for
GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code).
Comments from the `gemini-code-assist` bot should not be taken as
authoritative feedback from the Flutter team. If you find its comments
useful you can update your code accordingly, but if you are unsure or
disagree with the feedback, please feel free to wait for a Flutter team
member's review for guidance on which automated comments should be
addressed.

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
diff --git a/packages/flutter/lib/src/material/expansion_tile.dart b/packages/flutter/lib/src/material/expansion_tile.dart
index f638f12..e3e1e53 100644
--- a/packages/flutter/lib/src/material/expansion_tile.dart
+++ b/packages/flutter/lib/src/material/expansion_tile.dart
@@ -693,7 +693,7 @@
       shape: expansionTileBorder,
     );
 
-    final Widget tile = Padding(
+    Widget tile = Padding(
       padding: decoration.padding,
       child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[header, body]),
     );
@@ -713,6 +713,14 @@
       );
     }
 
+    // If the background color is not transparent, wrap the tile in a Material widget.
+    // This is needed to ensure that the ListTile background color or ink splashes
+    // are visible. A DecoratedBox with a non-transparent color will hide the
+    // background color or ink splashes of the ListTile.
+    if (backgroundColor.a > 0) {
+      tile = Material(type: MaterialType.transparency, child: tile);
+    }
+
     return DecoratedBox(decoration: decoration, child: tile);
   }
 
diff --git a/packages/flutter/lib/src/material/list_tile.dart b/packages/flutter/lib/src/material/list_tile.dart
index 7ea6003..7d388f1 100644
--- a/packages/flutter/lib/src/material/list_tile.dart
+++ b/packages/flutter/lib/src/material/list_tile.dart
@@ -31,6 +31,7 @@
 import 'ink_decoration.dart';
 import 'ink_well.dart';
 import 'list_tile_theme.dart';
+import 'material.dart';
 import 'material_state.dart';
 import 'text_theme.dart';
 import 'theme.dart';
@@ -807,17 +808,6 @@
     return dense ?? tileTheme.dense ?? theme.listTileTheme.dense ?? false;
   }
 
-  Color _tileBackgroundColor(
-    ThemeData theme,
-    ListTileThemeData tileTheme,
-    ListTileThemeData defaults,
-  ) {
-    final Color? color = selected
-        ? selectedTileColor ?? tileTheme.selectedTileColor ?? theme.listTileTheme.selectedTileColor
-        : tileColor ?? tileTheme.tileColor ?? theme.listTileTheme.tileColor;
-    return color ?? defaults.tileColor!;
-  }
-
   @override
   Widget build(BuildContext context) {
     assert(debugCheckHasMaterial(context));
@@ -829,6 +819,19 @@
     final ListTileThemeData defaults = theme.useMaterial3
         ? _LisTileDefaultsM3(context)
         : _LisTileDefaultsM2(context, listTileStyle);
+
+    final Color backgroundColor =
+        tileColor ?? tileTheme.tileColor ?? theme.listTileTheme.tileColor ?? defaults.tileColor!;
+    final Color selectedBackgroundColor =
+        selectedTileColor ??
+        tileTheme.selectedTileColor ??
+        theme.listTileTheme.selectedTileColor ??
+        defaults.tileColor!;
+    final effectiveTileColor = selected ? selectedBackgroundColor : backgroundColor;
+    final bool hasOpaqueBackground = backgroundColor.alpha > 0 || selectedBackgroundColor.alpha > 0;
+    if (onTap != null || onLongPress != null || hasOpaqueBackground) {
+      assert(_debugCheckBackgroundIsHidden(context));
+    }
     final states = <WidgetState>{
       if (!enabled) WidgetState.disabled,
       if (selected) WidgetState.selected,
@@ -996,7 +999,7 @@
         child: Ink(
           decoration: ShapeDecoration(
             shape: shape ?? tileTheme.shape ?? const Border(),
-            color: _tileBackgroundColor(theme, tileTheme, defaults),
+            color: effectiveTileColor,
           ),
           child: SafeArea(
             top: false,
@@ -1140,6 +1143,62 @@
       ),
     );
   }
+
+  bool _debugCheckBackgroundIsHidden(BuildContext context) {
+    assert(() {
+      final Widget? intermediateWidget = _findIntermediateWidget(context);
+      if (intermediateWidget != null) {
+        FlutterError.reportError(
+          FlutterErrorDetails(
+            exception: FlutterError.fromParts(<DiagnosticsNode>[
+              ErrorSummary('ListTile background color or ink splashes may be invisible.'),
+              ErrorDescription(
+                'The ListTile is wrapped in a ${intermediateWidget.runtimeType} that has a background color. '
+                'Because ListTile paints its background and ink splashes on the nearest Material ancestor, '
+                'this ${intermediateWidget.runtimeType} will hide those effects.',
+              ),
+              ErrorHint(
+                'To fix this, wrap the ListTile in its own Material widget, '
+                'or remove the background color from the intermediate ${intermediateWidget.runtimeType}.',
+              ),
+            ]),
+            informationCollector: () => <DiagnosticsNode>[
+              DiagnosticsProperty<ListTile>('ListTile', this, expandableValue: true),
+              DiagnosticsProperty<Widget>(
+                '${intermediateWidget.runtimeType}',
+                intermediateWidget,
+                expandableValue: true,
+              ),
+            ],
+          ),
+        );
+      }
+      return true;
+    }());
+    return true;
+  }
+
+  Widget? _findIntermediateWidget(BuildContext context) {
+    Widget? intermediateWidget;
+    (context as Element).visitAncestorElements((Element ancestor) {
+      if (ancestor.widget is Material) {
+        return false;
+      }
+      final Widget widget = ancestor.widget;
+      final Color? color = switch (widget) {
+        ColoredBox(:final Color color) => color,
+        DecoratedBox(decoration: BoxDecoration(:final Color? color)) => color,
+        DecoratedBox(decoration: ShapeDecoration(:final Color? color)) => color,
+        _ => null,
+      };
+      if (color != null && color.a > 0) {
+        intermediateWidget = widget;
+        return false;
+      }
+      return true;
+    });
+    return intermediateWidget;
+  }
 }
 
 class _IndividualOverrides extends WidgetStateProperty<Color?> {
diff --git a/packages/flutter/test/material/expansion_tile_test.dart b/packages/flutter/test/material/expansion_tile_test.dart
index 32fbe61..44bd9fd 100644
--- a/packages/flutter/test/material/expansion_tile_test.dart
+++ b/packages/flutter/test/material/expansion_tile_test.dart
@@ -7,9 +7,12 @@
 @Tags(<String>['reduced-test-set'])
 library;
 
+import 'dart:ui';
+
 import 'package:flutter/foundation.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/rendering.dart';
+import 'package:flutter/services.dart';
 import 'package:flutter_test/flutter_test.dart';
 
 class TestIcon extends StatefulWidget {
@@ -702,6 +705,74 @@
     expect(getTextColor(), textColor);
   });
 
+  testWidgets('ExpansionTile shows hover color with opaque backgroundColor', (
+    WidgetTester tester,
+  ) async {
+    const Color hoverColor = Colors.green; // Green
+    const Color backgroundColor = Colors.blue; // Blue
+
+    await tester.pumpWidget(
+      MaterialApp(
+        theme: ThemeData(hoverColor: hoverColor),
+        home: const Material(
+          child: ExpansionTile(title: Text('Title'), collapsedBackgroundColor: backgroundColor),
+        ),
+      ),
+    );
+
+    final Finder materialFinder = find.descendant(
+      of: find.byType(ExpansionTile),
+      matching: find.byType(Material),
+    );
+
+    // Hover the tile.
+    final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
+    await gesture.addPointer(location: Offset.zero);
+    addTearDown(gesture.removePointer);
+    await tester.pump();
+    await gesture.moveTo(tester.getCenter(find.text('Title')));
+    await tester.pumpAndSettle();
+
+    expect(
+      materialFinder,
+      paints
+        ..rect(color: Colors.transparent)
+        ..rect(color: hoverColor),
+    );
+  });
+
+  testWidgets('ExpansionTile shows focus color with opaque backgroundColor', (
+    WidgetTester tester,
+  ) async {
+    const Color focusColor = Colors.pink; // Green
+    const Color backgroundColor = Colors.amber; // Blue
+
+    await tester.pumpWidget(
+      MaterialApp(
+        theme: ThemeData(focusColor: focusColor),
+        home: const Material(
+          child: ExpansionTile(title: Text('Title'), collapsedBackgroundColor: backgroundColor),
+        ),
+      ),
+    );
+
+    final Finder materialFinder = find.descendant(
+      of: find.byType(ExpansionTile),
+      matching: find.byType(Material),
+    );
+
+    // Focus the tile.
+    await tester.sendKeyEvent(LogicalKeyboardKey.tab);
+    await tester.pumpAndSettle();
+
+    expect(
+      materialFinder,
+      paints
+        ..rect(color: Colors.transparent)
+        ..rect(color: focusColor),
+    );
+  });
+
   testWidgets('ExpansionTile Border', (WidgetTester tester) async {
     const Key expansionTileKey = PageStorageKey<String>('expansionTile');
 
diff --git a/packages/flutter/test/material/list_tile_test.dart b/packages/flutter/test/material/list_tile_test.dart
index 208b139..6b5b70e 100644
--- a/packages/flutter/test/material/list_tile_test.dart
+++ b/packages/flutter/test/material/list_tile_test.dart
@@ -938,13 +938,13 @@
     tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
     Widget buildApp({bool enabled = true}) {
       return MaterialApp(
-        home: Material(
-          child: Center(
-            child: StatefulBuilder(
-              builder: (BuildContext context, StateSetter setState) {
-                return Container(
-                  width: 100,
-                  height: 100,
+        home: Center(
+          child: StatefulBuilder(
+            builder: (BuildContext context, StateSetter setState) {
+              return SizedBox(
+                width: 100,
+                height: 100,
+                child: Material(
                   color: Colors.white,
                   child: ListTile(
                     onTap: enabled ? () {} : null,
@@ -952,9 +952,9 @@
                     autofocus: true,
                     focusNode: focusNode,
                   ),
-                );
-              },
-            ),
+                ),
+              );
+            },
           ),
         ),
       );
@@ -967,12 +967,12 @@
     expect(
       find.byType(Material),
       paints
-        ..rect()
-        ..rect(color: Colors.orange[500], rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0))
-        ..rect(
+        ..rrect(
+          rrect: RRect.fromLTRBR(0.0, 0.0, 100.0, 100.0, Radius.zero),
           color: const Color(0xffffffff),
-          rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0),
-        ),
+        )
+        ..rect(color: const Color(0x00000000))
+        ..rect(color: Colors.orange[500], rect: const Rect.fromLTRB(0.0, 0.0, 100.0, 100.0)),
     );
 
     // Check when the list tile is disabled.
@@ -982,11 +982,11 @@
     expect(
       find.byType(Material),
       paints
-        ..rect()
-        ..rect(
+        ..rrect(
+          rrect: RRect.fromLTRBR(0.0, 0.0, 100.0, 100.0, Radius.zero),
           color: const Color(0xffffffff),
-          rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0),
-        ),
+        )
+        ..rect(color: const Color(0x00000000)),
     );
 
     focusNode.dispose();
@@ -996,22 +996,22 @@
     tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
     Widget buildApp({bool enabled = true}) {
       return MaterialApp(
-        home: Material(
-          child: Center(
-            child: StatefulBuilder(
-              builder: (BuildContext context, StateSetter setState) {
-                return Container(
-                  width: 100,
-                  height: 100,
+        home: Center(
+          child: StatefulBuilder(
+            builder: (BuildContext context, StateSetter setState) {
+              return SizedBox(
+                width: 100,
+                height: 100,
+                child: Material(
                   color: Colors.white,
                   child: ListTile(
                     onTap: enabled ? () {} : null,
                     hoverColor: Colors.orange[500],
                     autofocus: true,
                   ),
-                );
-              },
-            ),
+                ),
+              );
+            },
           ),
         ),
       );
@@ -1024,15 +1024,12 @@
     expect(
       find.byType(Material),
       paints
-        ..rect()
-        ..rect(
-          color: const Color(0x1f000000),
-          rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0),
-        )
-        ..rect(
+        ..rrect(
+          rrect: RRect.fromLTRBR(0.0, 0.0, 100.0, 100.0, Radius.zero),
           color: const Color(0xffffffff),
-          rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0),
-        ),
+        )
+        ..rect(color: const Color(0x00000000))
+        ..rect(color: const Color(0x1f000000), rect: const Rect.fromLTRB(0.0, 0.0, 100.0, 100.0)),
     );
 
     // Start hovering
@@ -1045,16 +1042,13 @@
     expect(
       find.byType(Material),
       paints
-        ..rect()
-        ..rect(
-          color: const Color(0x1f000000),
-          rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0),
-        )
-        ..rect(color: Colors.orange[500], rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0))
-        ..rect(
+        ..rrect(
+          rrect: RRect.fromLTRBR(0.0, 0.0, 100.0, 100.0, Radius.zero),
           color: const Color(0xffffffff),
-          rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0),
-        ),
+        )
+        ..rect(color: const Color(0x00000000))
+        ..rect(color: const Color(0x1f000000), rect: const Rect.fromLTRB(0.0, 0.0, 100.0, 100.0))
+        ..rect(color: Colors.orange[500], rect: const Rect.fromLTRB(0.0, 0.0, 100.0, 100.0)),
     );
 
     await tester.pumpWidget(buildApp(enabled: false));
@@ -1063,14 +1057,14 @@
     expect(
       find.byType(Material),
       paints
-        ..rect()
+        ..rrect(
+          rrect: RRect.fromLTRBR(0.0, 0.0, 100.0, 100.0, Radius.zero),
+          color: const Color(0xffffffff),
+        )
+        ..rect(color: const Color(0x00000000))
         ..rect(
           color: Colors.orange[500]!.withAlpha(0),
-          rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0),
-        )
-        ..rect(
-          color: const Color(0xffffffff),
-          rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0),
+          rect: const Rect.fromLTRB(0.0, 0.0, 100.0, 100.0),
         ),
     );
   });
@@ -1108,10 +1102,9 @@
           child: Center(
             child: StatefulBuilder(
               builder: (BuildContext context, StateSetter setState) {
-                return Container(
+                return SizedBox(
                   width: 200,
                   height: 100,
-                  color: Colors.white,
                   child: ListTile(
                     key: tileKey,
                     onTap: enabled
@@ -4763,6 +4756,136 @@
     );
     expect(tester.getSize(find.byType(ListTile)), Size.zero);
   });
+
+  testWidgets('ListTile shows warning when a Container with color wraps it', (
+    WidgetTester tester,
+  ) async {
+    final errorDetails = <FlutterErrorDetails>[];
+    final FlutterExceptionHandler? oldHandler = FlutterError.onError;
+    FlutterError.onError = (FlutterErrorDetails details) {
+      errorDetails.add(details);
+    };
+
+    await tester.pumpWidget(
+      MaterialApp(
+        home: Scaffold(
+          body: Center(
+            child: Container(
+              color: Colors.amber,
+              height:
+                  200, // This is to remove the lint on Container. Otherwise, linter suggests to use ColoredBox instead.
+              child: const ListTile(tileColor: Colors.red, title: Text('ListTile')),
+            ),
+          ),
+        ),
+      ),
+    );
+
+    FlutterError.onError = oldHandler;
+
+    expect(errorDetails, isNotEmpty);
+    final message = errorDetails.first.toString();
+    expect(message, contains('ListTile background color or ink splashes may be invisible'));
+    expect(message, contains('The ListTile is wrapped in a ColoredBox'));
+    expect(message, contains('ListTile:'));
+    expect(message, contains('tileColor:'));
+    expect(message, contains('ColoredBox:'));
+    expect(message, contains('color:'));
+  });
+
+  testWidgets('ListTile throw exception when wrapped in ColoredBox with non-transparent color', (
+    WidgetTester tester,
+  ) async {
+    final errorDetails = <FlutterErrorDetails>[];
+    final FlutterExceptionHandler? oldHandler = FlutterError.onError;
+    FlutterError.onError = (FlutterErrorDetails details) {
+      errorDetails.add(details);
+    };
+
+    await tester.pumpWidget(
+      const MaterialApp(
+        home: Scaffold(
+          body: Center(
+            child: ColoredBox(
+              color: Colors.amber,
+              child: ListTile(tileColor: Colors.red, title: Text('ListTile')),
+            ),
+          ),
+        ),
+      ),
+    );
+
+    FlutterError.onError = oldHandler;
+
+    expect(errorDetails, isNotEmpty);
+    final message = errorDetails.first.toString();
+    expect(message, contains('ListTile background color or ink splashes may be invisible'));
+    expect(message, contains('The ListTile is wrapped in a ColoredBox'));
+    expect(message, contains('ListTile:'));
+    expect(message, contains('tileColor:'));
+    expect(message, contains('ColoredBox:'));
+    expect(message, contains('color:'));
+  });
+
+  testWidgets('ListTile throw exception when wrapped in DecoratedBox with non-transparent color', (
+    WidgetTester tester,
+  ) async {
+    final errorDetails = <FlutterErrorDetails>[];
+    final FlutterExceptionHandler? oldHandler = FlutterError.onError;
+    FlutterError.onError = (FlutterErrorDetails details) {
+      errorDetails.add(details);
+    };
+
+    await tester.pumpWidget(
+      const MaterialApp(
+        home: Scaffold(
+          body: Center(
+            child: DecoratedBox(
+              decoration: BoxDecoration(color: Colors.amber),
+              child: ListTile(tileColor: Colors.red, title: Text('ListTile')),
+            ),
+          ),
+        ),
+      ),
+    );
+
+    FlutterError.onError = oldHandler;
+
+    expect(errorDetails, isNotEmpty);
+    final message = errorDetails.first.toString();
+    expect(message, contains('ListTile background color or ink splashes may be invisible'));
+    expect(message, contains('The ListTile is wrapped in a DecoratedBox'));
+    expect(message, contains('ListTile:'));
+    expect(message, contains('tileColor:'));
+    expect(message, contains('DecoratedBox:'));
+    expect(message, contains('bg'));
+  });
+
+  testWidgets('ListTile does not throw exception when parent has no/transparent color', (
+    WidgetTester tester,
+  ) async {
+    final errorDetails = <FlutterErrorDetails>[];
+    final FlutterExceptionHandler? oldHandler = FlutterError.onError;
+    FlutterError.onError = (FlutterErrorDetails details) {
+      errorDetails.add(details);
+    };
+
+    await tester.pumpWidget(
+      const MaterialApp(
+        home: Scaffold(
+          body: Center(
+            child: ColoredBox(
+              color: Colors.transparent,
+              child: ListTile(tileColor: Colors.red, title: Text('Visible ListTile')),
+            ),
+          ),
+        ),
+      ),
+    );
+    FlutterError.onError = oldHandler;
+
+    expect(errorDetails, isEmpty);
+  });
 }
 
 RenderParagraph _getTextRenderObject(WidgetTester tester, String text) {
diff --git a/packages/flutter/test/material/switch_list_tile_test.dart b/packages/flutter/test/material/switch_list_tile_test.dart
index 85c4c45..3396921 100644
--- a/packages/flutter/test/material/switch_list_tile_test.dart
+++ b/packages/flutter/test/material/switch_list_tile_test.dart
@@ -721,16 +721,18 @@
         child: Center(
           child: StatefulBuilder(
             builder: (BuildContext context, StateSetter setState) {
-              return Container(
+              return SizedBox(
                 width: 500,
                 height: 100,
-                color: Colors.white,
-                child: SwitchListTile(
-                  value: false,
-                  key: key,
-                  hoverColor: Colors.orange[500],
-                  title: const Text('A'),
-                  onChanged: (bool? value) {},
+                child: Material(
+                  color: Colors.white,
+                  child: SwitchListTile(
+                    value: false,
+                    key: key,
+                    hoverColor: Colors.orange[500],
+                    title: const Text('A'),
+                    onChanged: (bool? value) {},
+                  ),
                 ),
               );
             },
@@ -749,7 +751,7 @@
       Material.of(tester.element(find.byKey(key))),
       paints
         ..rect()
-        ..rect(color: Colors.orange[500], rect: const Rect.fromLTRB(150.0, 250.0, 650.0, 350.0)),
+        ..rect(color: Colors.orange[500], rect: const Rect.fromLTRB(0.0, 0.0, 500.0, 100.0)),
     );
   });