// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:math' as math; 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'; import '../rendering/mock_canvas.dart'; import '../widgets/semantics_tester.dart'; import 'feedback_tester.dart'; class TestIcon extends StatefulWidget { const TestIcon({ super.key }); @override TestIconState createState() => TestIconState(); } class TestIconState extends State<TestIcon> { late IconThemeData iconTheme; @override Widget build(BuildContext context) { iconTheme = IconTheme.of(context); return const Icon(Icons.add); } } class TestText extends StatefulWidget { const TestText(this.text, { super.key }); final String text; @override TestTextState createState() => TestTextState(); } class TestTextState extends State<TestText> { late TextStyle textStyle; @override Widget build(BuildContext context) { textStyle = DefaultTextStyle.of(context).style; return Text(widget.text); } } void main() { testWidgets('ListTile geometry (LTR)', (WidgetTester tester) async { // See https://material.io/go/design-lists final Key leadingKey = GlobalKey(); final Key trailingKey = GlobalKey(); late bool hasSubtitle; const double leftPadding = 10.0; const double rightPadding = 20.0; Widget buildFrame({ bool dense = false, bool isTwoLine = false, bool isThreeLine = false, double textScaleFactor = 1.0, double? subtitleScaleFactor }) { hasSubtitle = isTwoLine || isThreeLine; subtitleScaleFactor ??= textScaleFactor; return MaterialApp( theme: ThemeData(useMaterial3: true), home: MediaQuery( data: MediaQueryData( padding: const EdgeInsets.only(left: leftPadding, right: rightPadding), textScaleFactor: textScaleFactor, ), child: Material( child: Center( child: ListTile( leading: SizedBox(key: leadingKey, width: 24.0, height: 24.0), title: const Text('title'), subtitle: hasSubtitle ? Text('subtitle', textScaleFactor: subtitleScaleFactor) : null, trailing: SizedBox(key: trailingKey, width: 24.0, height: 24.0), dense: dense, isThreeLine: isThreeLine, ), ), ), ), ); } void testChildren() { expect(find.byKey(leadingKey), findsOneWidget); expect(find.text('title'), findsOneWidget); if (hasSubtitle) { expect(find.text('subtitle'), findsOneWidget); } expect(find.byKey(trailingKey), findsOneWidget); } double left(String text) => tester.getTopLeft(find.text(text)).dx; double top(String text) => tester.getTopLeft(find.text(text)).dy; double bottom(String text) => tester.getBottomLeft(find.text(text)).dy; double height(String text) => tester.getRect(find.text(text)).height; double leftKey(Key key) => tester.getTopLeft(find.byKey(key)).dx; double rightKey(Key key) => tester.getTopRight(find.byKey(key)).dx; double widthKey(Key key) => tester.getSize(find.byKey(key)).width; double heightKey(Key key) => tester.getSize(find.byKey(key)).height; // ListTiles are contained by a SafeArea defined like this: // SafeArea(top: false, bottom: false, minimum: contentPadding) // The default contentPadding is 16.0 on the left and 24.0 on the right. void testHorizontalGeometry() { expect(leftKey(leadingKey), math.max(16.0, leftPadding)); expect(left('title'), 40.0 + math.max(16.0, leftPadding)); if (hasSubtitle) { expect(left('subtitle'), 40.0 + math.max(16.0, leftPadding)); } expect(left('title'), rightKey(leadingKey) + 16.0); expect(rightKey(trailingKey), 800.0 - math.max(24.0, rightPadding)); expect(widthKey(trailingKey), 24.0); } void testVerticalGeometry(double expectedHeight) { final Rect tileRect = tester.getRect(find.byType(ListTile)); expect(tileRect.size, Size(800.0, expectedHeight)); expect(top('title'), greaterThanOrEqualTo(tileRect.top)); if (hasSubtitle) { expect(top('subtitle'), greaterThanOrEqualTo(bottom('title'))); expect(bottom('subtitle'), lessThan(tileRect.bottom)); } else { expect(top('title'), equals(tileRect.top + (tileRect.height - height('title')) / 2.0)); } expect(heightKey(trailingKey), 24.0); } await tester.pumpWidget(buildFrame()); testChildren(); testHorizontalGeometry(); testVerticalGeometry(56.0); await tester.pumpWidget(buildFrame(isTwoLine: true)); testChildren(); testHorizontalGeometry(); testVerticalGeometry(72.0); await tester.pumpWidget(buildFrame(isThreeLine: true)); testChildren(); testHorizontalGeometry(); testVerticalGeometry(88.0); await tester.pumpWidget(buildFrame(textScaleFactor: 4.0)); testChildren(); testHorizontalGeometry(); testVerticalGeometry(112.0); await tester.pumpWidget(buildFrame(isTwoLine: true, textScaleFactor: 4.0)); testChildren(); testHorizontalGeometry(); // TODO(tahatesser): https://github.com/flutter/flutter/issues/99933 // A bug in the HTML renderer and/or Chrome 96+ causes a // discrepancy in the paragraph height. const bool hasIssue99933 = kIsWeb && !bool.fromEnvironment('FLUTTER_WEB_USE_SKIA'); testVerticalGeometry(hasIssue99933 ? 193 : 192.0); // Make sure that the height of a large subtitle is taken into account. await tester.pumpWidget(buildFrame(isTwoLine: true, textScaleFactor: 0.5, subtitleScaleFactor: 4.0)); testChildren(); testHorizontalGeometry(); testVerticalGeometry(hasIssue99933 ? 109 : 108.0); await tester.pumpWidget(buildFrame(isThreeLine: true, textScaleFactor: 4.0)); testChildren(); testHorizontalGeometry(); testVerticalGeometry(hasIssue99933 ? 193 : 192.0); }); testWidgets('ListTile geometry (RTL)', (WidgetTester tester) async { const double leftPadding = 10.0; const double rightPadding = 20.0; await tester.pumpWidget(MaterialApp( theme: ThemeData(useMaterial3: true), home: const MediaQuery( data: MediaQueryData( padding: EdgeInsets.only(left: leftPadding, right: rightPadding), ), child: Directionality( textDirection: TextDirection.rtl, child: Material( child: Center( child: ListTile( leading: Text('L'), title: Text('title'), trailing: Text('T'), ), ), ), ), ), )); double left(String text) => tester.getTopLeft(find.text(text)).dx; double right(String text) => tester.getTopRight(find.text(text)).dx; void testHorizontalGeometry() { expect(right('L'), 800.0 - math.max(16.0, rightPadding)); expect(right('title'), 800.0 - 40.0 - math.max(16.0, rightPadding)); expect(left('T'), math.max(24.0, leftPadding)); } testHorizontalGeometry(); }); testWidgets('ListTile.divideTiles', (WidgetTester tester) async { final List<String> titles = <String>[ 'first', 'second', 'third' ]; await tester.pumpWidget(MaterialApp( home: Material( child: Builder( builder: (BuildContext context) { return ListView( children: ListTile.divideTiles( context: context, tiles: titles.map<Widget>((String title) => ListTile(title: Text(title))), ).toList(), ); }, ), ), )); expect(find.text('first'), findsOneWidget); expect(find.text('second'), findsOneWidget); expect(find.text('third'), findsOneWidget); }); testWidgets('ListTile.divideTiles with empty list', (WidgetTester tester) async { final Iterable<Widget> output = ListTile.divideTiles(tiles: <Widget>[], color: Colors.grey); expect(output, isEmpty); }); testWidgets('ListTile.divideTiles with single item list', (WidgetTester tester) async { final Iterable<Widget> output = ListTile.divideTiles(tiles: const <Widget>[SizedBox()], color: Colors.grey); expect(output.single, isA<SizedBox>()); }); testWidgets('ListTile.divideTiles only runs the generator once', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/pull/78879 int callCount = 0; Iterable<Widget> generator() sync* { callCount += 1; yield const Text(''); yield const Text(''); } final List<Widget> output = ListTile.divideTiles(tiles: generator(), color: Colors.grey).toList(); expect(output, hasLength(2)); expect(callCount, 1); }); testWidgets('ListTile semantics', (WidgetTester tester) async { final SemanticsTester semantics = SemanticsTester(tester); await tester.pumpWidget( Material( child: Directionality( textDirection: TextDirection.ltr, child: MediaQuery( data: const MediaQueryData(), child: Column( children: <Widget>[ const ListTile( title: Text('one'), ), ListTile( title: const Text('two'), onTap: () {}, ), const ListTile( title: Text('three'), selected: true, ), const ListTile( title: Text('four'), enabled: false, ), ], ), ), ), ), ); expect( semantics, hasSemantics( TestSemantics.root( children: <TestSemantics>[ TestSemantics.rootChild( flags: <SemanticsFlag>[ SemanticsFlag.hasEnabledState, SemanticsFlag.isEnabled, ], label: 'one', ), TestSemantics.rootChild( flags: <SemanticsFlag>[ SemanticsFlag.hasEnabledState, SemanticsFlag.isEnabled, SemanticsFlag.isFocusable, ], actions: <SemanticsAction>[SemanticsAction.tap], label: 'two', ), TestSemantics.rootChild( flags: <SemanticsFlag>[ SemanticsFlag.isSelected, SemanticsFlag.hasEnabledState, SemanticsFlag.isEnabled, ], label: 'three', ), TestSemantics.rootChild( flags: <SemanticsFlag>[ SemanticsFlag.hasEnabledState, ], label: 'four', ), ], ), ignoreTransform: true, ignoreId: true, ignoreRect: true, ), ); semantics.dispose(); }); testWidgets('ListTile contentPadding', (WidgetTester tester) async { Widget buildFrame(TextDirection textDirection) { return MediaQuery( data: const MediaQueryData(), child: Directionality( textDirection: textDirection, child: Material( child: Container( alignment: Alignment.topLeft, child: const ListTile( contentPadding: EdgeInsetsDirectional.only( start: 10.0, end: 20.0, top: 30.0, bottom: 40.0, ), leading: Text('L'), title: Text('title'), trailing: Text('T'), ), ), ), ), ); } double left(String text) => tester.getTopLeft(find.text(text)).dx; double right(String text) => tester.getTopRight(find.text(text)).dx; await tester.pumpWidget(buildFrame(TextDirection.ltr)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 126.0)); // 126 = 56 + 30 + 40 expect(left('L'), 10.0); // contentPadding.start = 10 expect(right('T'), 780.0); // 800 - contentPadding.end await tester.pumpWidget(buildFrame(TextDirection.rtl)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 126.0)); // 126 = 56 + 30 + 40 expect(left('T'), 20.0); // contentPadding.end = 20 expect(right('L'), 790.0); // 800 - contentPadding.start }); testWidgets('ListTile wide leading Widget', (WidgetTester tester) async { const Key leadingKey = ValueKey<String>('L'); Widget buildFrame(double leadingWidth, TextDirection textDirection) { return MaterialApp( theme: ThemeData(useMaterial3: true), home: Directionality( textDirection: textDirection, child: Material( child: Container( alignment: Alignment.topLeft, child: ListTile( contentPadding: EdgeInsets.zero, leading: SizedBox(key: leadingKey, width: leadingWidth, height: 32.0), title: const Text('title'), subtitle: const Text('subtitle'), ), ), ), ), ); } double left(String text) => tester.getTopLeft(find.text(text)).dx; double right(String text) => tester.getTopRight(find.text(text)).dx; // textDirection = LTR // Two-line tile's height = 72, leading 24x32 widget is positioned in the center. await tester.pumpWidget(buildFrame(24.0, TextDirection.ltr)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 72.0)); expect(tester.getTopLeft(find.byKey(leadingKey)), const Offset(0.0, 20.0)); expect(tester.getBottomRight(find.byKey(leadingKey)), const Offset(24.0, 20.0 + 32.0)); // Leading widget's width is 20, so default layout: the left edges of the // title and subtitle are at 40dps, leading widget width is 24dp and 16dp // is horizontalTitleGap (contentPadding is zero). expect(left('title'), 40.0); expect(left('subtitle'), 40.0); // If the leading widget is wider than 40 it is separated from the // title and subtitle by 16. await tester.pumpWidget(buildFrame(56.0, TextDirection.ltr)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 72.0)); expect(tester.getTopLeft(find.byKey(leadingKey)), const Offset(0.0, 20.0)); expect(tester.getBottomRight(find.byKey(leadingKey)), const Offset(56.0, 20.0 + 32.0)); expect(left('title'), 72.0); expect(left('subtitle'), 72.0); // Same tests, textDirection = RTL await tester.pumpWidget(buildFrame(24.0, TextDirection.rtl)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 72.0)); expect(tester.getTopRight(find.byKey(leadingKey)), const Offset(800.0, 20.0)); expect(tester.getBottomLeft(find.byKey(leadingKey)), const Offset(800.0 - 24.0, 20.0 + 32.0)); expect(right('title'), 800.0 - 40.0); expect(right('subtitle'), 800.0 - 40.0); await tester.pumpWidget(buildFrame(56.0, TextDirection.rtl)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 72.0)); expect(tester.getTopRight(find.byKey(leadingKey)), const Offset(800.0, 20.0)); expect(tester.getBottomLeft(find.byKey(leadingKey)), const Offset(800.0 - 56.0, 20.0 + 32.0)); expect(right('title'), 800.0 - 72.0); expect(right('subtitle'), 800.0 - 72.0); }); testWidgets('ListTile leading and trailing positions', (WidgetTester tester) async { // This test is based on the redlines at // https://material.io/design/components/lists.html#specs // "ONE"-LINE await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: true), home: Material( child: ListView( children: const <Widget>[ ListTile( leading: CircleAvatar(), trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), title: Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), ), ListTile( leading: CircleAvatar(), trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), title: Text('A'), ), ], ), ), ), ); await tester.pump(const Duration(seconds: 2)); // the text styles are animated when we change dense // LEFT TOP WIDTH HEIGHT expect(tester.getRect(find.byType(ListTile).at(0)), const Rect.fromLTWH( 0.0, 0.0, 800.0, 328.0)); expect(tester.getRect(find.byType(CircleAvatar).at(0)), const Rect.fromLTWH( 16.0, 144.0, 40.0, 40.0)); expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 24.0 - 24.0, 152.0, 24.0, 24.0)); expect(tester.getRect(find.byType(ListTile).at(1)), const Rect.fromLTWH( 0.0, 328.0 , 800.0, 56.0)); expect(tester.getRect(find.byType(CircleAvatar).at(1)), const Rect.fromLTWH( 16.0, 328.0 + 8.0, 40.0, 40.0)); expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 24.0 - 24.0, 328.0 + 16.0, 24.0, 24.0)); // "TWO"-LINE await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: true), home: Material( child: ListView( children: const <Widget>[ ListTile( leading: CircleAvatar(), trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), title: Text('A'), subtitle: Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), ), ListTile( leading: CircleAvatar(), trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), title: Text('A'), subtitle: Text('A'), ), ], ), ), ), ); // TODO(tahatesser): https://github.com/flutter/flutter/issues/99933 // A bug in the HTML renderer and/or Chrome 96+ causes a // discrepancy in the paragraph height. const bool hasIssue99933 = kIsWeb && !bool.fromEnvironment('FLUTTER_WEB_USE_SKIA'); const double height = hasIssue99933 ? 301.0 : 300; const double avatarTop = hasIssue99933 ? 130.5 : 130.0; const double placeholderTop = hasIssue99933 ? 138.5 : 138.0; // LEFT TOP WIDTH HEIGHT expect(tester.getRect(find.byType(ListTile).at(0)), const Rect.fromLTWH( 0.0, 0.0, 800.0, height)); expect(tester.getRect(find.byType(CircleAvatar).at(0)), const Rect.fromLTWH( 16.0, avatarTop, 40.0, 40.0)); expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 24.0 - 24.0, placeholderTop, 24.0, 24.0)); expect(tester.getRect(find.byType(ListTile).at(1)), const Rect.fromLTWH( 0.0, height , 800.0, 72.0)); expect(tester.getRect(find.byType(CircleAvatar).at(1)), const Rect.fromLTWH( 16.0, height + 16.0, 40.0, 40.0)); expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 24.0 - 24.0, height + 24.0, 24.0, 24.0)); // THREE-LINE await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: true), home: Material( child: ListView( children: const <Widget>[ ListTile( isThreeLine: true, leading: CircleAvatar(), trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), title: Text('A'), subtitle: Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), ), ListTile( isThreeLine: true, leading: CircleAvatar(), trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), title: Text('A'), subtitle: Text('A'), ), ], ), ), ), ); // LEFT TOP WIDTH HEIGHT expect(tester.getRect(find.byType(ListTile).at(0)), const Rect.fromLTWH( 0.0, 0.0, 800.0, height)); expect(tester.getRect(find.byType(CircleAvatar).at(0)), const Rect.fromLTWH( 16.0, 8.0, 40.0, 40.0)); expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 24.0 - 24.0, 8.0, 24.0, 24.0)); expect(tester.getRect(find.byType(ListTile).at(1)), const Rect.fromLTWH( 0.0, height , 800.0, 88.0)); expect(tester.getRect(find.byType(CircleAvatar).at(1)), const Rect.fromLTWH( 16.0, height + 8.0, 40.0, 40.0)); expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 24.0 - 24.0, height + 8.0, 24.0, 24.0)); // "ONE-LINE" with Small Leading Widget await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: true), home: Material( child: ListView( children: const <Widget>[ ListTile( leading: SizedBox(height: 12.0, width: 24.0, child: Placeholder()), trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), title: Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), ), ListTile( leading: SizedBox(height: 12.0, width: 24.0, child: Placeholder()), trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), title: Text('A'), ), ], ), ), ), ); await tester.pump(const Duration(seconds: 2)); // the text styles are animated when we change dense // LEFT TOP WIDTH HEIGHT expect(tester.getRect(find.byType(ListTile).at(0)), const Rect.fromLTWH( 0.0, 0.0, 800.0, 328.0)); expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH( 16.0, 158.0, 24.0, 12.0)); expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 24.0 - 24.0, 152.0, 24.0, 24.0)); expect(tester.getRect(find.byType(ListTile).at(1)), const Rect.fromLTWH( 0.0, 328.0 , 800.0, 56.0)); expect(tester.getRect(find.byType(Placeholder).at(2)), const Rect.fromLTWH( 16.0, 328.0 + 22.0, 24.0, 12.0)); expect(tester.getRect(find.byType(Placeholder).at(3)), const Rect.fromLTWH(800.0 - 24.0 - 24.0, 328.0 + 16.0, 24.0, 24.0)); }); testWidgets('ListTile leading icon height does not exceed ListTile height', (WidgetTester tester) async { // regression test for https://github.com/flutter/flutter/issues/28765 const SizedBox oversizedWidget = SizedBox(height: 80.0, width: 24.0, child: Placeholder()); // One line await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: true), home: Material( child: ListView( children: const <Widget>[ ListTile( leading: oversizedWidget, title: Text('A'), ), ListTile( leading: oversizedWidget, title: Text('B'), ), ], ), ), ), ); expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(16.0, 0.0, 24.0, 56.0)); expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(16.0, 56.0, 24.0, 56.0)); // Two line await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: true), home: Material( child: ListView( children: const <Widget>[ ListTile( leading: oversizedWidget, title: Text('A'), subtitle: Text('A'), ), ListTile( leading: oversizedWidget, title: Text('B'), subtitle: Text('B'), ), ], ), ), ), ); expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(16.0, 8.0, 24.0, 56.0)); expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(16.0, 72.0 + 8.0, 24.0, 56.0)); // Three line await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: true), home: Material( child: ListView( children: const <Widget>[ ListTile( leading: oversizedWidget, title: Text('A'), subtitle: Text('A'), isThreeLine: true, ), ListTile( leading: oversizedWidget, title: Text('B'), subtitle: Text('B'), isThreeLine: true, ), ], ), ), ), ); expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(16.0, 8.0, 24.0, 56.0)); expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(16.0, 88.0 + 8.0, 24.0, 56.0)); }); testWidgets('ListTile trailing icon height does not exceed ListTile height', (WidgetTester tester) async { // regression test for https://github.com/flutter/flutter/issues/28765 const SizedBox oversizedWidget = SizedBox(height: 80.0, width: 24.0, child: Placeholder()); // One line await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: true), home: Material( child: ListView( children: const <Widget>[ ListTile( trailing: oversizedWidget, title: Text('A'), dense: false, ), ListTile( trailing: oversizedWidget, title: Text('B'), dense: false, ), ], ), ), ), ); expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 24.0 - 24.0, 0.0, 24.0, 56.0)); expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 24.0 - 24.0, 56.0, 24.0, 56.0)); // Two line await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: true), home: Material( child: ListView( children: const <Widget>[ ListTile( trailing: oversizedWidget, title: Text('A'), subtitle: Text('A'), dense: false, ), ListTile( trailing: oversizedWidget, title: Text('B'), subtitle: Text('B'), dense: false, ), ], ), ), ), ); expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 24.0 - 24.0, 8.0, 24.0, 56.0)); expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 24.0 - 24.0, 72.0 + 8.0, 24.0, 56.0)); // Three line await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: true), home: Material( child: ListView( children: const <Widget>[ ListTile( trailing: oversizedWidget, title: Text('A'), subtitle: Text('A'), isThreeLine: true, dense: false, ), ListTile( trailing: oversizedWidget, title: Text('B'), subtitle: Text('B'), isThreeLine: true, dense: false, ), ], ), ), ), ); expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 24.0 - 24.0, 8.0, 24.0, 56.0)); expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 24.0 - 24.0, 88.0 + 8.0, 24.0, 56.0)); }); testWidgets('ListTile only accepts focus when enabled', (WidgetTester tester) async { final GlobalKey childKey = GlobalKey(); await tester.pumpWidget( MaterialApp( home: Material( child: ListView( children: <Widget>[ ListTile( title: Text('A', key: childKey), dense: true, onTap: () {}, ), ], ), ), ), ); await tester.pump(); // Let the focus take effect. final FocusNode tileNode = Focus.of(childKey.currentContext!); tileNode.requestFocus(); await tester.pump(); // Let the focus take effect. expect(Focus.of(childKey.currentContext!).hasPrimaryFocus, isTrue); expect(tileNode.hasPrimaryFocus, isTrue); await tester.pumpWidget( MaterialApp( home: Material( child: ListView( children: <Widget>[ ListTile( title: Text('A', key: childKey), dense: true, enabled: false, onTap: () {}, ), ], ), ), ), ); expect(tester.binding.focusManager.primaryFocus, isNot(equals(tileNode))); expect(Focus.of(childKey.currentContext!).hasPrimaryFocus, isFalse); }); testWidgets('ListTile can autofocus unless disabled.', (WidgetTester tester) async { final GlobalKey childKey = GlobalKey(); await tester.pumpWidget( MaterialApp( home: Material( child: ListView( children: <Widget>[ ListTile( title: Text('A', key: childKey), dense: true, autofocus: true, onTap: () {}, ), ], ), ), ), ); await tester.pump(); expect(Focus.of(childKey.currentContext!).hasPrimaryFocus, isTrue); await tester.pumpWidget( MaterialApp( home: Material( child: ListView( children: <Widget>[ ListTile( title: Text('A', key: childKey), dense: true, enabled: false, autofocus: true, onTap: () {}, ), ], ), ), ), ); await tester.pump(); expect(Focus.of(childKey.currentContext!).hasPrimaryFocus, isFalse); }); testWidgets('ListTile is focusable and has correct focus color', (WidgetTester tester) async { final FocusNode focusNode = FocusNode(debugLabel: 'ListTile'); 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, color: Colors.white, child: ListTile( onTap: enabled ? () {} : null, focusColor: Colors.orange[500], autofocus: true, focusNode: focusNode, ), ); }), ), ), ); } await tester.pumpWidget(buildApp()); await tester.pumpAndSettle(); expect(focusNode.hasPrimaryFocus, isTrue); expect( find.byType(Material), paints ..rect() ..rect( color: Colors.orange[500], 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), ), ); // Check when the list tile is disabled. await tester.pumpWidget(buildApp(enabled: false)); await tester.pumpAndSettle(); expect(focusNode.hasPrimaryFocus, isFalse); expect( find.byType(Material), paints ..rect() ..rect( color: const Color(0xffffffff), rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), ), ); }); testWidgets('ListTile can be hovered and has correct hover color', (WidgetTester tester) async { 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, color: Colors.white, child: ListTile( onTap: enabled ? () {} : null, hoverColor: Colors.orange[500], autofocus: true, ), ); }), ), ), ); } await tester.pumpWidget(buildApp()); await tester.pump(); await tester.pumpAndSettle(); 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: const Color(0xffffffff), rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), ), ); // Start hovering final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); await gesture.moveTo(tester.getCenter(find.byType(ListTile))); await tester.pumpWidget(buildApp()); await tester.pump(); await tester.pumpAndSettle(); 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( color: const Color(0xffffffff), rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), ), ); await tester.pumpWidget(buildApp(enabled: false)); await tester.pump(); await tester.pumpAndSettle(); expect( find.byType(Material), paints ..rect() ..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), ), ); }); testWidgets('ListTile can be splashed and has correct splash color', (WidgetTester tester) async { final Widget buildApp = MaterialApp( theme: ThemeData(useMaterial3: false), home: Material( child: Center( child: SizedBox( width: 100, height: 100, child: ListTile( onTap: () {}, splashColor: const Color(0xff88ff88), ), ), ), ), ); await tester.pumpWidget(buildApp); await tester.pumpAndSettle(); final TestGesture gesture = await tester.startGesture(tester.getRect(find.byType(ListTile)).center); await tester.pump(const Duration(milliseconds: 200)); expect(find.byType(Material), paints..circle(x: 50, y: 50, color: const Color(0xff88ff88))); await gesture.up(); }); testWidgets('ListTile can be triggered by keyboard shortcuts', (WidgetTester tester) async { tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; const Key tileKey = Key('ListTile'); bool tapped = false; Widget buildApp({bool enabled = true}) { return MaterialApp( home: Material( child: Center( child: StatefulBuilder(builder: (BuildContext context, StateSetter setState) { return Container( width: 200, height: 100, color: Colors.white, child: ListTile( key: tileKey, onTap: enabled ? () { setState(() { tapped = true; }); } : null, hoverColor: Colors.orange[500], autofocus: true, ), ); }), ), ), ); } await tester.pumpWidget(buildApp()); await tester.pumpAndSettle(); await tester.sendKeyEvent(LogicalKeyboardKey.space); await tester.pumpAndSettle(); expect(tapped, isTrue); }); testWidgets('ListTile responds to density changes.', (WidgetTester tester) async { const Key key = Key('test'); Future<void> buildTest(VisualDensity visualDensity) async { return tester.pumpWidget( MaterialApp( home: Material( child: Center( child: ListTile( key: key, onTap: () {}, autofocus: true, visualDensity: visualDensity, ), ), ), ), ); } await buildTest(VisualDensity.standard); final RenderBox box = tester.renderObject(find.byKey(key)); await tester.pumpAndSettle(); expect(box.size, equals(const Size(800, 56))); await buildTest(const VisualDensity(horizontal: 3.0, vertical: 3.0)); await tester.pumpAndSettle(); expect(box.size, equals(const Size(800, 68))); await buildTest(const VisualDensity(horizontal: -3.0, vertical: -3.0)); await tester.pumpAndSettle(); expect(box.size, equals(const Size(800, 44))); await buildTest(const VisualDensity(horizontal: 3.0, vertical: -3.0)); await tester.pumpAndSettle(); expect(box.size, equals(const Size(800, 44))); }); testWidgets('ListTile shape is painted correctly', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/63877 const ShapeBorder rectShape = RoundedRectangleBorder(); const ShapeBorder stadiumShape = StadiumBorder(); final Color tileColor = Colors.red.shade500; Widget buildListTile(ShapeBorder shape) { return MaterialApp( home: Material( child: Center( child: ListTile(shape: shape, tileColor: tileColor), ), ), ); } // Test rectangle shape await tester.pumpWidget(buildListTile(rectShape)); Rect rect = tester.getRect(find.byType(ListTile)); // Check if a rounded rectangle was painted with the correct color and shape expect( find.byType(Material), paints..rect(color: tileColor, rect: rect), ); // Test stadium shape await tester.pumpWidget(buildListTile(stadiumShape)); rect = tester.getRect(find.byType(ListTile)); // Check if a rounded rectangle was painted with the correct color and shape expect( find.byType(Material), paints..clipRect()..rrect( color: tileColor, rrect: RRect.fromRectAndRadius(rect, Radius.circular(rect.shortestSide / 2.0)), ), ); }); testWidgets('ListTile changes mouse cursor when hovered', (WidgetTester tester) async { // Test ListTile() constructor await tester.pumpWidget( MaterialApp( home: Material( child: Center( child: MouseRegion( cursor: SystemMouseCursors.forbidden, child: ListTile( onTap: () {}, mouseCursor: SystemMouseCursors.text, ), ), ), ), ), ); final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse, pointer: 1); await gesture.addPointer(location: tester.getCenter(find.byType(ListTile))); await tester.pump(); expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.text); // Test default cursor await tester.pumpWidget( MaterialApp( home: Material( child: Center( child: MouseRegion( cursor: SystemMouseCursors.forbidden, child: ListTile( onTap: () {}, ), ), ), ), ), ); expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.click); // Test default cursor when disabled await tester.pumpWidget( const MaterialApp( home: Material( child: Center( child: MouseRegion( cursor: SystemMouseCursors.forbidden, child: ListTile( enabled: false, ), ), ), ), ), ); expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.basic); // Test default cursor when onTap or onLongPress is null await tester.pumpWidget( const MaterialApp( home: Material( child: Center( child: MouseRegion( cursor: SystemMouseCursors.forbidden, child: ListTile(), ), ), ), ), ); expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.basic); }); testWidgets('ListTile onFocusChange callback', (WidgetTester tester) async { final FocusNode node = FocusNode(debugLabel: 'ListTile Focus'); bool gotFocus = false; await tester.pumpWidget( MaterialApp( home: Material( child: ListTile( focusNode: node, onFocusChange: (bool focused) { gotFocus = focused; }, onTap: () {}, ), ), ), ); node.requestFocus(); await tester.pump(); expect(gotFocus, isTrue); expect(node.hasFocus, isTrue); node.unfocus(); await tester.pump(); expect(gotFocus, isFalse); expect(node.hasFocus, isFalse); }); testWidgets('ListTile respects tileColor & selectedTileColor', (WidgetTester tester) async { bool isSelected = false; final Color tileColor = Colors.green.shade500; final Color selectedTileColor = Colors.red.shade500; await tester.pumpWidget( MaterialApp( home: Material( child: Center( child: StatefulBuilder( builder: (BuildContext context, StateSetter setState) { return ListTile( selected: isSelected, selectedTileColor: selectedTileColor, tileColor: tileColor, onTap: () { setState(()=> isSelected = !isSelected); }, title: const Text('Title'), ); }, ), ), ), ), ); // Initially, when isSelected is false, the ListTile should respect tileColor. expect(find.byType(Material), paints..rect(color: tileColor)); // Tap on tile to change isSelected. await tester.tap(find.byType(ListTile)); await tester.pumpAndSettle(); // When isSelected is true, the ListTile should respect selectedTileColor. expect(find.byType(Material), paints..rect(color: selectedTileColor)); }); testWidgets('ListTile shows Material ripple effects on top of tileColor', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/73616 final Color tileColor = Colors.red.shade500; await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: false), home: Material( child: Center( child: ListTile( tileColor: tileColor, onTap: () {}, title: const Text('Title'), ), ), ), ), ); // Before ListTile is tapped, it should be tileColor expect(find.byType(Material), paints..rect(color: tileColor)); // Tap on tile to trigger ink effect and wait for it to be underway. await tester.tap(find.byType(ListTile)); await tester.pump(const Duration(milliseconds: 200)); // After tap, the tile could be drawn in tileColor, with the ripple (circle) on top expect( find.byType(Material), paints ..rect(color: tileColor) ..circle(), ); }); testWidgets('ListTile default tile color', (WidgetTester tester) async { bool isSelected = false; final ThemeData theme = ThemeData(useMaterial3: true); const Color defaultColor = Colors.transparent; await tester.pumpWidget( MaterialApp( theme: theme, home: Material( child: Center( child: StatefulBuilder( builder: (BuildContext context, StateSetter setState) { return ListTile( selected: isSelected, onTap: () { setState(()=> isSelected = !isSelected); }, title: const Text('Title'), ); }, ), ), ), ), ); expect(find.byType(Material), paints..rect(color: defaultColor)); // Tap on tile to change isSelected. await tester.tap(find.byType(ListTile)); await tester.pumpAndSettle(); expect(find.byType(Material), paints..rect(color: defaultColor)); }); testWidgets('Default tile color when ListTile is wrapped with an elevated widget', (WidgetTester tester) async { // This is a regression test for https://github.com/flutter/flutter/issues/117700 bool isSelected = false; final ThemeData theme = ThemeData(useMaterial3: true); const Color defaultColor = Colors.transparent; await tester.pumpWidget( MaterialApp( theme: theme, home: Center( child: StatefulBuilder( builder: (BuildContext context, StateSetter setState) { return Card( elevation: 8.0, child: ListTile( selected: isSelected, onTap: () { setState(()=> isSelected = !isSelected); }, title: const Text('Title'), ), ); }, ), ), ), ); expect( find.byType(Material), paints ..path(color: const Color(0xff000000)) ..path(color: const Color(0xffece6f3)) ..save() ..save(), ); expect(find.byType(Material), paints..rect(color: defaultColor)); // Tap on tile to change isSelected. await tester.tap(find.byType(ListTile)); await tester.pumpAndSettle(); expect( find.byType(Material), paints ..path(color: const Color(0xff000000)) ..path(color: const Color(0xffece6f3)) ..save() ..save(), ); expect(find.byType(Material), paints..rect(color: defaultColor)); }); testWidgets('ListTile layout at zero size', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/66636 const Key key = Key('key'); await tester.pumpWidget(const MaterialApp( home: Scaffold( body: SizedBox.shrink( child: ListTile( key: key, tileColor: Colors.green, ), ), ), )); final RenderBox renderBox = tester.renderObject(find.byKey(key)); expect(renderBox.size.width, equals(0.0)); expect(renderBox.size.height, equals(0.0)); }); group('feedback', () { late FeedbackTester feedback; setUp(() { feedback = FeedbackTester(); }); tearDown(() { feedback.dispose(); }); testWidgets('ListTile with disabled feedback', (WidgetTester tester) async { const bool enableFeedback = false; await tester.pumpWidget( MaterialApp( home: Material( child: ListTile( title: const Text('Title'), onTap: () {}, enableFeedback: enableFeedback, ), ), ), ); await tester.tap(find.byType(ListTile)); await tester.pump(const Duration(seconds: 1)); expect(feedback.clickSoundCount, 0); expect(feedback.hapticCount, 0); }); testWidgets('ListTile with enabled feedback', (WidgetTester tester) async { const bool enableFeedback = true; await tester.pumpWidget( MaterialApp( home: Material( child: ListTile( title: const Text('Title'), onTap: () {}, enableFeedback: enableFeedback, ), ), ), ); await tester.tap(find.byType(ListTile)); await tester.pump(const Duration(seconds: 1)); expect(feedback.clickSoundCount, 1); expect(feedback.hapticCount, 0); }); testWidgets('ListTile with enabled feedback by default', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( home: Material( child: ListTile( title: const Text('Title'), onTap: () {}, ), ), ), ); await tester.tap(find.byType(ListTile)); await tester.pump(const Duration(seconds: 1)); expect(feedback.clickSoundCount, 1); expect(feedback.hapticCount, 0); }); testWidgets('ListTile with disabled feedback using ListTileTheme', (WidgetTester tester) async { const bool enableFeedbackTheme = false; await tester.pumpWidget( MaterialApp( home: Material( child: ListTileTheme( data: const ListTileThemeData(enableFeedback: enableFeedbackTheme), child: ListTile( title: const Text('Title'), onTap: () {}, ), ), ), ), ); await tester.tap(find.byType(ListTile)); await tester.pump(const Duration(seconds: 1)); expect(feedback.clickSoundCount, 0); expect(feedback.hapticCount, 0); }); testWidgets('ListTile.enableFeedback overrides ListTileTheme.enableFeedback', (WidgetTester tester) async { const bool enableFeedbackTheme = false; const bool enableFeedback = true; await tester.pumpWidget( MaterialApp( home: Material( child: ListTileTheme( data: const ListTileThemeData(enableFeedback: enableFeedbackTheme), child: ListTile( enableFeedback: enableFeedback, title: const Text('Title'), onTap: () {}, ), ), ), ), ); await tester.tap(find.byType(ListTile)); await tester.pump(const Duration(seconds: 1)); expect(feedback.clickSoundCount, 1); expect(feedback.hapticCount, 0); }); testWidgets('ListTile.mouseCursor overrides ListTileTheme.mouseCursor', (WidgetTester tester) async { final Key tileKey = UniqueKey(); await tester.pumpWidget( MaterialApp( home: Material( child: ListTileTheme( data: const ListTileThemeData(mouseCursor: MaterialStateMouseCursor.clickable), child: ListTile( key: tileKey, mouseCursor: MaterialStateMouseCursor.textable, title: const Text('Title'), onTap: () {}, ), ), ), ), ); final Offset listTile = tester.getCenter(find.byKey(tileKey)); final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); await gesture.addPointer(); await gesture.moveTo(listTile); await tester.pumpAndSettle(); expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.text); }); }); testWidgets('ListTile horizontalTitleGap = 0.0', (WidgetTester tester) async { Widget buildFrame(TextDirection textDirection, { double? themeHorizontalTitleGap, double? widgetHorizontalTitleGap }) { return MaterialApp( theme: ThemeData(useMaterial3: true), home: Directionality( textDirection: textDirection, child: Material( child: ListTileTheme( data: ListTileThemeData(horizontalTitleGap: themeHorizontalTitleGap), child: Container( alignment: Alignment.topLeft, child: ListTile( horizontalTitleGap: widgetHorizontalTitleGap, leading: const Text('L'), title: const Text('title'), trailing: const Text('T'), ), ), ), ), ), ); } double left(String text) => tester.getTopLeft(find.text(text)).dx; double right(String text) => tester.getTopRight(find.text(text)).dx; await tester.pumpWidget(buildFrame(TextDirection.ltr, widgetHorizontalTitleGap: 0)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); expect(left('title'), 40.0); await tester.pumpWidget(buildFrame(TextDirection.ltr, themeHorizontalTitleGap: 0)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); expect(left('title'), 40.0); await tester.pumpWidget(buildFrame(TextDirection.ltr, themeHorizontalTitleGap: 10, widgetHorizontalTitleGap: 0)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); expect(left('title'), 40.0); await tester.pumpWidget(buildFrame(TextDirection.rtl, widgetHorizontalTitleGap: 0)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); expect(right('title'), 760.0); await tester.pumpWidget(buildFrame(TextDirection.rtl, themeHorizontalTitleGap: 0)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); expect(right('title'), 760.0); await tester.pumpWidget(buildFrame(TextDirection.rtl, themeHorizontalTitleGap: 10, widgetHorizontalTitleGap: 0)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); expect(right('title'), 760.0); }); testWidgets('ListTile horizontalTitleGap = (default) && ListTile minLeadingWidth = (default)', (WidgetTester tester) async { Widget buildFrame(TextDirection textDirection) { return MaterialApp( theme: ThemeData(useMaterial3: true), home: Directionality( textDirection: textDirection, child: Material( child: Container( alignment: Alignment.topLeft, child: const ListTile( leading: Text('L'), title: Text('title'), trailing: Text('T'), ), ), ), ), ); } double left(String text) => tester.getTopLeft(find.text(text)).dx; double right(String text) => tester.getTopRight(find.text(text)).dx; await tester.pumpWidget(buildFrame(TextDirection.ltr)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); // horizontalTitleGap: ListTileDefaultValue.horizontalTitleGap (16.0) expect(left('title'), 56.0); await tester.pumpWidget(buildFrame(TextDirection.rtl)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); // horizontalTitleGap: ListTileDefaultValue.horizontalTitleGap (16.0) expect(right('title'), 744.0); }); testWidgets('ListTile horizontalTitleGap with visualDensity', (WidgetTester tester) async { Widget buildFrame({ double? horizontalTitleGap, VisualDensity? visualDensity, }) { return MaterialApp( theme: ThemeData(useMaterial3: true), home: Directionality( textDirection: TextDirection.ltr, child: Material( child: Container( alignment: Alignment.topLeft, child: ListTile( visualDensity: visualDensity, horizontalTitleGap: horizontalTitleGap, leading: const Text('L'), title: const Text('title'), trailing: const Text('T'), ), ), ), ), ); } double left(String text) => tester.getTopLeft(find.text(text)).dx; await tester.pumpWidget(buildFrame( horizontalTitleGap: 10.0, visualDensity: const VisualDensity(horizontal: VisualDensity.minimumDensity), )); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); expect(left('title'), 42.0); // Pump another frame of the same widget to ensure the underlying render // object did not cache the original horizontalTitleGap calculation based on the // visualDensity await tester.pumpWidget(buildFrame( horizontalTitleGap: 10.0, visualDensity: const VisualDensity(horizontal: VisualDensity.minimumDensity), )); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); expect(left('title'), 42.0); }); testWidgets('ListTile minVerticalPadding = 80.0', (WidgetTester tester) async { Widget buildFrame(TextDirection textDirection, { double? themeMinVerticalPadding, double? widgetMinVerticalPadding }) { return MaterialApp( theme: ThemeData(useMaterial3: true), home: Directionality( textDirection: textDirection, child: Material( child: ListTileTheme( data: ListTileThemeData(minVerticalPadding: themeMinVerticalPadding), child: Container( alignment: Alignment.topLeft, child: ListTile( minVerticalPadding: widgetMinVerticalPadding, leading: const Text('L'), title: const Text('title'), trailing: const Text('T'), ), ), ), ), ), ); } await tester.pumpWidget(buildFrame(TextDirection.ltr, widgetMinVerticalPadding: 80)); // 80 + 80 + 16(Title) = 176 expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 184.0)); await tester.pumpWidget(buildFrame(TextDirection.ltr, themeMinVerticalPadding: 80)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 184.0)); await tester.pumpWidget(buildFrame(TextDirection.ltr, themeMinVerticalPadding: 0, widgetMinVerticalPadding: 80)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 184.0)); await tester.pumpWidget(buildFrame(TextDirection.rtl, widgetMinVerticalPadding: 80)); // 80 + 80 + 16(Title) = 176 expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 184.0)); await tester.pumpWidget(buildFrame(TextDirection.rtl, themeMinVerticalPadding: 80)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 184.0)); await tester.pumpWidget(buildFrame(TextDirection.rtl, themeMinVerticalPadding: 0, widgetMinVerticalPadding: 80)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 184.0)); }); testWidgets('ListTile minLeadingWidth = 60.0', (WidgetTester tester) async { Widget buildFrame(TextDirection textDirection, { double? themeMinLeadingWidth, double? widgetMinLeadingWidth }) { return MediaQuery( data: const MediaQueryData(), child: Directionality( textDirection: textDirection, child: Material( child: ListTileTheme( data: ListTileThemeData(minLeadingWidth: themeMinLeadingWidth), child: Container( alignment: Alignment.topLeft, child: ListTile( minLeadingWidth: widgetMinLeadingWidth, leading: const Text('L'), title: const Text('title'), trailing: const Text('T'), ), ), ), ), ), ); } double left(String text) => tester.getTopLeft(find.text(text)).dx; double right(String text) => tester.getTopRight(find.text(text)).dx; await tester.pumpWidget(buildFrame(TextDirection.ltr, widgetMinLeadingWidth: 60)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); // 92.0 = 16.0(Default contentPadding) + 16.0(Default horizontalTitleGap) + 60.0 expect(left('title'), 92.0); await tester.pumpWidget(buildFrame(TextDirection.ltr, themeMinLeadingWidth: 60)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); expect(left('title'), 92.0); await tester.pumpWidget(buildFrame(TextDirection.ltr, themeMinLeadingWidth: 0, widgetMinLeadingWidth: 60)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); expect(left('title'), 92.0); await tester.pumpWidget(buildFrame(TextDirection.rtl, widgetMinLeadingWidth: 60)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); // 708.0 = 800.0 - (16.0(Default contentPadding) + 16.0(Default horizontalTitleGap) + 60.0) expect(right('title'), 708.0); await tester.pumpWidget(buildFrame(TextDirection.rtl, themeMinLeadingWidth: 60)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); expect(right('title'), 708.0); await tester.pumpWidget(buildFrame(TextDirection.rtl, themeMinLeadingWidth: 0, widgetMinLeadingWidth: 60)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); expect(right('title'), 708.0); }); testWidgets('colors are applied to leading and trailing text widgets', (WidgetTester tester) async { final Key leadingKey = UniqueKey(); final Key trailingKey = UniqueKey(); late ThemeData theme; Widget buildFrame({ bool enabled = true, bool selected = false, }) { return MaterialApp( theme: ThemeData(useMaterial3: false), home: Material( child: Center( child: Builder( builder: (BuildContext context) { theme = Theme.of(context); return ListTile( enabled: enabled, selected: selected, leading: TestText('leading', key: leadingKey), title: const TestText('title'), trailing: TestText('trailing', key: trailingKey), ); }, ), ), ), ); } Color textColor(Key key) => tester.state<TestTextState>(find.byKey(key)).textStyle.color!; await tester.pumpWidget(buildFrame()); // Enabled color should be default bodyMedium color. expect(textColor(leadingKey), theme.textTheme.bodyMedium!.color); expect(textColor(trailingKey), theme.textTheme.bodyMedium!.color); await tester.pumpWidget(buildFrame(selected: true)); // Wait for text color to animate. await tester.pumpAndSettle(); // Selected color should be ThemeData.primaryColor by default. expect(textColor(leadingKey), theme.primaryColor); expect(textColor(trailingKey), theme.primaryColor); await tester.pumpWidget(buildFrame(enabled: false)); // Wait for text color to animate. await tester.pumpAndSettle(); // Disabled color should be ThemeData.disabledColor by default. expect(textColor(leadingKey), theme.disabledColor); expect(textColor(trailingKey), theme.disabledColor); }); testWidgets('selected, enabled ListTile default icon color', (WidgetTester tester) async { final ThemeData theme = ThemeData(useMaterial3: true); final ColorScheme colorScheme = theme.colorScheme; final Key leadingKey = UniqueKey(); final Key titleKey = UniqueKey(); final Key subtitleKey = UniqueKey(); final Key trailingKey = UniqueKey(); Widget buildFrame({required bool selected }) { return MaterialApp( theme: theme, home: Material( child: Center( child: ListTile( selected: selected, leading: TestIcon(key: leadingKey), title: TestIcon(key: titleKey), subtitle: TestIcon(key: subtitleKey), trailing: TestIcon(key: trailingKey), ), ), ), ); } Color iconColor(Key key) => tester.state<TestIconState>(find.byKey(key)).iconTheme.color!; await tester.pumpWidget(buildFrame(selected: true)); expect(iconColor(leadingKey), colorScheme.primary); expect(iconColor(titleKey), colorScheme.primary); expect(iconColor(subtitleKey), colorScheme.primary); expect(iconColor(trailingKey), colorScheme.primary); await tester.pumpWidget(buildFrame(selected: false)); expect(iconColor(leadingKey), colorScheme.onSurfaceVariant); expect(iconColor(titleKey), colorScheme.onSurfaceVariant); expect(iconColor(subtitleKey), colorScheme.onSurfaceVariant); expect(iconColor(trailingKey), colorScheme.onSurfaceVariant); }); testWidgets('ListTile font size', (WidgetTester tester) async { Widget buildFrame() { return MaterialApp( theme: ThemeData(useMaterial3: true), home: Material( child: Center( child: Builder( builder: (BuildContext context) { return const ListTile( leading: TestText('leading'), title: TestText('title'), subtitle: TestText('subtitle') , trailing: TestText('trailing'), ); }, ), ), ), ); } // ListTile default text sizes. await tester.pumpWidget(buildFrame()); final RenderParagraph leading = _getTextRenderObject(tester, 'leading'); expect(leading.text.style!.fontSize, 11.0); final RenderParagraph title = _getTextRenderObject(tester, 'title'); expect(title.text.style!.fontSize, 16.0); final RenderParagraph subtitle = _getTextRenderObject(tester, 'subtitle'); expect(subtitle.text.style!.fontSize, 14.0); final RenderParagraph trailing = _getTextRenderObject(tester, 'trailing'); expect(trailing.text.style!.fontSize, 11.0); }); testWidgets('ListTile text color', (WidgetTester tester) async { Widget buildFrame() { return MaterialApp( theme: ThemeData(useMaterial3: true), home: Material( child: Center( child: Builder( builder: (BuildContext context) { return const ListTile( leading: TestText('leading'), title: TestText('title'), subtitle: TestText('subtitle') , trailing: TestText('trailing'), ); }, ), ), ), ); } final ThemeData theme = ThemeData(useMaterial3: true); // ListTile default text colors. await tester.pumpWidget(buildFrame()); final RenderParagraph leading = _getTextRenderObject(tester, 'leading'); expect(leading.text.style!.color, theme.colorScheme.onSurfaceVariant); final RenderParagraph title = _getTextRenderObject(tester, 'title'); expect(title.text.style!.color, theme.colorScheme.onSurface); final RenderParagraph subtitle = _getTextRenderObject(tester, 'subtitle'); expect(subtitle.text.style!.color, theme.colorScheme.onSurfaceVariant); final RenderParagraph trailing = _getTextRenderObject(tester, 'trailing'); expect(trailing.text.style!.color, theme.colorScheme.onSurfaceVariant); }); testWidgets('Default ListTile debugFillProperties', (WidgetTester tester) async { final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); const ListTile().debugFillProperties(builder); final List<String> description = builder.properties .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) .map((DiagnosticsNode node) => node.toString()) .toList(); expect(description, <String>[]); }); testWidgets('ListTile implements debugFillProperties', (WidgetTester tester) async { final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); const ListTile( leading: Text('leading'), title: Text('title'), subtitle: Text('trailing'), trailing: Text('trailing'), isThreeLine: true, dense: true, visualDensity: VisualDensity.standard, shape: RoundedRectangleBorder(), style: ListTileStyle.list, selectedColor: Color(0xff0000ff), iconColor: Color(0xff00ff00), textColor: Color(0xffff0000), titleTextStyle: TextStyle(fontSize: 22), subtitleTextStyle: TextStyle(fontSize: 18), leadingAndTrailingTextStyle: TextStyle(fontSize: 16), contentPadding: EdgeInsets.zero, enabled: false, selected: true, focusColor: Color(0xff00ffff), hoverColor: Color(0xff0000ff), autofocus: true, tileColor: Color(0xffffff00), selectedTileColor: Color(0xff123456), enableFeedback: false, horizontalTitleGap: 4.0, minVerticalPadding: 2.0, minLeadingWidth: 6.0, titleAlignment: ListTileTitleAlignment.bottom, ).debugFillProperties(builder); final List<String> description = builder.properties .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) .map((DiagnosticsNode node) => node.toString()) .toList(); expect( description, equalsIgnoringHashCodes(<String>[ 'leading: Text', 'title: Text', 'subtitle: Text', 'trailing: Text', 'isThreeLine: THREE_LINE', 'dense: true', 'visualDensity: VisualDensity#00000(h: 0.0, v: 0.0)', 'shape: RoundedRectangleBorder(BorderSide(width: 0.0, style: none), BorderRadius.zero)', 'style: ListTileStyle.list', 'selectedColor: Color(0xff0000ff)', 'iconColor: Color(0xff00ff00)', 'textColor: Color(0xffff0000)', 'titleTextStyle: TextStyle(inherit: true, size: 22.0)', 'subtitleTextStyle: TextStyle(inherit: true, size: 18.0)', 'leadingAndTrailingTextStyle: TextStyle(inherit: true, size: 16.0)', 'contentPadding: EdgeInsets.zero', 'enabled: false', 'selected: true', 'focusColor: Color(0xff00ffff)', 'hoverColor: Color(0xff0000ff)', 'autofocus: true', 'tileColor: Color(0xffffff00)', 'selectedTileColor: Color(0xff123456)', 'enableFeedback: false', 'horizontalTitleGap: 4.0', 'minVerticalPadding: 2.0', 'minLeadingWidth: 6.0', 'titleAlignment: ListTileTitleAlignment.bottom', ]), ); }); testWidgets('ListTile.textColor respects MaterialStateColor', (WidgetTester tester) async { bool enabled = false; bool selected = false; const Color defaultColor = Colors.blue; const Color selectedColor = Colors.green; const Color disabledColor = Colors.red; Widget buildFrame() { return MaterialApp( theme: ThemeData(useMaterial3: true), home: Material( child: Center( child: Builder( builder: (BuildContext context) { return ListTile( enabled: enabled, selected: selected, textColor: MaterialStateColor.resolveWith((Set<MaterialState> states) { if (states.contains(MaterialState.disabled)) { return disabledColor; } if (states.contains(MaterialState.selected)) { return selectedColor; } return defaultColor; }), title: const TestText('title'), subtitle: const TestText('subtitle') , ); }, ), ), ), ); } // Test disabled state. await tester.pumpWidget(buildFrame()); RenderParagraph title = _getTextRenderObject(tester, 'title'); expect(title.text.style!.color, disabledColor); // Test enabled state. enabled = true; await tester.pumpWidget(buildFrame()); await tester.pumpAndSettle(); title = _getTextRenderObject(tester, 'title'); expect(title.text.style!.color, defaultColor); // Test selected state. selected = true; await tester.pumpWidget(buildFrame()); await tester.pumpAndSettle(); title = _getTextRenderObject(tester, 'title'); expect(title.text.style!.color, selectedColor); }); testWidgets('ListTile.iconColor respects MaterialStateColor', (WidgetTester tester) async { bool enabled = false; bool selected = false; const Color defaultColor = Colors.blue; const Color selectedColor = Colors.green; const Color disabledColor = Colors.red; final Key leadingKey = UniqueKey(); Widget buildFrame() { return MaterialApp( theme: ThemeData(useMaterial3: true), home: Material( child: Center( child: Builder( builder: (BuildContext context) { return ListTile( enabled: enabled, selected: selected, iconColor: MaterialStateColor.resolveWith((Set<MaterialState> states) { if (states.contains(MaterialState.disabled)) { return disabledColor; } if (states.contains(MaterialState.selected)) { return selectedColor; } return defaultColor; }), leading: TestIcon(key: leadingKey), ); }, ), ), ), ); } Color iconColor(Key key) => tester.state<TestIconState>(find.byKey(key)).iconTheme.color!; // Test disabled state. await tester.pumpWidget(buildFrame()); expect(iconColor(leadingKey), disabledColor); // Test enabled state. enabled = true; await tester.pumpWidget(buildFrame()); await tester.pumpAndSettle(); expect(iconColor(leadingKey), defaultColor); // Test selected state. selected = true; await tester.pumpWidget(buildFrame()); await tester.pumpAndSettle(); expect(iconColor(leadingKey), selectedColor); }); testWidgets('ListTile.iconColor respects iconColor property with icon buttons Material 3 in presence of IconButtonTheme override', (WidgetTester tester) async { const Color iconButtonThemeColor = Colors.blue; const Color listTileIconColor = Colors.green; const Icon leadingIcon = Icon(Icons.favorite); const Icon trailingIcon = Icon(Icons.close); Widget buildFrame() { return MaterialApp( theme: ThemeData( useMaterial3: true, iconButtonTheme: IconButtonThemeData( style: IconButton.styleFrom( foregroundColor: iconButtonThemeColor, ), ), ), home: Material( child: Center( child: Builder( builder: (BuildContext context) { return ListTile( iconColor: listTileIconColor, leading: IconButton(icon: leadingIcon, onPressed: () {}), trailing: IconButton(icon: trailingIcon, onPressed: () {}), ); }, ), ), ), ); } TextStyle? getIconStyle(WidgetTester tester, IconData icon) => tester.widget<RichText>(find.descendant( of: find.byIcon(icon), matching: find.byType(RichText), ), ).text.style; await tester.pumpWidget(buildFrame()); expect(getIconStyle(tester, leadingIcon.icon!)?.color, listTileIconColor); expect(getIconStyle(tester, trailingIcon.icon!)?.color, listTileIconColor); }); testWidgets('ListTile.dense does not throw assertion', (WidgetTester tester) async { // This is a regression test for https://github.com/flutter/flutter/pull/116908 Widget buildFrame({required bool useMaterial3}) { return MaterialApp( theme: ThemeData(useMaterial3: useMaterial3), home: Material( child: Center( child: StatefulBuilder( builder: (BuildContext context, StateSetter setState) { return const ListTile( dense: true, title: Text('Title'), ); }, ), ), ), ); } await tester.pumpWidget(buildFrame(useMaterial3: false)); expect(tester.takeException(), isNull); await tester.pumpWidget(buildFrame(useMaterial3: true)); expect(tester.takeException(), isNull); }); testWidgets('titleAlignment position with title widget', (WidgetTester tester) async { final Key leadingKey = GlobalKey(); final Key trailingKey = GlobalKey(); const double leadingHeight = 24.0; const double titleHeight = 50.0; const double trailingHeight = 24.0; const double minVerticalPadding = 10.0; const double tileHeight = minVerticalPadding * 2 + titleHeight; Widget buildFrame({ ListTileTitleAlignment? titleAlignment }) { return MaterialApp( theme: ThemeData(useMaterial3: true), home: Material( child: Center( child: ListTile( titleAlignment: titleAlignment, minVerticalPadding: minVerticalPadding, leading: SizedBox(key: leadingKey, width: 24.0, height: leadingHeight), title: const SizedBox(width: 20.0, height: titleHeight), trailing: SizedBox(key: trailingKey, width: 24.0, height: trailingHeight), ), ), ), ); } // If [ThemeData.useMaterial3] is true, the default title alignment is // [ListTileTitleAlignment.threeLine], which positions the leading and // trailing widgets center vertically in the tile if the [ListTile.isThreeLine] // property is false. await tester.pumpWidget(buildFrame()); Offset tileOffset = tester.getTopLeft(find.byType(ListTile)); Offset leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); Offset trailingOffset = tester.getTopRight(find.byKey(trailingKey)); // Leading and trailing widgets are centered vertically in the tile. const double centerPosition = (tileHeight / 2) - (leadingHeight / 2); expect(leadingOffset.dy - tileOffset.dy, centerPosition); expect(trailingOffset.dy - tileOffset.dy, centerPosition); // Test [ListTileTitleAlignment.threeLine] alignment. await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.threeLine)); tileOffset = tester.getTopLeft(find.byType(ListTile)); leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); trailingOffset = tester.getTopRight(find.byKey(trailingKey)); // Leading and trailing widgets are centered vertically in the tile, // If the [ListTile.isThreeLine] property is false. expect(leadingOffset.dy - tileOffset.dy, centerPosition); expect(trailingOffset.dy - tileOffset.dy, centerPosition); // Test [ListTileTitleAlignment.titleHeight] alignment. await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.titleHeight)); tileOffset = tester.getTopLeft(find.byType(ListTile)); leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); trailingOffset = tester.getTopRight(find.byKey(trailingKey)); // If the tile height is less than 72.0 pixels, the leading widget is placed // 16.0 pixels below the top of the title widget, and the trailing is centered // vertically in the tile. const double titlePosition = 16.0; expect(leadingOffset.dy - tileOffset.dy, titlePosition); expect(trailingOffset.dy - tileOffset.dy, centerPosition); // Test [ListTileTitleAlignment.top] alignment. await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.top)); tileOffset = tester.getTopLeft(find.byType(ListTile)); leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); trailingOffset = tester.getTopRight(find.byKey(trailingKey)); // Leading and trailing widgets are placed minVerticalPadding below // the top of the title widget. const double topPosition = minVerticalPadding; expect(leadingOffset.dy - tileOffset.dy, topPosition); expect(trailingOffset.dy - tileOffset.dy, topPosition); // Test [ListTileTitleAlignment.center] alignment. await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.center)); tileOffset = tester.getTopLeft(find.byType(ListTile)); leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); trailingOffset = tester.getTopRight(find.byKey(trailingKey)); // Leading and trailing widgets are centered vertically in the tile. expect(leadingOffset.dy - tileOffset.dy, centerPosition); expect(trailingOffset.dy - tileOffset.dy, centerPosition); // Test [ListTileTitleAlignment.bottom] alignment. await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.bottom)); tileOffset = tester.getTopLeft(find.byType(ListTile)); leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); trailingOffset = tester.getTopRight(find.byKey(trailingKey)); // Leading and trailing widgets are placed minVerticalPadding above // the bottom of the subtitle widget. const double bottomPosition = tileHeight - minVerticalPadding - leadingHeight; expect(leadingOffset.dy - tileOffset.dy, bottomPosition); expect(trailingOffset.dy - tileOffset.dy, bottomPosition); }); testWidgets('titleAlignment position with title and subtitle widgets', (WidgetTester tester) async { final Key leadingKey = GlobalKey(); final Key trailingKey = GlobalKey(); const double leadingHeight = 24.0; const double titleHeight = 50.0; const double subtitleHeight = 50.0; const double trailingHeight = 24.0; const double minVerticalPadding = 10.0; const double tileHeight = minVerticalPadding * 2 + titleHeight + subtitleHeight; Widget buildFrame({ ListTileTitleAlignment? titleAlignment }) { return MaterialApp( theme: ThemeData(useMaterial3: true), home: Material( child: Center( child: ListTile( titleAlignment: titleAlignment, minVerticalPadding: minVerticalPadding, leading: SizedBox(key: leadingKey, width: 24.0, height: leadingHeight), title: const SizedBox(width: 20.0, height: titleHeight), subtitle: const SizedBox(width: 20.0, height: subtitleHeight), trailing: SizedBox(key: trailingKey, width: 24.0, height: trailingHeight), ), ), ), ); } // If [ThemeData.useMaterial3] is true, the default title alignment is // [ListTileTitleAlignment.threeLine], which positions the leading and // trailing widgets center vertically in the tile if the [ListTile.isThreeLine] // property is false. await tester.pumpWidget(buildFrame()); Offset tileOffset = tester.getTopLeft(find.byType(ListTile)); Offset leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); Offset trailingOffset = tester.getTopRight(find.byKey(trailingKey)); // Leading and trailing widgets are centered vertically in the tile. const double centerPosition = (tileHeight / 2) - (leadingHeight / 2); expect(leadingOffset.dy - tileOffset.dy, centerPosition); expect(trailingOffset.dy - tileOffset.dy, centerPosition); // Test [ListTileTitleAlignment.threeLine] alignment. await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.threeLine)); tileOffset = tester.getTopLeft(find.byType(ListTile)); leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); trailingOffset = tester.getTopRight(find.byKey(trailingKey)); // Leading and trailing widgets are centered vertically in the tile, // If the [ListTile.isThreeLine] property is false. expect(leadingOffset.dy - tileOffset.dy, centerPosition); expect(trailingOffset.dy - tileOffset.dy, centerPosition); // Test [ListTileTitleAlignment.titleHeight] alignment. await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.titleHeight)); tileOffset = tester.getTopLeft(find.byType(ListTile)); leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); trailingOffset = tester.getTopRight(find.byKey(trailingKey)); // Leading and trailing widgets are positioned 16.0 pixels below the // top of the title widget. const double titlePosition = 16.0; expect(leadingOffset.dy - tileOffset.dy, titlePosition); expect(trailingOffset.dy - tileOffset.dy, titlePosition); // Test [ListTileTitleAlignment.top] alignment. await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.top)); tileOffset = tester.getTopLeft(find.byType(ListTile)); leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); trailingOffset = tester.getTopRight(find.byKey(trailingKey)); // Leading and trailing widgets are placed minVerticalPadding below // the top of the title widget. const double topPosition = minVerticalPadding; expect(leadingOffset.dy - tileOffset.dy, topPosition); expect(trailingOffset.dy - tileOffset.dy, topPosition); // Test [ListTileTitleAlignment.center] alignment. await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.center)); tileOffset = tester.getTopLeft(find.byType(ListTile)); leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); trailingOffset = tester.getTopRight(find.byKey(trailingKey)); // Leading and trailing widgets are centered vertically in the tile. expect(leadingOffset.dy - tileOffset.dy, centerPosition); expect(trailingOffset.dy - tileOffset.dy, centerPosition); // Test [ListTileTitleAlignment.bottom] alignment. await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.bottom)); tileOffset = tester.getTopLeft(find.byType(ListTile)); leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); trailingOffset = tester.getTopRight(find.byKey(trailingKey)); // Leading and trailing widgets are placed minVerticalPadding above // the bottom of the subtitle widget. const double bottomPosition = tileHeight - minVerticalPadding - leadingHeight; expect(leadingOffset.dy - tileOffset.dy, bottomPosition); expect(trailingOffset.dy - tileOffset.dy, bottomPosition); }); testWidgets("ListTile.isThreeLine updates ListTileTitleAlignment.threeLine's alignment", (WidgetTester tester) async { final Key leadingKey = GlobalKey(); final Key trailingKey = GlobalKey(); const double leadingHeight = 24.0; const double titleHeight = 50.0; const double subtitleHeight = 50.0; const double trailingHeight = 24.0; const double minVerticalPadding = 10.0; const double tileHeight = minVerticalPadding * 2 + titleHeight + subtitleHeight; Widget buildFrame({ ListTileTitleAlignment? titleAlignment, bool isThreeLine = false }) { return MaterialApp( theme: ThemeData(useMaterial3: true), home: Material( child: Center( child: ListTile( titleAlignment: titleAlignment, minVerticalPadding: minVerticalPadding, leading: SizedBox(key: leadingKey, width: 24.0, height: leadingHeight), title: const SizedBox(width: 20.0, height: titleHeight), subtitle: const SizedBox(width: 20.0, height: subtitleHeight), trailing: SizedBox(key: trailingKey, width: 24.0, height: trailingHeight), isThreeLine: isThreeLine, ), ), ), ); } // If [ThemeData.useMaterial3] is true, then title alignment should // default to [ListTileTitleAlignment.threeLine]. await tester.pumpWidget(buildFrame()); Offset tileOffset = tester.getTopLeft(find.byType(ListTile)); Offset leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); Offset trailingOffset = tester.getTopRight(find.byKey(trailingKey)); // By default, leading and trailing widgets are centered vertically // in the tile. const double centerPosition = (tileHeight / 2) - (leadingHeight / 2); expect(leadingOffset.dy - tileOffset.dy, centerPosition); expect(trailingOffset.dy - tileOffset.dy, centerPosition); // Set [ListTile.isThreeLine] to true to update the alignment. await tester.pumpWidget(buildFrame(isThreeLine: true)); tileOffset = tester.getTopLeft(find.byType(ListTile)); leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); trailingOffset = tester.getTopRight(find.byKey(trailingKey)); // The leading and trailing widgets are placed minVerticalPadding // to the top of the tile widget. const double topPosition = minVerticalPadding; expect(leadingOffset.dy - tileOffset.dy, topPosition); expect(trailingOffset.dy - tileOffset.dy, topPosition); }); group('Material 2', () { // These tests are only relevant for Material 2. Once Material 2 // support is deprecated and the APIs are removed, these tests // can be deleted. testWidgets('ListTile geometry (LTR)', (WidgetTester tester) async { // See https://material.io/go/design-lists final Key leadingKey = GlobalKey(); final Key trailingKey = GlobalKey(); late bool hasSubtitle; const double leftPadding = 10.0; const double rightPadding = 20.0; Widget buildFrame({ bool dense = false, bool isTwoLine = false, bool isThreeLine = false, double textScaleFactor = 1.0, double? subtitleScaleFactor }) { hasSubtitle = isTwoLine || isThreeLine; subtitleScaleFactor ??= textScaleFactor; return MaterialApp( theme: ThemeData(useMaterial3: false), home: MediaQuery( data: MediaQueryData( padding: const EdgeInsets.only(left: leftPadding, right: rightPadding), textScaleFactor: textScaleFactor, ), child: Material( child: Center( child: ListTile( leading: SizedBox(key: leadingKey, width: 24.0, height: 24.0), title: const Text('title'), subtitle: hasSubtitle ? Text('subtitle', textScaleFactor: subtitleScaleFactor) : null, trailing: SizedBox(key: trailingKey, width: 24.0, height: 24.0), dense: dense, isThreeLine: isThreeLine, ), ), ), ), ); } void testChildren() { expect(find.byKey(leadingKey), findsOneWidget); expect(find.text('title'), findsOneWidget); if (hasSubtitle) { expect(find.text('subtitle'), findsOneWidget); } expect(find.byKey(trailingKey), findsOneWidget); } double left(String text) => tester.getTopLeft(find.text(text)).dx; double top(String text) => tester.getTopLeft(find.text(text)).dy; double bottom(String text) => tester.getBottomLeft(find.text(text)).dy; double height(String text) => tester.getRect(find.text(text)).height; double leftKey(Key key) => tester.getTopLeft(find.byKey(key)).dx; double rightKey(Key key) => tester.getTopRight(find.byKey(key)).dx; double widthKey(Key key) => tester.getSize(find.byKey(key)).width; double heightKey(Key key) => tester.getSize(find.byKey(key)).height; // ListTiles are contained by a SafeArea defined like this: // SafeArea(top: false, bottom: false, minimum: contentPadding) // The default contentPadding is 16.0 on the left and right. void testHorizontalGeometry() { expect(leftKey(leadingKey), math.max(16.0, leftPadding)); expect(left('title'), 56.0 + math.max(16.0, leftPadding)); if (hasSubtitle) { expect(left('subtitle'), 56.0 + math.max(16.0, leftPadding)); } expect(left('title'), rightKey(leadingKey) + 32.0); expect(rightKey(trailingKey), 800.0 - math.max(16.0, rightPadding)); expect(widthKey(trailingKey), 24.0); } void testVerticalGeometry(double expectedHeight) { final Rect tileRect = tester.getRect(find.byType(ListTile)); expect(tileRect.size, Size(800.0, expectedHeight)); expect(top('title'), greaterThanOrEqualTo(tileRect.top)); if (hasSubtitle) { expect(top('subtitle'), greaterThanOrEqualTo(bottom('title'))); expect(bottom('subtitle'), lessThan(tileRect.bottom)); } else { expect(top('title'), equals(tileRect.top + (tileRect.height - height('title')) / 2.0)); } expect(heightKey(trailingKey), 24.0); } await tester.pumpWidget(buildFrame()); testChildren(); testHorizontalGeometry(); testVerticalGeometry(56.0); await tester.pumpWidget(buildFrame(dense: true)); testChildren(); testHorizontalGeometry(); testVerticalGeometry(48.0); await tester.pumpWidget(buildFrame(isTwoLine: true)); testChildren(); testHorizontalGeometry(); testVerticalGeometry(72.0); await tester.pumpWidget(buildFrame(isTwoLine: true, dense: true)); testChildren(); testHorizontalGeometry(); testVerticalGeometry(64.0); await tester.pumpWidget(buildFrame(isThreeLine: true)); testChildren(); testHorizontalGeometry(); testVerticalGeometry(88.0); await tester.pumpWidget(buildFrame(isThreeLine: true, dense: true)); testChildren(); testHorizontalGeometry(); testVerticalGeometry(76.0); await tester.pumpWidget(buildFrame(textScaleFactor: 4.0)); testChildren(); testHorizontalGeometry(); testVerticalGeometry(72.0); await tester.pumpWidget(buildFrame(dense: true, textScaleFactor: 4.0)); testChildren(); testHorizontalGeometry(); testVerticalGeometry(72.0); await tester.pumpWidget(buildFrame(isTwoLine: true, textScaleFactor: 4.0)); testChildren(); testHorizontalGeometry(); testVerticalGeometry(128.0); // Make sure that the height of a large subtitle is taken into account. await tester.pumpWidget(buildFrame(isTwoLine: true, textScaleFactor: 0.5, subtitleScaleFactor: 4.0)); testChildren(); testHorizontalGeometry(); testVerticalGeometry(72.0); await tester.pumpWidget(buildFrame(isTwoLine: true, dense: true, textScaleFactor: 4.0)); testChildren(); testHorizontalGeometry(); testVerticalGeometry(128.0); await tester.pumpWidget(buildFrame(isThreeLine: true, textScaleFactor: 4.0)); testChildren(); testHorizontalGeometry(); testVerticalGeometry(128.0); await tester.pumpWidget(buildFrame(isThreeLine: true, dense: true, textScaleFactor: 4.0)); testChildren(); testHorizontalGeometry(); testVerticalGeometry(128.0); }); testWidgets('ListTile geometry (RTL)', (WidgetTester tester) async { const double leftPadding = 10.0; const double rightPadding = 20.0; await tester.pumpWidget(MaterialApp( theme: ThemeData(useMaterial3: false), home: const MediaQuery( data: MediaQueryData( padding: EdgeInsets.only(left: leftPadding, right: rightPadding), ), child: Directionality( textDirection: TextDirection.rtl, child: Material( child: Center( child: ListTile( leading: Text('L'), title: Text('title'), trailing: Text('T'), ), ), ), ), ), )); double left(String text) => tester.getTopLeft(find.text(text)).dx; double right(String text) => tester.getTopRight(find.text(text)).dx; void testHorizontalGeometry() { expect(right('L'), 800.0 - math.max(16.0, rightPadding)); expect(right('title'), 800.0 - 56.0 - math.max(16.0, rightPadding)); expect(left('T'), math.max(16.0, leftPadding)); } testHorizontalGeometry(); }); testWidgets('ListTile leading and trailing positions', (WidgetTester tester) async { // This test is based on the redlines at // https://material.io/design/components/lists.html#specs // DENSE "ONE"-LINE await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: false), home: Material( child: ListView( children: const <Widget>[ ListTile( dense: true, leading: CircleAvatar(), trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), title: Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), ), ListTile( dense: true, leading: CircleAvatar(), trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), title: Text('A'), ), ], ), ), ), ); // LEFT TOP WIDTH HEIGHT expect(tester.getRect(find.byType(ListTile).at(0)), const Rect.fromLTWH( 0.0, 0.0, 800.0, 177.0)); expect(tester.getRect(find.byType(CircleAvatar).at(0)), const Rect.fromLTWH( 16.0, 16.0, 40.0, 40.0)); expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 16.0, 24.0, 24.0)); expect(tester.getRect(find.byType(ListTile).at(1)), const Rect.fromLTWH( 0.0, 177.0, 800.0, 48.0)); expect(tester.getRect(find.byType(CircleAvatar).at(1)), const Rect.fromLTWH( 16.0, 177.0 + 4.0, 40.0, 40.0)); expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 177.0 + 12.0, 24.0, 24.0)); // NON-DENSE "ONE"-LINE await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: false), home: Material( child: ListView( children: const <Widget>[ ListTile( leading: CircleAvatar(), trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), title: Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), ), ListTile( leading: CircleAvatar(), trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), title: Text('A'), ), ], ), ), ), ); await tester.pump(const Duration(seconds: 2)); // the text styles are animated when we change dense // LEFT TOP WIDTH HEIGHT expect(tester.getRect(find.byType(ListTile).at(0)), const Rect.fromLTWH( 0.0, 0.0, 800.0, 216.0)); expect(tester.getRect(find.byType(CircleAvatar).at(0)), const Rect.fromLTWH( 16.0, 16.0, 40.0, 40.0)); expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 16.0, 24.0, 24.0)); expect(tester.getRect(find.byType(ListTile).at(1)), const Rect.fromLTWH( 0.0, 216.0 , 800.0, 56.0)); expect(tester.getRect(find.byType(CircleAvatar).at(1)), const Rect.fromLTWH( 16.0, 216.0 + 8.0, 40.0, 40.0)); expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 216.0 + 16.0, 24.0, 24.0)); // DENSE "TWO"-LINE await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: false), home: Material( child: ListView( children: const <Widget>[ ListTile( dense: true, leading: CircleAvatar(), trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), title: Text('A'), subtitle: Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), ), ListTile( dense: true, leading: CircleAvatar(), trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), title: Text('A'), subtitle: Text('A'), ), ], ), ), ), ); // LEFT TOP WIDTH HEIGHT expect(tester.getRect(find.byType(ListTile).at(0)), const Rect.fromLTWH( 0.0, 0.0, 800.0, 180.0)); expect(tester.getRect(find.byType(CircleAvatar).at(0)), const Rect.fromLTWH( 16.0, 16.0, 40.0, 40.0)); expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 16.0, 24.0, 24.0)); expect(tester.getRect(find.byType(ListTile).at(1)), const Rect.fromLTWH( 0.0, 180.0, 800.0, 64.0)); expect(tester.getRect(find.byType(CircleAvatar).at(1)), const Rect.fromLTWH( 16.0, 180.0 + 12.0, 40.0, 40.0)); expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 180.0 + 20.0, 24.0, 24.0)); // NON-DENSE "TWO"-LINE await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: false), home: Material( child: ListView( children: const <Widget>[ ListTile( leading: CircleAvatar(), trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), title: Text('A'), subtitle: Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), ), ListTile( leading: CircleAvatar(), trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), title: Text('A'), subtitle: Text('A'), ), ], ), ), ), ); // LEFT TOP WIDTH HEIGHT expect(tester.getRect(find.byType(ListTile).at(0)), const Rect.fromLTWH( 0.0, 0.0, 800.0, 180.0)); expect(tester.getRect(find.byType(CircleAvatar).at(0)), const Rect.fromLTWH( 16.0, 16.0, 40.0, 40.0)); expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 16.0, 24.0, 24.0)); expect(tester.getRect(find.byType(ListTile).at(1)), const Rect.fromLTWH( 0.0, 180.0, 800.0, 72.0)); expect(tester.getRect(find.byType(CircleAvatar).at(1)), const Rect.fromLTWH( 16.0, 180.0 + 16.0, 40.0, 40.0)); expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 180.0 + 24.0, 24.0, 24.0)); // DENSE "THREE"-LINE await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: false), home: Material( child: ListView( children: const <Widget>[ ListTile( dense: true, isThreeLine: true, leading: CircleAvatar(), trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), title: Text('A'), subtitle: Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), ), ListTile( dense: true, isThreeLine: true, leading: CircleAvatar(), trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), title: Text('A'), subtitle: Text('A'), ), ], ), ), ), ); // LEFT TOP WIDTH HEIGHT expect(tester.getRect(find.byType(ListTile).at(0)), const Rect.fromLTWH( 0.0, 0.0, 800.0, 180.0)); expect(tester.getRect(find.byType(CircleAvatar).at(0)), const Rect.fromLTWH( 16.0, 16.0, 40.0, 40.0)); expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 16.0, 24.0, 24.0)); expect(tester.getRect(find.byType(ListTile).at(1)), const Rect.fromLTWH( 0.0, 180.0, 800.0, 76.0)); expect(tester.getRect(find.byType(CircleAvatar).at(1)), const Rect.fromLTWH( 16.0, 180.0 + 16.0, 40.0, 40.0)); expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 180.0 + 16.0, 24.0, 24.0)); // NON-DENSE THREE-LINE await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: false), home: Material( child: ListView( children: const <Widget>[ ListTile( isThreeLine: true, leading: CircleAvatar(), trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), title: Text('A'), subtitle: Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), ), ListTile( isThreeLine: true, leading: CircleAvatar(), trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), title: Text('A'), subtitle: Text('A'), ), ], ), ), ), ); // LEFT TOP WIDTH HEIGHT expect(tester.getRect(find.byType(ListTile).at(0)), const Rect.fromLTWH( 0.0, 0.0, 800.0, 180.0)); expect(tester.getRect(find.byType(CircleAvatar).at(0)), const Rect.fromLTWH( 16.0, 16.0, 40.0, 40.0)); expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 16.0, 24.0, 24.0)); expect(tester.getRect(find.byType(ListTile).at(1)), const Rect.fromLTWH( 0.0, 180.0, 800.0, 88.0)); expect(tester.getRect(find.byType(CircleAvatar).at(1)), const Rect.fromLTWH( 16.0, 180.0 + 16.0, 40.0, 40.0)); expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 180.0 + 16.0, 24.0, 24.0)); // "ONE-LINE" with Small Leading Widget await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: false), home: Material( child: ListView( children: const <Widget>[ ListTile( leading: SizedBox(height:12.0, width:24.0, child: Placeholder()), trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), title: Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), ), ListTile( leading: SizedBox(height:12.0, width:24.0, child: Placeholder()), trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), title: Text('A'), ), ], ), ), ), ); await tester.pump(const Duration(seconds: 2)); // the text styles are animated when we change dense // LEFT TOP WIDTH HEIGHT expect(tester.getRect(find.byType(ListTile).at(0)), const Rect.fromLTWH( 0.0, 0.0, 800.0, 216.0)); expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH( 16.0, 16.0, 24.0, 12.0)); expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 16.0, 24.0, 24.0)); expect(tester.getRect(find.byType(ListTile).at(1)), const Rect.fromLTWH( 0.0, 216.0 , 800.0, 56.0)); expect(tester.getRect(find.byType(Placeholder).at(2)), const Rect.fromLTWH( 16.0, 216.0 + 16.0, 24.0, 12.0)); expect(tester.getRect(find.byType(Placeholder).at(3)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 216.0 + 16.0, 24.0, 24.0)); }); testWidgets('ListTile leading icon height does not exceed ListTile height', (WidgetTester tester) async { // regression test for https://github.com/flutter/flutter/issues/28765 const SizedBox oversizedWidget = SizedBox(height: 80.0, width: 24.0, child: Placeholder()); // Dense One line await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: false), home: Material( child: ListView( children: const <Widget>[ ListTile( leading: oversizedWidget, title: Text('A'), dense: true, ), ListTile( leading: oversizedWidget, title: Text('B'), dense: true, ), ], ), ), ), ); expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(16.0, 0.0, 24.0, 48.0)); expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(16.0, 48.0, 24.0, 48.0)); // Non-dense One line await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: false), home: Material( child: ListView( children: const <Widget>[ ListTile( leading: oversizedWidget, title: Text('A'), dense: false, ), ListTile( leading: oversizedWidget, title: Text('B'), dense: false, ), ], ), ), ), ); expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(16.0, 0.0, 24.0, 56.0)); expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(16.0, 56.0, 24.0, 56.0)); // Dense Two line await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: false), home: Material( child: ListView( children: const <Widget>[ ListTile( leading: oversizedWidget, title: Text('A'), subtitle: Text('A'), dense: true, ), ListTile( leading: oversizedWidget, title: Text('B'), subtitle: Text('B'), dense: true, ), ], ), ), ), ); expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(16.0, 8.0, 24.0, 48.0)); expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(16.0, 64.0 + 8.0, 24.0, 48.0)); // Non-dense Two line await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: false), home: Material( child: ListView( children: const <Widget>[ ListTile( leading: oversizedWidget, title: Text('A'), subtitle: Text('A'), dense: false, ), ListTile( leading: oversizedWidget, title: Text('B'), subtitle: Text('B'), dense: false, ), ], ), ), ), ); expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(16.0, 8.0, 24.0, 56.0)); expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(16.0, 72.0 + 8.0, 24.0, 56.0)); // Dense Three line await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: false), home: Material( child: ListView( children: const <Widget>[ ListTile( leading: oversizedWidget, title: Text('A'), subtitle: Text('A'), isThreeLine: true, dense: true, ), ListTile( leading: oversizedWidget, title: Text('B'), subtitle: Text('B'), isThreeLine: true, dense: true, ), ], ), ), ), ); expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(16.0, 16.0, 24.0, 48.0)); expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(16.0, 76.0 + 16.0, 24.0, 48.0)); // Non-dense Three line await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: false), home: Material( child: ListView( children: const <Widget>[ ListTile( leading: oversizedWidget, title: Text('A'), subtitle: Text('A'), isThreeLine: true, dense: false, ), ListTile( leading: oversizedWidget, title: Text('B'), subtitle: Text('B'), isThreeLine: true, dense: false, ), ], ), ), ), ); expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(16.0, 16.0, 24.0, 56.0)); expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(16.0, 88.0 + 16.0, 24.0, 56.0)); }); testWidgets('ListTile trailing icon height does not exceed ListTile height', (WidgetTester tester) async { // regression test for https://github.com/flutter/flutter/issues/28765 const SizedBox oversizedWidget = SizedBox(height: 80.0, width: 24.0, child: Placeholder()); // Dense One line await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: false), home: Material( child: ListView( children: const <Widget>[ ListTile( trailing: oversizedWidget, title: Text('A'), dense: true, ), ListTile( trailing: oversizedWidget, title: Text('B'), dense: true, ), ], ), ), ), ); expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 0, 24.0, 48.0)); expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 48.0, 24.0, 48.0)); // Non-dense One line await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: false), home: Material( child: ListView( children: const <Widget>[ ListTile( trailing: oversizedWidget, title: Text('A'), dense: false, ), ListTile( trailing: oversizedWidget, title: Text('B'), dense: false, ), ], ), ), ), ); expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 0.0, 24.0, 56.0)); expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 56.0, 24.0, 56.0)); // Dense Two line await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: false), home: Material( child: ListView( children: const <Widget>[ ListTile( trailing: oversizedWidget, title: Text('A'), subtitle: Text('A'), dense: true, ), ListTile( trailing: oversizedWidget, title: Text('B'), subtitle: Text('B'), dense: true, ), ], ), ), ), ); expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 8.0, 24.0, 48.0)); expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 64.0 + 8.0, 24.0, 48.0)); // Non-dense Two line await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: false), home: Material( child: ListView( children: const <Widget>[ ListTile( trailing: oversizedWidget, title: Text('A'), subtitle: Text('A'), dense: false, ), ListTile( trailing: oversizedWidget, title: Text('B'), subtitle: Text('B'), dense: false, ), ], ), ), ), ); expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 8.0, 24.0, 56.0)); expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 72.0 + 8.0, 24.0, 56.0)); // Dense Three line await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: false), home: Material( child: ListView( children: const <Widget>[ ListTile( trailing: oversizedWidget, title: Text('A'), subtitle: Text('A'), isThreeLine: true, dense: true, ), ListTile( trailing: oversizedWidget, title: Text('B'), subtitle: Text('B'), isThreeLine: true, dense: true, ), ], ), ), ), ); expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 16.0, 24.0, 48.0)); expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 76.0 + 16.0, 24.0, 48.0)); // Non-dense Three line await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: false), home: Material( child: ListView( children: const <Widget>[ ListTile( trailing: oversizedWidget, title: Text('A'), subtitle: Text('A'), isThreeLine: true, dense: false, ), ListTile( trailing: oversizedWidget, title: Text('B'), subtitle: Text('B'), isThreeLine: true, dense: false, ), ], ), ), ), ); expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 16.0, 24.0, 56.0)); expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 88.0 + 16.0, 24.0, 56.0)); }); testWidgets('ListTile wide leading Widget', (WidgetTester tester) async { const Key leadingKey = ValueKey<String>('L'); Widget buildFrame(double leadingWidth, TextDirection textDirection) { return MaterialApp( theme: ThemeData(useMaterial3: false), home: Directionality( textDirection: textDirection, child: Material( child: Container( alignment: Alignment.topLeft, child: ListTile( contentPadding: EdgeInsets.zero, leading: SizedBox(key: leadingKey, width: leadingWidth, height: 32.0), title: const Text('title'), subtitle: const Text('subtitle'), ), ), ), ), ); } double left(String text) => tester.getTopLeft(find.text(text)).dx; double right(String text) => tester.getTopRight(find.text(text)).dx; // textDirection = LTR // Two-line tile's height = 72, leading 24x32 widget is positioned 16.0 pixels from the top. await tester.pumpWidget(buildFrame(24.0, TextDirection.ltr)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 72.0)); expect(tester.getTopLeft(find.byKey(leadingKey)), const Offset(0.0, 16.0)); expect(tester.getBottomRight(find.byKey(leadingKey)), const Offset(24.0, 16.0 + 32.0)); // Leading widget's width is 20, so default layout: the left edges of the // title and subtitle are at 56dps (contentPadding is zero). expect(left('title'), 56.0); expect(left('subtitle'), 56.0); // If the leading widget is wider than 40 it is separated from the // title and subtitle by 16. await tester.pumpWidget(buildFrame(56.0, TextDirection.ltr)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 72.0)); expect(tester.getTopLeft(find.byKey(leadingKey)), const Offset(0.0, 16.0)); expect(tester.getBottomRight(find.byKey(leadingKey)), const Offset(56.0, 16.0 + 32.0)); expect(left('title'), 72.0); expect(left('subtitle'), 72.0); // Same tests, textDirection = RTL await tester.pumpWidget(buildFrame(24.0, TextDirection.rtl)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 72.0)); expect(tester.getTopRight(find.byKey(leadingKey)), const Offset(800.0, 16.0)); expect(tester.getBottomLeft(find.byKey(leadingKey)), const Offset(800.0 - 24.0, 16.0 + 32.0)); expect(right('title'), 800.0 - 56.0); expect(right('subtitle'), 800.0 - 56.0); await tester.pumpWidget(buildFrame(56.0, TextDirection.rtl)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 72.0)); expect(tester.getTopRight(find.byKey(leadingKey)), const Offset(800.0, 16.0)); expect(tester.getBottomLeft(find.byKey(leadingKey)), const Offset(800.0 - 56.0, 16.0 + 32.0)); expect(right('title'), 800.0 - 72.0); expect(right('subtitle'), 800.0 - 72.0); }); testWidgets('ListTile horizontalTitleGap = 0.0', (WidgetTester tester) async { Widget buildFrame(TextDirection textDirection, { double? themeHorizontalTitleGap, double? widgetHorizontalTitleGap }) { return MaterialApp( theme: ThemeData(useMaterial3: false), home: Directionality( textDirection: textDirection, child: Material( child: ListTileTheme( data: ListTileThemeData(horizontalTitleGap: themeHorizontalTitleGap), child: Container( alignment: Alignment.topLeft, child: ListTile( horizontalTitleGap: widgetHorizontalTitleGap, leading: const Text('L'), title: const Text('title'), trailing: const Text('T'), ), ), ), ), ), ); } double left(String text) => tester.getTopLeft(find.text(text)).dx; double right(String text) => tester.getTopRight(find.text(text)).dx; await tester.pumpWidget(buildFrame(TextDirection.ltr, widgetHorizontalTitleGap: 0)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); expect(left('title'), 56.0); await tester.pumpWidget(buildFrame(TextDirection.ltr, themeHorizontalTitleGap: 0)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); expect(left('title'), 56.0); await tester.pumpWidget(buildFrame(TextDirection.ltr, themeHorizontalTitleGap: 10, widgetHorizontalTitleGap: 0)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); expect(left('title'), 56.0); await tester.pumpWidget(buildFrame(TextDirection.rtl, widgetHorizontalTitleGap: 0)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); expect(right('title'), 744.0); await tester.pumpWidget(buildFrame(TextDirection.rtl, themeHorizontalTitleGap: 0)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); expect(right('title'), 744.0); await tester.pumpWidget(buildFrame(TextDirection.rtl, themeHorizontalTitleGap: 10, widgetHorizontalTitleGap: 0)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); expect(right('title'), 744.0); }); testWidgets('ListTile horizontalTitleGap = (default) && ListTile minLeadingWidth = (default)', (WidgetTester tester) async { Widget buildFrame(TextDirection textDirection) { return MaterialApp( theme: ThemeData(useMaterial3: false), home: Directionality( textDirection: textDirection, child: Material( child: Container( alignment: Alignment.topLeft, child: const ListTile( leading: Text('L'), title: Text('title'), trailing: Text('T'), ), ), ), ), ); } double left(String text) => tester.getTopLeft(find.text(text)).dx; double right(String text) => tester.getTopRight(find.text(text)).dx; await tester.pumpWidget(buildFrame(TextDirection.ltr)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); // horizontalTitleGap: ListTileDefaultValue.horizontalTitleGap (16.0) expect(left('title'), 72.0); await tester.pumpWidget(buildFrame(TextDirection.rtl)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); // horizontalTitleGap: ListTileDefaultValue.horizontalTitleGap (16.0) expect(right('title'), 728.0); }); testWidgets('ListTile horizontalTitleGap with visualDensity', (WidgetTester tester) async { Widget buildFrame({ double? horizontalTitleGap, VisualDensity? visualDensity, }) { return MaterialApp( theme: ThemeData(useMaterial3: false), home: Directionality( textDirection: TextDirection.ltr, child: Material( child: Container( alignment: Alignment.topLeft, child: ListTile( visualDensity: visualDensity, horizontalTitleGap: horizontalTitleGap, leading: const Text('L'), title: const Text('title'), trailing: const Text('T'), ), ), ), ), ); } double left(String text) => tester.getTopLeft(find.text(text)).dx; await tester.pumpWidget(buildFrame( horizontalTitleGap: 10.0, visualDensity: const VisualDensity(horizontal: VisualDensity.minimumDensity), )); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); expect(left('title'), 58.0); // Pump another frame of the same widget to ensure the underlying render // object did not cache the original horizontalTitleGap calculation based on the // visualDensity await tester.pumpWidget(buildFrame( horizontalTitleGap: 10.0, visualDensity: const VisualDensity(horizontal: VisualDensity.minimumDensity), )); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); expect(left('title'), 58.0); }); testWidgets('ListTile minVerticalPadding = 80.0', (WidgetTester tester) async { Widget buildFrame(TextDirection textDirection, { double? themeMinVerticalPadding, double? widgetMinVerticalPadding }) { return MaterialApp( theme: ThemeData(useMaterial3: false), home: Directionality( textDirection: textDirection, child: Material( child: ListTileTheme( data: ListTileThemeData(minVerticalPadding: themeMinVerticalPadding), child: Container( alignment: Alignment.topLeft, child: ListTile( minVerticalPadding: widgetMinVerticalPadding, leading: const Text('L'), title: const Text('title'), trailing: const Text('T'), ), ), ), ), ), ); } await tester.pumpWidget(buildFrame(TextDirection.ltr, widgetMinVerticalPadding: 80)); // 80 + 80 + 16(Title) = 176 expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); await tester.pumpWidget(buildFrame(TextDirection.ltr, themeMinVerticalPadding: 80)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); await tester.pumpWidget(buildFrame(TextDirection.ltr, themeMinVerticalPadding: 0, widgetMinVerticalPadding: 80)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); await tester.pumpWidget(buildFrame(TextDirection.rtl, widgetMinVerticalPadding: 80)); // 80 + 80 + 16(Title) = 176 expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); await tester.pumpWidget(buildFrame(TextDirection.rtl, themeMinVerticalPadding: 80)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); await tester.pumpWidget(buildFrame(TextDirection.rtl, themeMinVerticalPadding: 0, widgetMinVerticalPadding: 80)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); }); testWidgets('ListTile font size', (WidgetTester tester) async { Widget buildFrame({ bool dense = false, bool enabled = true, bool selected = false, ListTileStyle? style, }) { return MaterialApp( theme: ThemeData(useMaterial3: false), home: Material( child: Center( child: Builder( builder: (BuildContext context) { return ListTile( dense: dense, enabled: enabled, selected: selected, style: style, leading: const TestText('leading'), title: const TestText('title'), subtitle: const TestText('subtitle') , trailing: const TestText('trailing'), ); }, ), ), ), ); } // ListTile - ListTileStyle.list (default). await tester.pumpWidget(buildFrame()); RenderParagraph leading = _getTextRenderObject(tester, 'leading'); expect(leading.text.style!.fontSize, 14.0); RenderParagraph title = _getTextRenderObject(tester, 'title'); expect(title.text.style!.fontSize, 16.0); RenderParagraph subtitle = _getTextRenderObject(tester, 'subtitle'); expect(subtitle.text.style!.fontSize, 14.0); RenderParagraph trailing = _getTextRenderObject(tester, 'trailing'); expect(trailing.text.style!.fontSize, 14.0); // ListTile - Densed - ListTileStyle.list (default). await tester.pumpWidget(buildFrame(dense: true)); await tester.pumpAndSettle(); leading = _getTextRenderObject(tester, 'leading'); expect(leading.text.style!.fontSize, 14.0); title = _getTextRenderObject(tester, 'title'); expect(title.text.style!.fontSize, 13.0); subtitle = _getTextRenderObject(tester, 'subtitle'); expect(subtitle.text.style!.fontSize, 12.0); trailing = _getTextRenderObject(tester, 'trailing'); expect(trailing.text.style!.fontSize, 14.0); // ListTile - ListTileStyle.drawer. await tester.pumpWidget(buildFrame(style: ListTileStyle.drawer)); await tester.pumpAndSettle(); leading = _getTextRenderObject(tester, 'leading'); expect(leading.text.style!.fontSize, 14.0); title = _getTextRenderObject(tester, 'title'); expect(title.text.style!.fontSize, 14.0); subtitle = _getTextRenderObject(tester, 'subtitle'); expect(subtitle.text.style!.fontSize, 14.0); trailing = _getTextRenderObject(tester, 'trailing'); expect(trailing.text.style!.fontSize, 14.0); // ListTile - Densed - ListTileStyle.drawer. await tester.pumpWidget(buildFrame(dense: true, style: ListTileStyle.drawer)); await tester.pumpAndSettle(); leading = _getTextRenderObject(tester, 'leading'); expect(leading.text.style!.fontSize, 14.0); title = _getTextRenderObject(tester, 'title'); expect(title.text.style!.fontSize, 13.0); subtitle = _getTextRenderObject(tester, 'subtitle'); expect(subtitle.text.style!.fontSize, 12.0); trailing = _getTextRenderObject(tester, 'trailing'); expect(trailing.text.style!.fontSize, 14.0); }); testWidgets('ListTile text color', (WidgetTester tester) async { final ThemeData theme = ThemeData(useMaterial3: false); Widget buildFrame({ bool dense = false, bool enabled = true, bool selected = false, ListTileStyle? style, }) { return MaterialApp( theme: theme, home: Material( child: Center( child: Builder( builder: (BuildContext context) { return ListTile( dense: dense, enabled: enabled, selected: selected, style: style, leading: const TestText('leading'), title: const TestText('title'), subtitle: const TestText('subtitle') , trailing: const TestText('trailing'), ); }, ), ), ), ); } // ListTile - ListTileStyle.list (default). await tester.pumpWidget(buildFrame()); RenderParagraph leading = _getTextRenderObject(tester, 'leading'); expect(leading.text.style!.color, theme.textTheme.bodyMedium!.color); RenderParagraph title = _getTextRenderObject(tester, 'title'); expect(title.text.style!.color, theme.textTheme.titleMedium!.color); RenderParagraph subtitle = _getTextRenderObject(tester, 'subtitle'); expect(subtitle.text.style!.color, theme.textTheme.bodySmall!.color); RenderParagraph trailing = _getTextRenderObject(tester, 'trailing'); expect(trailing.text.style!.color, theme.textTheme.bodyMedium!.color); // ListTile - ListTileStyle.drawer. await tester.pumpWidget(buildFrame(style: ListTileStyle.drawer)); await tester.pumpAndSettle(); leading = _getTextRenderObject(tester, 'leading'); expect(leading.text.style!.color, theme.textTheme.bodyMedium!.color); title = _getTextRenderObject(tester, 'title'); expect(title.text.style!.color, theme.textTheme.titleMedium!.color); subtitle = _getTextRenderObject(tester, 'subtitle'); expect(subtitle.text.style!.color, theme.textTheme.bodySmall!.color); trailing = _getTextRenderObject(tester, 'trailing'); expect(trailing.text.style!.color, theme.textTheme.bodyMedium!.color); }); testWidgets('selected, enabled ListTile default icon color, light and dark themes', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/pull/77004 const ColorScheme lightColorScheme = ColorScheme.light(); const ColorScheme darkColorScheme = ColorScheme.dark(); final Key leadingKey = UniqueKey(); final Key titleKey = UniqueKey(); final Key subtitleKey = UniqueKey(); final Key trailingKey = UniqueKey(); Widget buildFrame({ required Brightness brightness, required bool selected }) { final ThemeData theme = brightness == Brightness.light ? ThemeData.from(colorScheme: const ColorScheme.light(), useMaterial3: false) : ThemeData.from(colorScheme: const ColorScheme.dark(), useMaterial3: false); return MaterialApp( theme: theme, home: Material( child: Center( child: ListTile( selected: selected, leading: TestIcon(key: leadingKey), title: TestIcon(key: titleKey), subtitle: TestIcon(key: subtitleKey), trailing: TestIcon(key: trailingKey), ), ), ), ); } Color iconColor(Key key) => tester.state<TestIconState>(find.byKey(key)).iconTheme.color!; await tester.pumpWidget(buildFrame(brightness: Brightness.light, selected: true)); expect(iconColor(leadingKey), lightColorScheme.primary); expect(iconColor(titleKey), lightColorScheme.primary); expect(iconColor(subtitleKey), lightColorScheme.primary); expect(iconColor(trailingKey), lightColorScheme.primary); await tester.pumpWidget(buildFrame(brightness: Brightness.light, selected: false)); expect(iconColor(leadingKey), Colors.black45); expect(iconColor(titleKey), Colors.black45); expect(iconColor(subtitleKey), Colors.black45); expect(iconColor(trailingKey), Colors.black45); await tester.pumpWidget(buildFrame(brightness: Brightness.dark, selected: true)); await tester.pumpAndSettle(); // Animated theme change expect(iconColor(leadingKey), darkColorScheme.primary); expect(iconColor(titleKey), darkColorScheme.primary); expect(iconColor(subtitleKey), darkColorScheme.primary); expect(iconColor(trailingKey), darkColorScheme.primary); // For this configuration, ListTile defers to the default IconTheme. // The default dark theme's IconTheme has color:white await tester.pumpWidget(buildFrame(brightness: Brightness.dark, selected: false)); expect(iconColor(leadingKey), Colors.white); expect(iconColor(titleKey), Colors.white); expect(iconColor(subtitleKey), Colors.white); expect(iconColor(trailingKey), Colors.white); }); testWidgets('ListTile default tile color', (WidgetTester tester) async { bool isSelected = false; const Color defaultColor = Colors.transparent; await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: false), home: Material( child: Center( child: StatefulBuilder( builder: (BuildContext context, StateSetter setState) { return ListTile( selected: isSelected, onTap: () { setState(()=> isSelected = !isSelected); }, title: const Text('Title'), ); }, ), ), ), ), ); expect(find.byType(Material), paints..rect(color: defaultColor)); // Tap on tile to change isSelected. await tester.tap(find.byType(ListTile)); await tester.pumpAndSettle(); expect(find.byType(Material), paints..rect(color: defaultColor)); }); testWidgets('titleAlignment position with title widget', (WidgetTester tester) async { final Key leadingKey = GlobalKey(); final Key trailingKey = GlobalKey(); const double leadingHeight = 24.0; const double titleHeight = 50.0; const double trailingHeight = 24.0; const double minVerticalPadding = 10.0; const double tileHeight = minVerticalPadding * 2 + titleHeight; Widget buildFrame({ ListTileTitleAlignment? titleAlignment }) { return MaterialApp( theme: ThemeData(useMaterial3: false), home: Material( child: Center( child: ListTile( titleAlignment: titleAlignment, minVerticalPadding: minVerticalPadding, leading: SizedBox(key: leadingKey, width: 24.0, height: leadingHeight), title: const SizedBox(width: 20.0, height: titleHeight), trailing: SizedBox(key: trailingKey, width: 24.0, height: trailingHeight), ), ), ), ); } // If [ThemeData.useMaterial3] is false, the default title alignment is // [ListTileTitleAlignment.titleHeight], If the tile height is less than // 72.0 pixels, the leading is placed 16.0 pixels below the top of // the title widget and the trailing is centered vertically in the tile. await tester.pumpWidget(buildFrame()); Offset tileOffset = tester.getTopLeft(find.byType(ListTile)); Offset leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); Offset trailingOffset = tester.getTopRight(find.byKey(trailingKey)); // Leading and trailing widgets are centered vertically in the tile. const double titlePosition = 16.0; const double centerPosition = (tileHeight / 2) - (leadingHeight / 2); expect(leadingOffset.dy - tileOffset.dy, titlePosition); expect(trailingOffset.dy - tileOffset.dy, centerPosition); // Test [ListTileTitleAlignment.threeLine] alignment. await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.threeLine)); tileOffset = tester.getTopLeft(find.byType(ListTile)); leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); trailingOffset = tester.getTopRight(find.byKey(trailingKey)); // Leading and trailing widgets are centered vertically in the tile, // If the [ListTile.isThreeLine] property is false. expect(leadingOffset.dy - tileOffset.dy, centerPosition); expect(trailingOffset.dy - tileOffset.dy, centerPosition); // Test [ListTileTitleAlignment.titleHeight] alignment. await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.titleHeight)); tileOffset = tester.getTopLeft(find.byType(ListTile)); leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); trailingOffset = tester.getTopRight(find.byKey(trailingKey)); // If the tile height is less than 72.0 pixels, the leading is placed // 16.0 pixels below the top of the tile widget, and the trailing is // centered vertically in the tile. expect(leadingOffset.dy - tileOffset.dy, titlePosition); expect(trailingOffset.dy - tileOffset.dy, centerPosition); // Test [ListTileTitleAlignment.top] alignment. await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.top)); tileOffset = tester.getTopLeft(find.byType(ListTile)); leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); trailingOffset = tester.getTopRight(find.byKey(trailingKey)); // Leading and trailing widgets are placed minVerticalPadding below // the top of the title widget. const double topPosition = minVerticalPadding; expect(leadingOffset.dy - tileOffset.dy, topPosition); expect(trailingOffset.dy - tileOffset.dy, topPosition); // Test [ListTileTitleAlignment.center] alignment. await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.center)); tileOffset = tester.getTopLeft(find.byType(ListTile)); leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); trailingOffset = tester.getTopRight(find.byKey(trailingKey)); // Leading and trailing widgets are vertically centered in the tile. expect(leadingOffset.dy - tileOffset.dy, centerPosition); expect(trailingOffset.dy - tileOffset.dy, centerPosition); // Test [ListTileTitleAlignment.bottom] alignment. await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.bottom)); tileOffset = tester.getTopLeft(find.byType(ListTile)); leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); trailingOffset = tester.getTopRight(find.byKey(trailingKey)); // Leading and trailing widgets are placed minVerticalPadding above // the bottom of the subtitle widget. const double bottomPosition = tileHeight - minVerticalPadding - leadingHeight; expect(leadingOffset.dy - tileOffset.dy, bottomPosition); expect(trailingOffset.dy - tileOffset.dy, bottomPosition); }); testWidgets('titleAlignment position with title and subtitle widgets', (WidgetTester tester) async { final Key leadingKey = GlobalKey(); final Key trailingKey = GlobalKey(); const double leadingHeight = 24.0; const double titleHeight = 50.0; const double subtitleHeight = 50.0; const double trailingHeight = 24.0; const double minVerticalPadding = 10.0; const double tileHeight = minVerticalPadding * 2 + titleHeight + subtitleHeight; Widget buildFrame({ ListTileTitleAlignment? titleAlignment }) { return MaterialApp( theme: ThemeData(useMaterial3: false), home: Material( child: Center( child: ListTile( titleAlignment: titleAlignment, minVerticalPadding: minVerticalPadding, leading: SizedBox(key: leadingKey, width: 24.0, height: leadingHeight), title: const SizedBox(width: 20.0, height: titleHeight), subtitle: const SizedBox(width: 20.0, height: subtitleHeight), trailing: SizedBox(key: trailingKey, width: 24.0, height: trailingHeight), ), ), ), ); } // If [ThemeData.useMaterial3] is false, the default title alignment is // [ListTileTitleAlignment.titleHeight], which positions the leading and // trailing widgets 16.0 pixels below the top of the tile widget. await tester.pumpWidget(buildFrame()); Offset tileOffset = tester.getTopLeft(find.byType(ListTile)); Offset leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); Offset trailingOffset = tester.getTopRight(find.byKey(trailingKey)); // Leading and trailing widgets are positioned 16.0 pixels below the // top of the tile widget. const double titlePosition = 16.0; expect(leadingOffset.dy - tileOffset.dy, titlePosition); expect(trailingOffset.dy - tileOffset.dy, titlePosition); // Test [ListTileTitleAlignment.threeLine] alignment. await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.threeLine)); tileOffset = tester.getTopLeft(find.byType(ListTile)); leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); trailingOffset = tester.getTopRight(find.byKey(trailingKey)); // Leading and trailing widgets are vertically centered in the tile, // If the [ListTile.isThreeLine] property is false. const double centerPosition = (tileHeight / 2) - (leadingHeight / 2); expect(leadingOffset.dy - tileOffset.dy, centerPosition); expect(trailingOffset.dy - tileOffset.dy, centerPosition); // Test [ListTileTitleAlignment.titleHeight] alignment. await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.titleHeight)); tileOffset = tester.getTopLeft(find.byType(ListTile)); leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); trailingOffset = tester.getTopRight(find.byKey(trailingKey)); // Leading and trailing widgets are positioned 16.0 pixels below the // top of the tile widget. expect(leadingOffset.dy - tileOffset.dy, titlePosition); expect(trailingOffset.dy - tileOffset.dy, titlePosition); // Test [ListTileTitleAlignment.top] alignment. await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.top)); tileOffset = tester.getTopLeft(find.byType(ListTile)); leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); trailingOffset = tester.getTopRight(find.byKey(trailingKey)); // Leading and trailing widgets are placed minVerticalPadding below // the top of the tile widget. const double topPosition = minVerticalPadding; expect(leadingOffset.dy - tileOffset.dy, topPosition); expect(trailingOffset.dy - tileOffset.dy, topPosition); // Test [ListTileTitleAlignment.center] alignment. await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.center)); tileOffset = tester.getTopLeft(find.byType(ListTile)); leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); trailingOffset = tester.getTopRight(find.byKey(trailingKey)); // Leading and trailing widgets are vertically centered in the tile. expect(leadingOffset.dy - tileOffset.dy, centerPosition); expect(trailingOffset.dy - tileOffset.dy, centerPosition); // Test [ListTileTitleAlignment.bottom] alignment. await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.bottom)); tileOffset = tester.getTopLeft(find.byType(ListTile)); leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); trailingOffset = tester.getTopRight(find.byKey(trailingKey)); // Leading and trailing widgets are placed minVerticalPadding above // the bottom of the subtitle widget. const double bottomPosition = tileHeight - minVerticalPadding - leadingHeight; expect(leadingOffset.dy - tileOffset.dy, bottomPosition); expect(trailingOffset.dy - tileOffset.dy, bottomPosition); }); testWidgets("ListTile.isThreeLine updates ListTileTitleAlignment.threeLine's alignment", (WidgetTester tester) async { final Key leadingKey = GlobalKey(); final Key trailingKey = GlobalKey(); const double leadingHeight = 24.0; const double titleHeight = 50.0; const double subtitleHeight = 50.0; const double trailingHeight = 24.0; const double minVerticalPadding = 10.0; const double tileHeight = minVerticalPadding * 2 + titleHeight + subtitleHeight; Widget buildFrame({ ListTileTitleAlignment? titleAlignment, bool isThreeLine = false }) { return MaterialApp( theme: ThemeData(useMaterial3: false), home: Material( child: Center( child: ListTile( titleAlignment: titleAlignment, minVerticalPadding: minVerticalPadding, leading: SizedBox(key: leadingKey, width: 24.0, height: leadingHeight), title: const SizedBox(width: 20.0, height: titleHeight), subtitle: const SizedBox(width: 20.0, height: subtitleHeight), trailing: SizedBox(key: trailingKey, width: 24.0, height: trailingHeight), isThreeLine: isThreeLine, ), ), ), ); } // Set title alignment to threeLine. await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.threeLine)); Offset tileOffset = tester.getTopLeft(find.byType(ListTile)); Offset leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); Offset trailingOffset = tester.getTopRight(find.byKey(trailingKey)); // If title alignment is threeLine and [ListTile.isThreeLine] is false, // leading and trailing widgets are centered vertically in the tile. const double leadingTrailingPosition = (tileHeight / 2) - (leadingHeight / 2); expect(leadingOffset.dy - tileOffset.dy, leadingTrailingPosition); expect(trailingOffset.dy - tileOffset.dy, leadingTrailingPosition); // Set [ListTile.isThreeLine] to true to update the alignment. await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.threeLine, isThreeLine: true)); tileOffset = tester.getTopLeft(find.byType(ListTile)); leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); trailingOffset = tester.getTopRight(find.byKey(trailingKey)); // The leading and trailing widgets are placed minVerticalPadding // to the top of the tile widget. expect(leadingOffset.dy - tileOffset.dy, minVerticalPadding); expect(trailingOffset.dy - tileOffset.dy, minVerticalPadding); }); }); } RenderParagraph _getTextRenderObject(WidgetTester tester, String text) { return tester.renderObject(find.descendant( of: find.byType(ListTile), matching: find.text(text), )); }