diff --git a/packages/flutter/lib/src/material/list_tile.dart b/packages/flutter/lib/src/material/list_tile.dart index 469e0893ba..444dcb2ba9 100644 --- a/packages/flutter/lib/src/material/list_tile.dart +++ b/packages/flutter/lib/src/material/list_tile.dart @@ -2,7 +2,11 @@ // 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 'package:flutter/widgets.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; import 'colors.dart'; import 'constants.dart'; @@ -42,6 +46,7 @@ class ListTileTheme extends InheritedWidget { this.selectedColor, this.iconColor, this.textColor, + this.contentPadding, Widget child, }) : super(key: key, child: child); @@ -56,6 +61,7 @@ class ListTileTheme extends InheritedWidget { Color selectedColor, Color iconColor, Color textColor, + EdgeInsetsGeometry contentPadding, @required Widget child, }) { assert(child != null); @@ -69,6 +75,7 @@ class ListTileTheme extends InheritedWidget { selectedColor: selectedColor ?? parent.selectedColor, iconColor: iconColor ?? parent.iconColor, textColor: textColor ?? parent.textColor, + contentPadding: contentPadding ?? parent.contentPadding, child: child, ); }, @@ -90,6 +97,12 @@ class ListTileTheme extends InheritedWidget { /// If specified, the text color used for enabled [ListTile]s that are not selected. final Color textColor; + /// The tile's internal padding. + /// + /// Insets a [ListTile]'s contents: its [leading], [title], [subtitle], + /// and [trailing] widgets. + final EdgeInsetsGeometry contentPadding; + /// The closest instance of this class that encloses the given context. /// /// Typical usage is as follows: @@ -108,7 +121,8 @@ class ListTileTheme extends InheritedWidget { || style != oldWidget.style || selectedColor != oldWidget.selectedColor || iconColor != oldWidget.iconColor - || textColor != oldWidget.textColor; + || textColor != oldWidget.textColor + || contentPadding != oldWidget.contentPadding; } } @@ -210,6 +224,7 @@ class ListTile extends StatelessWidget { this.trailing, this.isThreeLine: false, this.dense, + this.contentPadding, this.enabled: true, this.onTap, this.onLongPress, @@ -251,6 +266,14 @@ class ListTile extends StatelessWidget { /// If this property is null then its value is based on [ListTileTheme.dense]. final bool dense; + /// The tile's internal padding. + /// + /// Insets a [ListTile]'s contents: its [leading], [title], [subtitle], + /// and [trailing] widgets. + /// + /// If null, `EdgeInsets.symmetric(horizontal: 16.0)` is used. + final EdgeInsetsGeometry contentPadding; + /// Whether this list tile is interactive. /// /// If false, this list tile is styled with the disabled color from the @@ -347,7 +370,7 @@ class ListTile extends StatelessWidget { return defaultColor; } - bool _denseLayout(ListTileTheme tileTheme) { + bool _isDenseLayout(ListTileTheme tileTheme) { return dense != null ? dense : (tileTheme?.dense ?? false); } @@ -366,7 +389,7 @@ class ListTile extends StatelessWidget { style = theme.textTheme.subhead; } final Color color = _textColor(theme, tileTheme, style.color); - return _denseLayout(tileTheme) + return _isDenseLayout(tileTheme) ? style.copyWith(fontSize: 13.0, color: color) : style.copyWith(color: color); } @@ -374,7 +397,7 @@ class ListTile extends StatelessWidget { TextStyle _subtitleTextStyle(ThemeData theme, ListTileTheme tileTheme) { final TextStyle style = theme.textTheme.body1; final Color color = _textColor(theme, tileTheme, theme.textTheme.caption.color); - return _denseLayout(tileTheme) + return _isDenseLayout(tileTheme) ? style.copyWith(color: color, fontSize: 12.0) : style.copyWith(color: color); } @@ -385,91 +408,567 @@ class ListTile extends StatelessWidget { final ThemeData theme = Theme.of(context); final ListTileTheme tileTheme = ListTileTheme.of(context); - final bool isTwoLine = !isThreeLine && subtitle != null; - final bool isOneLine = !isThreeLine && !isTwoLine; - double tileHeight; - if (isOneLine) - tileHeight = _denseLayout(tileTheme) ? 48.0 : 56.0; - else if (isTwoLine) - tileHeight = _denseLayout(tileTheme) ? 60.0 : 72.0; - else - tileHeight = _denseLayout(tileTheme) ? 76.0 : 88.0; - - // Overall, the list tile is a Row() with these children. - final List children = []; - IconThemeData iconThemeData; if (leading != null || trailing != null) iconThemeData = new IconThemeData(color: _iconColor(theme, tileTheme)); + Widget leadingIcon; if (leading != null) { - children.add(IconTheme.merge( + leadingIcon = IconTheme.merge( data: iconThemeData, - child: new Container( - margin: const EdgeInsetsDirectional.only(end: 16.0), - width: 40.0, - alignment: AlignmentDirectional.centerStart, - child: leading, - ), - )); - } - - final Widget primaryLine = new AnimatedDefaultTextStyle( - style: _titleTextStyle(theme, tileTheme), - duration: kThemeChangeDuration, - child: title ?? new Container() - ); - Widget center = primaryLine; - if (subtitle != null && (isTwoLine || isThreeLine)) { - center = new Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - primaryLine, - new AnimatedDefaultTextStyle( - style: _subtitleTextStyle(theme, tileTheme), - duration: kThemeChangeDuration, - child: subtitle, - ), - ], + child: leading, ); } - children.add(new Expanded( - child: center, - )); - if (trailing != null) { - children.add(IconTheme.merge( - data: iconThemeData, - child: new Container( - margin: const EdgeInsetsDirectional.only(start: 16.0), - alignment: AlignmentDirectional.centerEnd, - child: trailing, - ), - )); + final Widget titleText = new AnimatedDefaultTextStyle( + style: _titleTextStyle(theme, tileTheme), + duration: kThemeChangeDuration, + child: title ?? const SizedBox() + ); + + Widget subtitleText; + if (subtitle != null) { + subtitleText = new AnimatedDefaultTextStyle( + style: _subtitleTextStyle(theme, tileTheme), + duration: kThemeChangeDuration, + child: subtitle, + ); } + Widget trailingIcon; + if (trailing != null) { + trailingIcon = IconTheme.merge( + data: iconThemeData, + child: trailing, + ); + } + + const EdgeInsets _defaultContentPadding = const EdgeInsets.symmetric(horizontal: 16.0); + final TextDirection textDirection = Directionality.of(context); + final EdgeInsets resolvedContentPadding = contentPadding?.resolve(textDirection) + ?? tileTheme?.contentPadding?.resolve(textDirection) + ?? _defaultContentPadding; + return new InkWell( onTap: enabled ? onTap : null, onLongPress: enabled ? onLongPress : null, child: new Semantics( selected: selected, enabled: enabled, - child: new ConstrainedBox( - constraints: new BoxConstraints(minHeight: tileHeight), - child: new Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: new UnconstrainedBox( - constrainedAxis: Axis.horizontal, - child: new SafeArea( - top: false, - bottom: false, - child: new Row(children: children), - ), - ), - ) + child: new SafeArea( + top: false, + bottom: false, + minimum: resolvedContentPadding, + child: new _ListTile( + leading: leadingIcon, + title: titleText, + subtitle: subtitleText, + trailing: trailingIcon, + isDense: _isDenseLayout(tileTheme), + isThreeLine: isThreeLine, + ), ), ), ); } } + +class _ListTile extends RenderObjectWidget { + const _ListTile({ + Key key, + this.leading, + this.title, + this.subtitle, + this.trailing, + this.isThreeLine, + this.isDense, + }) : super(key: key); + + final Widget leading; + final Widget title; + final Widget subtitle; + final Widget trailing; + final bool isThreeLine; + final bool isDense; + + @override + _RenderListTileElement createElement() => new _RenderListTileElement(this); + + @override + _RenderListTile createRenderObject(BuildContext context) { + return new _RenderListTile( + isThreeLine: isThreeLine, + isDense: isDense, + textDirection: Directionality.of(context), + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderListTile renderObject) { + renderObject + ..isThreeLine = isThreeLine + ..isDense = isDense + ..textDirection = Directionality.of(context); + } +} + +// Identifies the children of a _ListTileElement. +enum _ListTileSlot { + leading, + title, + subtitle, + trailing, +} + +class _RenderListTile extends RenderBox { + _RenderListTile({ + bool isDense, + bool isThreeLine, + TextDirection textDirection, + }) : _isDense = isDense, + _isThreeLine = isThreeLine, + _textDirection = textDirection; + + static const double _minLeadingWidth = 40.0; + // The horizontal gap between the titles and the leading/trailing widgets + static const double _horizontalTitleGap = 16.0; + // The minimum padding on the top and bottom of the title and subtitle widgets. + static const double _minVerticalPadding = 4.0; + + final Map<_ListTileSlot, RenderBox> slotToChild = <_ListTileSlot, RenderBox>{}; + final Map childToSlot = {}; + + RenderBox _updateChild(RenderBox oldChild, RenderBox newChild, _ListTileSlot slot) { + if (oldChild != null) { + dropChild(oldChild); + childToSlot.remove(oldChild); + slotToChild.remove(slot); + } + if (newChild != null) { + childToSlot[newChild] = slot; + slotToChild[slot] = newChild; + adoptChild(newChild); + } + return newChild; + } + + RenderBox _leading; + RenderBox get leading => _leading; + set leading(RenderBox value) { + _leading = _updateChild(_leading, value, _ListTileSlot.leading); + } + + RenderBox _title; + RenderBox get title => _title; + set title(RenderBox value) { + _title = _updateChild(_title, value, _ListTileSlot.title); + } + + RenderBox _subtitle; + RenderBox get subtitle => _subtitle; + set subtitle(RenderBox value) { + _subtitle = _updateChild(_subtitle, value, _ListTileSlot.subtitle); + } + + RenderBox _trailing; + RenderBox get trailing => _trailing; + set trailing(RenderBox value) { + _trailing = _updateChild(_trailing, value, _ListTileSlot.trailing); + } + + // The returned list is ordered for hit testing. + Iterable get _children sync *{ + if (leading != null) + yield leading; + if (title != null) + yield title; + if (subtitle != null) + yield subtitle; + if (trailing != null) + yield trailing; + } + + bool get isDense => _isDense; + bool _isDense; + set isDense(bool value) { + if (_isDense == value) + return; + _isDense = value; + markNeedsLayout(); + } + + bool get isThreeLine => _isThreeLine; + bool _isThreeLine; + set isThreeLine(bool value) { + if (_isThreeLine == value) + return; + _isThreeLine = value; + markNeedsLayout(); + } + + TextDirection get textDirection => _textDirection; + TextDirection _textDirection; + set textDirection(TextDirection value) { + if (_textDirection == value) + return; + _textDirection = value; + markNeedsLayout(); + } + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + for (RenderBox child in _children) + child.attach(owner); + } + + @override + void detach() { + super.detach(); + for (RenderBox child in _children) + child.detach(); + } + + @override + void redepthChildren() { + _children.forEach(redepthChild); + } + + @override + void visitChildren(RenderObjectVisitor visitor) { + _children.forEach(visitor); + } + + @override + List debugDescribeChildren() { + final List value = []; + void add(RenderBox child, String name) { + if (child != null) + value.add(child.toDiagnosticsNode(name: name)); + } + add(leading, 'leading'); + add(title, 'title'); + add(subtitle, 'subtitle'); + add(trailing, 'trailing'); + return value; + } + + @override + bool get sizedByParent => false; + + static double _minWidth(RenderBox box, double height) { + return box == null ? 0.0 : box.getMinIntrinsicWidth(height); + } + + static double _maxWidth(RenderBox box, double height) { + return box == null ? 0.0 : box.getMaxIntrinsicWidth(height); + } + + @override + double computeMinIntrinsicWidth(double height) { + final double leadingWidth = leading != null + ? math.max(leading.getMinIntrinsicWidth(height), _minLeadingWidth) + _horizontalTitleGap + : 0.0; + return leadingWidth + + math.max(_minWidth(title, height), _minWidth(subtitle, height)) + + _maxWidth(trailing, height); + } + + @override + double computeMaxIntrinsicWidth(double height) { + final double leadingWidth = leading != null + ? math.max(leading.getMaxIntrinsicWidth(height), _minLeadingWidth) + _horizontalTitleGap + : 0.0; + return leadingWidth + + math.max(_maxWidth(title, height), _maxWidth(subtitle, height)) + + _maxWidth(trailing, height); + } + + double get _defaultTileHeight { + final bool hasSubtitle = subtitle != null; + final bool isTwoLine = !isThreeLine && hasSubtitle; + final bool isOneLine = !isThreeLine && !hasSubtitle; + + if (isOneLine) + return isDense ? 48.0 : 56.0; + if (isTwoLine) + return isDense ? 64.0 : 72.0; + return isDense ? 76.0 : 88.0; + } + + @override + double computeMinIntrinsicHeight(double width) { + return math.max( + _defaultTileHeight, + title.getMinIntrinsicHeight(width) + (subtitle?.getMinIntrinsicHeight(width) ?? 0.0) + ); + } + + @override + double computeMaxIntrinsicHeight(double width) { + return computeMinIntrinsicHeight(width); + } + + @override + double computeDistanceToActualBaseline(TextBaseline baseline) { + assert(title != null); + final BoxParentData parentData = title.parentData; + return parentData.offset.dy + title.getDistanceToBaseline(TextBaseline.alphabetic); + } + + static double _boxBaseline(RenderBox box) { + return box.getDistanceToBaseline(TextBaseline.alphabetic); + } + + static Size _layoutBox(RenderBox box, BoxConstraints constraints) { + if (box == null) + return Size.zero; + box.layout(constraints, parentUsesSize: true); + return box.size; + } + + static void _positionBox(RenderBox box, Offset offset) { + final BoxParentData parentData = box.parentData; + parentData.offset = offset; + } + + // All of the dimensions below were taken from the Material Design spec: + // https://material.io/design/components/lists.html#specs + @override + void performLayout() { + final bool hasLeading = leading != null; + final bool hasSubtitle = subtitle != null; + final bool hasTrailing = trailing != null; + final bool isTwoLine = !isThreeLine && hasSubtitle; + final bool isOneLine = !isThreeLine && !hasSubtitle; + final BoxConstraints looseConstraints = constraints.loosen(); + + final double tileWidth = looseConstraints.maxWidth; + final Size leadingSize = _layoutBox(leading, looseConstraints); + final Size trailingSize = _layoutBox(trailing, looseConstraints); + + final double titleStart = hasLeading + ? math.max(_minLeadingWidth, leadingSize.width) + _horizontalTitleGap + : 0.0; + final BoxConstraints textConstraints = looseConstraints.tighten( + width: tileWidth - titleStart - (hasTrailing ? trailingSize.width + _horizontalTitleGap : 0.0), + ); + final Size titleSize = _layoutBox(title, textConstraints); + final Size subtitleSize = _layoutBox(subtitle, textConstraints); + + double titleBaseline; + double subtitleBaseline; + if (isTwoLine) { + titleBaseline = isDense ? 28.0 : 32.0; + subtitleBaseline = isDense ? 48.0 : 52.0; + } else if (isThreeLine) { + titleBaseline = isDense ? 22.0 : 28.0; + subtitleBaseline = isDense ? 42.0 : 48.0; + } else { + assert(isOneLine); + } + + double tileHeight; + double titleY; + double subtitleY; + if (!hasSubtitle) { + tileHeight = math.max(_defaultTileHeight, titleSize.height + 2.0 * _minVerticalPadding); + titleY = (tileHeight - titleSize.height) / 2.0; + } else { + titleY = titleBaseline - _boxBaseline(title); + subtitleY = subtitleBaseline - _boxBaseline(subtitle); + 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 + // tileHeight so that both titles fit. + final double titleOverlap = titleY + titleSize.height - subtitleY; + if (titleOverlap > 0.0) { + titleY -= titleOverlap / 2.0; + subtitleY += titleOverlap / 2.0; + } + + // If the title or subtitle overflow tileHeight then punt: title + // and subtitle are arranged in a column, tileHeight = column height plus + // _minVerticalPadding on top and bottom. + if (titleY < _minVerticalPadding || + (subtitleY + subtitleSize.height + _minVerticalPadding) > tileHeight) { + tileHeight = titleSize.height + subtitleSize.height + 2.0 * _minVerticalPadding; + titleY = _minVerticalPadding; + subtitleY = titleSize.height + _minVerticalPadding; + } + } + + final double leadingY = (tileHeight - leadingSize.height) / 2.0; + final double trailingY = (tileHeight - trailingSize.height) / 2.0; + + switch (textDirection) { + case TextDirection.rtl: { + if (hasLeading) + _positionBox(leading, new Offset(tileWidth - leadingSize.width, leadingY)); + final double titleX = hasTrailing ? trailingSize.width + _horizontalTitleGap : 0.0; + _positionBox(title, new Offset(titleX, titleY)); + if (hasSubtitle) + _positionBox(subtitle, new Offset(titleX, subtitleY)); + if (hasTrailing) + _positionBox(trailing, new Offset(0.0, trailingY)); + break; + } + case TextDirection.ltr: { + if (hasLeading) + _positionBox(leading, new Offset(0.0, leadingY)); + _positionBox(title, new Offset(titleStart, titleY)); + if (hasSubtitle) + _positionBox(subtitle, new Offset(titleStart, subtitleY)); + if (hasTrailing) + _positionBox(trailing, new Offset(tileWidth - trailingSize.width, trailingY)); + break; + } + } + + size = constraints.constrain(new Size(tileWidth, tileHeight)); + assert(size.width == constraints.constrainWidth(tileWidth)); + assert(size.height == constraints.constrainHeight(tileHeight)); + } + + @override + void paint(PaintingContext context, Offset offset) { + void doPaint(RenderBox child) { + if (child != null) { + final BoxParentData parentData = child.parentData; + context.paintChild(child, parentData.offset + offset); + } + } + doPaint(leading); + doPaint(title); + doPaint(subtitle); + doPaint(trailing); + } + + @override + bool hitTestSelf(Offset position) => true; + + @override + bool hitTestChildren(HitTestResult result, { @required Offset position }) { + assert(position != null); + for (RenderBox child in _children) { + final BoxParentData parentData = child.parentData; + if (child.hitTest(result, position: position - parentData.offset)) + return true; + } + return false; + } +} + +class _RenderListTileElement extends RenderObjectElement { + _RenderListTileElement(_ListTile widget) : super(widget); + + final Map<_ListTileSlot, Element> slotToChild = <_ListTileSlot, Element>{}; + final Map childToSlot = {}; + + @override + _ListTile get widget => super.widget; + + @override + _RenderListTile get renderObject => super.renderObject; + + @override + void visitChildren(ElementVisitor visitor) { + slotToChild.values.forEach(visitor); + } + + @override + void forgetChild(Element child) { + assert(slotToChild.values.contains(child)); + assert(childToSlot.keys.contains(child)); + final _ListTileSlot slot = childToSlot[child]; + childToSlot.remove(child); + slotToChild.remove(slot); + } + + void _mountChild(Widget widget, _ListTileSlot slot) { + final Element oldChild = slotToChild[slot]; + final Element newChild = updateChild(oldChild, widget, slot); + if (oldChild != null) { + slotToChild.remove(slot); + childToSlot.remove(oldChild); + } + if (newChild != null) { + slotToChild[slot] = newChild; + childToSlot[newChild] = slot; + } + } + + @override + void mount(Element parent, dynamic newSlot) { + super.mount(parent, newSlot); + _mountChild(widget.leading, _ListTileSlot.leading); + _mountChild(widget.title, _ListTileSlot.title); + _mountChild(widget.subtitle, _ListTileSlot.subtitle); + _mountChild(widget.trailing, _ListTileSlot.trailing); + } + + void _updateChild(Widget widget, _ListTileSlot slot) { + final Element oldChild = slotToChild[slot]; + final Element newChild = updateChild(oldChild, widget, slot); + if (oldChild != null) { + childToSlot.remove(oldChild); + slotToChild.remove(slot); + } + if (newChild != null) { + slotToChild[slot] = newChild; + childToSlot[newChild] = slot; + } + } + + @override + void update(_ListTile newWidget) { + super.update(newWidget); + assert(widget == newWidget); + _updateChild(widget.leading, _ListTileSlot.leading); + _updateChild(widget.title, _ListTileSlot.title); + _updateChild(widget.subtitle, _ListTileSlot.subtitle); + _updateChild(widget.trailing, _ListTileSlot.trailing); + } + + void _updateRenderObject(RenderObject child, _ListTileSlot slot) { + switch (slot) { + case _ListTileSlot.leading: + renderObject.leading = child; + break; + case _ListTileSlot.title: + renderObject.title = child; + break; + case _ListTileSlot.subtitle: + renderObject.subtitle = child; + break; + case _ListTileSlot.trailing: + renderObject.trailing = child; + break; + } + } + + @override + void insertChildRenderObject(RenderObject child, dynamic slotValue) { + assert(child is RenderBox); + assert(slotValue is _ListTileSlot); + final _ListTileSlot slot = slotValue; + _updateRenderObject(child, slot); + assert(renderObject.childToSlot.keys.contains(child)); + assert(renderObject.slotToChild.keys.contains(slot)); + } + + @override + void removeChildRenderObject(RenderObject child) { + assert(child is RenderBox); + assert(renderObject.childToSlot.keys.contains(child)); + _updateRenderObject(null, renderObject.childToSlot[child]); + assert(!renderObject.childToSlot.keys.contains(child)); + assert(!renderObject.slotToChild.keys.contains(slot)); + } + + @override + void moveChildRenderObject(RenderObject child, dynamic slotValue) { + assert(false, 'not reachable'); + } +} diff --git a/packages/flutter/test/material/list_tile_test.dart b/packages/flutter/test/material/list_tile_test.dart index 36030bf559..886e672352 100644 --- a/packages/flutter/test/material/list_tile_test.dart +++ b/packages/flutter/test/material/list_tile_test.dart @@ -2,6 +2,8 @@ // 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 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -54,8 +56,9 @@ void main() { 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 }) { + Widget buildFrame({ bool dense: false, bool isTwoLine: false, bool isThreeLine: false, double textScaleFactor: 1.0, double subtitleScaleFactor }) { hasSubtitle = isTwoLine || isThreeLine; + subtitleScaleFactor ??= textScaleFactor; return new MaterialApp( home: new MediaQuery( data: new MediaQueryData( @@ -67,7 +70,7 @@ void main() { child: new ListTile( leading: new Container(key: leadingKey, width: 24.0, height: 24.0), title: const Text('title'), - subtitle: hasSubtitle ? const Text('subtitle') : null, + subtitle: hasSubtitle ? new Text('subtitle', textScaleFactor: subtitleScaleFactor) : null, trailing: new Container(key: trailingKey, width: 24.0, height: 24.0), dense: dense, isThreeLine: isThreeLine, @@ -89,29 +92,36 @@ void main() { 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; - - // 16.0 padding to the left and right of the leading and trailing widgets - // plus the media padding. + // 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), 16.0 + leftPadding); - expect(left('title'), 72.0 + leftPadding); + expect(leftKey(leadingKey), math.max(16.0, leftPadding)); + expect(left('title'), 56.0 + math.max(16.0, leftPadding)); if (hasSubtitle) - expect(left('subtitle'), 72.0 + leftPadding); + expect(left('subtitle'), 56.0 + math.max(16.0, leftPadding)); expect(left('title'), rightKey(leadingKey) + 32.0); - expect(rightKey(trailingKey), 800.0 - 16.0 - rightPadding); + expect(rightKey(trailingKey), 800.0 - math.max(16.0, rightPadding)); expect(widthKey(trailingKey), 24.0); } void testVerticalGeometry(double expectedHeight) { - expect(tester.getSize(find.byType(ListTile)), new Size(800.0, expectedHeight)); - if (hasSubtitle) - expect(top('subtitle'), bottom('title')); + final Rect tileRect = tester.getRect(find.byType(ListTile)); + expect(tileRect.size, new 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); } @@ -133,7 +143,7 @@ void main() { await tester.pumpWidget(buildFrame(isTwoLine: true, dense: true)); testChildren(); testHorizontalGeometry(); - testVerticalGeometry(60.0); + testVerticalGeometry(64.0); await tester.pumpWidget(buildFrame(isThreeLine: true)); testChildren(); @@ -148,35 +158,40 @@ void main() { await tester.pumpWidget(buildFrame(textScaleFactor: 4.0)); testChildren(); testHorizontalGeometry(); - testVerticalGeometry(64.0); + testVerticalGeometry(72.0); await tester.pumpWidget(buildFrame(dense: true, textScaleFactor: 4.0)); testChildren(); testHorizontalGeometry(); - testVerticalGeometry(64.0); + testVerticalGeometry(72.0); await tester.pumpWidget(buildFrame(isTwoLine: true, textScaleFactor: 4.0)); testChildren(); testHorizontalGeometry(); - testVerticalGeometry(120.0); + 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(120.0); + testVerticalGeometry(128.0); await tester.pumpWidget(buildFrame(isThreeLine: true, textScaleFactor: 4.0)); testChildren(); testHorizontalGeometry(); - testVerticalGeometry(120.0); + testVerticalGeometry(128.0); await tester.pumpWidget(buildFrame(isThreeLine: true, dense: true, textScaleFactor: 4.0)); testChildren(); testHorizontalGeometry(); - testVerticalGeometry(120.0); + testVerticalGeometry(128.0); }); - testWidgets('ListTile geometry (RTL)', (WidgetTester tester) async { const double leftPadding = 10.0; const double rightPadding = 20.0; @@ -189,9 +204,9 @@ void main() { child: const Material( child: const Center( child: const ListTile( - leading: const Text('leading'), + leading: const Text('L'), title: const Text('title'), - trailing: const Text('trailing'), + trailing: const Text('T'), ), ), ), @@ -202,10 +217,9 @@ void main() { double right(String text) => tester.getTopRight(find.text(text)).dx; void testHorizontalGeometry() { - expect(right('leading'), 800.0 - 16.0 - rightPadding); - expect(right('title'), 800.0 - 72.0 - rightPadding); - expect(left('leading') - right('title'), 16.0); - expect(left('trailing'), 16.0 + leftPadding); + 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(); @@ -385,4 +399,162 @@ void main() { semantics.dispose(); }); + + testWidgets('ListTile contentPadding', (WidgetTester tester) async { + Widget buildFrame(TextDirection textDirection) { + return new MediaQuery( + data: const MediaQueryData( + padding: EdgeInsets.zero, + textScaleFactor: 1.0 + ), + child: new Directionality( + textDirection: textDirection, + child: new Material( + child: new Container( + alignment: Alignment.topLeft, + child: const ListTile( + contentPadding: const EdgeInsetsDirectional.only( + start: 10.0, + end: 20.0, + top: 30.0, + bottom: 40.0, + ), + 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)); + + 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 contentPadding', (WidgetTester tester) async { + Widget buildFrame(TextDirection textDirection) { + return new MediaQuery( + data: const MediaQueryData( + padding: EdgeInsets.zero, + textScaleFactor: 1.0 + ), + child: new Directionality( + textDirection: textDirection, + child: new Material( + child: new Container( + alignment: Alignment.topLeft, + child: const ListTile( + contentPadding: const EdgeInsetsDirectional.only( + start: 10.0, + end: 20.0, + top: 30.0, + bottom: 40.0, + ), + 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)); + + 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('ListTileTheme wide leading Widget', (WidgetTester tester) async { + const Key leadingKey = const ValueKey('L'); + + Widget buildFrame(double leadingWidth, TextDirection textDirection) { + return new MediaQuery( + data: const MediaQueryData( + padding: EdgeInsets.zero, + textScaleFactor: 1.0 + ), + child: new Directionality( + textDirection: textDirection, + child: new Material( + child: new Container( + alignment: Alignment.topLeft, + child: new ListTile( + contentPadding: EdgeInsets.zero, + leading: new 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 vertically centered + 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)); + + // Leading widget's width is 20, so default layout: the left edges of the + // title and subtitle are at 56dps (contentPadding is zero). + expect(left('title'), 56.0); + expect(left('subtitle'), 56.0); + + // If the leading widget is wider than 40 it is separated from the + // title and subtitle by 16. + await tester.pumpWidget(buildFrame(56.0, TextDirection.ltr)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 72.0)); + expect(tester.getTopLeft(find.byKey(leadingKey)), const Offset(0.0, 20.0)); + expect(tester.getBottomRight(find.byKey(leadingKey)), const Offset(56.0, 52.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, 52.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(right('title'), 800.0 - 72.0); + expect(right('subtitle'), 800.0 - 72.0); + }); }