Improved positioning of leading and trailing widgets in overflowing ListTiles (#24767)

This commit is contained in:
Ian Hickson 2019-01-14 12:39:43 -08:00 committed by GitHub
parent 07e06171ba
commit 567db6f0d4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 256 additions and 17 deletions

View file

@ -160,10 +160,9 @@ enum ListTileControlAffinity {
/// is true then the overall height of this tile and the size of the
/// [DefaultTextStyle]s that wrap the [title] and [subtitle] widget are reduced.
///
/// List tiles are always a fixed height (which height depends on how
/// [isThreeLine], [dense], and [subtitle] are configured); they do not grow in
/// height based on their contents. If you are looking for a widget that allows
/// for arbitrary layout in a row, consider [Row].
/// It is the responsibility of the caller to ensure that [title] does not wrap,
/// and to ensure that [subtitle] doesn't wrap (if [isThreeLine] is false) or
/// wraps to two lines (if it is true).
///
/// List tiles are typically used in [ListView]s, or arranged in [Column]s in
/// [Drawer]s and [Card]s.
@ -246,20 +245,36 @@ class ListTile extends StatelessWidget {
/// The primary content of the list tile.
///
/// Typically a [Text] widget.
///
/// This should not wrap.
final Widget title;
/// Additional content displayed below the title.
///
/// Typically a [Text] widget.
///
/// If [isThreeLine] is false, this should not wrap.
///
/// If [isThreeLine] is true, this should be configured to take a maximum of
/// two lines.
final Widget subtitle;
/// A widget to display after the title.
///
/// Typically an [Icon] widget.
///
/// To show right-aligned metadata (assuming left-to-right reading order;
/// left-aligned for right-to-left reading order), consider using a [Row] with
/// [MainAxisAlign.baseline] alignment whose first item is [Expanded] and
/// whose second child is the metadata text, instead of using the [trailing]
/// property.
final Widget trailing;
/// Whether this list tile is intended to display three lines of text.
///
/// If true, then [subtitle] must be non-null (since it is expected to give
/// the second and third lines of text).
///
/// If false, the list tile is treated as having one line if the subtitle is
/// null and treated as having two lines if the subtitle is non-null.
final bool isThreeLine;
@ -267,6 +282,8 @@ class ListTile extends StatelessWidget {
/// Whether this list tile is part of a vertically dense list.
///
/// If this property is null then its value is based on [ListTileTheme.dense].
///
/// Dense list tiles default to a smaller height.
final bool dense;
/// The tile's internal padding.
@ -934,17 +951,19 @@ class _RenderListTile extends RenderBox {
assert(isOneLine);
}
final double defaultTileHeight = _defaultTileHeight;
double tileHeight;
double titleY;
double subtitleY;
if (!hasSubtitle) {
tileHeight = math.max(_defaultTileHeight, titleSize.height + 2.0 * _minVerticalPadding);
tileHeight = math.max(defaultTileHeight, titleSize.height + 2.0 * _minVerticalPadding);
titleY = (tileHeight - titleSize.height) / 2.0;
} else {
assert(subtitleBaselineType != null);
titleY = titleBaseline - _boxBaseline(title, titleBaselineType);
subtitleY = subtitleBaseline - _boxBaseline(subtitle, subtitleBaselineType);
tileHeight = _defaultTileHeight;
tileHeight = defaultTileHeight;
// If the title and subtitle overlap, move the title upwards by half
// the overlap and the subtitle down by the same amount, and adjust
@ -966,8 +985,30 @@ class _RenderListTile extends RenderBox {
}
}
final double leadingY = (tileHeight - leadingSize.height) / 2.0;
final double trailingY = (tileHeight - trailingSize.height) / 2.0;
// This attempts to implement the redlines for the vertical position of the
// leading and trailing icons on the spec page:
// https://material.io/design/components/lists.html#specs
// Some liberties have been taken to handle cases that aren't covered by
// that specification, such as leading and trailing widgets with weird
// sizes, "one-line" list tiles with title widgets that span multiple lines,
// etc.
double leadingY;
double trailingY;
if (isOneLine) {
leadingY = (defaultTileHeight - leadingSize.height) / 2.0;
trailingY = (defaultTileHeight - trailingSize.height) / 2.0;
} else if (isTwoLine) {
if (isDense) {
leadingY = 12.0; // by extrapolation
trailingY = 20.0; // by extrapolation
} else {
leadingY = leadingSize.height <= 40.0 ? 16.0 : 8.0;
trailingY = 24.0;
}
} else {
leadingY = 16.0;
trailingY = 16.0;
}
switch (textDirection) {
case TextDirection.rtl: {

View file

@ -521,11 +521,11 @@ void main() {
// textDirection = LTR
// Two-line tile's height = 72, leading 24x32 widget is vertically centered
// 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, 20.0));
expect(tester.getBottomRight(find.byKey(leadingKey)), const Offset(24.0, 52.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).
@ -536,8 +536,8 @@ void main() {
// 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, 52.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);
@ -545,16 +545,214 @@ void main() {
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, 52.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, 20.0));
expect(tester.getBottomLeft(find.byKey(leadingKey)), const Offset(800.0 - 56.0, 52.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 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(
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)), Rect.fromLTWH( 0.0, 0.0, 800.0, 177.0));
expect(tester.getRect(find.byType(CircleAvatar).at(0)), Rect.fromLTWH( 16.0, 4.0, 40.0, 40.0));
expect(tester.getRect(find.byType(Placeholder).at(0)), Rect.fromLTWH(800.0 - 24.0 - 16.0, 12.0, 24.0, 24.0));
expect(tester.getRect(find.byType(ListTile).at(1)), Rect.fromLTWH( 0.0, 177.0, 800.0, 48.0));
expect(tester.getRect(find.byType(CircleAvatar).at(1)), Rect.fromLTWH( 16.0, 177.0 + 4.0, 40.0, 40.0));
expect(tester.getRect(find.byType(Placeholder).at(1)), Rect.fromLTWH(800.0 - 24.0 - 16.0, 177.0 + 12.0, 24.0, 24.0));
// NON-DENSE "ONE"-LINE
await tester.pumpWidget(
MaterialApp(
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)), Rect.fromLTWH( 0.0, 0.0, 800.0, 216.0));
expect(tester.getRect(find.byType(CircleAvatar).at(0)), Rect.fromLTWH( 16.0, 8.0, 40.0, 40.0));
expect(tester.getRect(find.byType(Placeholder).at(0)), Rect.fromLTWH(800.0 - 24.0 - 16.0, 16.0, 24.0, 24.0));
expect(tester.getRect(find.byType(ListTile).at(1)), Rect.fromLTWH( 0.0, 216.0, 800.0, 56.0));
expect(tester.getRect(find.byType(CircleAvatar).at(1)), Rect.fromLTWH( 16.0, 216.0 + 8.0, 40.0, 40.0));
expect(tester.getRect(find.byType(Placeholder).at(1)), Rect.fromLTWH(800.0 - 24.0 - 16.0, 216.0 + 16.0, 24.0, 24.0));
// DENSE "TWO"-LINE
await tester.pumpWidget(
MaterialApp(
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)), Rect.fromLTWH( 0.0, 0.0, 800.0, 180.0));
expect(tester.getRect(find.byType(CircleAvatar).at(0)), Rect.fromLTWH( 16.0, 12.0, 40.0, 40.0));
expect(tester.getRect(find.byType(Placeholder).at(0)), Rect.fromLTWH(800.0 - 24.0 - 16.0, 20.0, 24.0, 24.0));
expect(tester.getRect(find.byType(ListTile).at(1)), Rect.fromLTWH( 0.0, 180.0, 800.0, 64.0));
expect(tester.getRect(find.byType(CircleAvatar).at(1)), Rect.fromLTWH( 16.0, 180.0 + 12.0, 40.0, 40.0));
expect(tester.getRect(find.byType(Placeholder).at(1)), Rect.fromLTWH(800.0 - 24.0 - 16.0, 180.0 + 20.0, 24.0, 24.0));
// NON-DENSE "TWO"-LINE
await tester.pumpWidget(
MaterialApp(
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)), Rect.fromLTWH( 0.0, 0.0, 800.0, 180.0));
expect(tester.getRect(find.byType(CircleAvatar).at(0)), Rect.fromLTWH( 16.0, 16.0, 40.0, 40.0));
expect(tester.getRect(find.byType(Placeholder).at(0)), Rect.fromLTWH(800.0 - 24.0 - 16.0, 24.0, 24.0, 24.0));
expect(tester.getRect(find.byType(ListTile).at(1)), Rect.fromLTWH( 0.0, 180.0, 800.0, 72.0));
expect(tester.getRect(find.byType(CircleAvatar).at(1)), Rect.fromLTWH( 16.0, 180.0 + 16.0, 40.0, 40.0));
expect(tester.getRect(find.byType(Placeholder).at(1)), Rect.fromLTWH(800.0 - 24.0 - 16.0, 180.0 + 24.0, 24.0, 24.0));
// DENSE "THREE"-LINE
await tester.pumpWidget(
MaterialApp(
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)), Rect.fromLTWH( 0.0, 0.0, 800.0, 180.0));
expect(tester.getRect(find.byType(CircleAvatar).at(0)), Rect.fromLTWH( 16.0, 16.0, 40.0, 40.0));
expect(tester.getRect(find.byType(Placeholder).at(0)), Rect.fromLTWH(800.0 - 24.0 - 16.0, 16.0, 24.0, 24.0));
expect(tester.getRect(find.byType(ListTile).at(1)), Rect.fromLTWH( 0.0, 180.0, 800.0, 76.0));
expect(tester.getRect(find.byType(CircleAvatar).at(1)), Rect.fromLTWH( 16.0, 180.0 + 16.0, 40.0, 40.0));
expect(tester.getRect(find.byType(Placeholder).at(1)), Rect.fromLTWH(800.0 - 24.0 - 16.0, 180.0 + 16.0, 24.0, 24.0));
// NON-DENSE THREE-LINE
await tester.pumpWidget(
MaterialApp(
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)), Rect.fromLTWH( 0.0, 0.0, 800.0, 180.0));
expect(tester.getRect(find.byType(CircleAvatar).at(0)), Rect.fromLTWH( 16.0, 16.0, 40.0, 40.0));
expect(tester.getRect(find.byType(Placeholder).at(0)), Rect.fromLTWH(800.0 - 24.0 - 16.0, 16.0, 24.0, 24.0));
expect(tester.getRect(find.byType(ListTile).at(1)), Rect.fromLTWH( 0.0, 180.0, 800.0, 88.0));
expect(tester.getRect(find.byType(CircleAvatar).at(1)), Rect.fromLTWH( 16.0, 180.0 + 16.0, 40.0, 40.0));
expect(tester.getRect(find.byType(Placeholder).at(1)), Rect.fromLTWH(800.0 - 24.0 - 16.0, 180.0 + 16.0, 24.0, 24.0));
});
}