blob: 5d0204ff88b8ba96a01469788e01df20095438fd [file] [log] [blame]
// 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(
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(
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(
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.textTheme.labelSmall!.color);
final RenderParagraph title = _getTextRenderObject(tester, 'title');
expect(title.text.style!.color, theme.textTheme.bodyLarge!.color);
final RenderParagraph subtitle = _getTextRenderObject(tester, 'subtitle');
expect(subtitle.text.style!.color, theme.textTheme.bodyMedium!.color);
final RenderParagraph trailing = _getTextRenderObject(tester, 'trailing');
expect(trailing.text.style!.color, theme.textTheme.labelSmall!.color);
});
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', () {
// Tests that are only relevant for Material 2. Once ThemeData.useMaterial3
// is turned on by default, these tests can be removed.
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<