diff --git a/dev/tools/gen_defaults/bin/gen_defaults.dart b/dev/tools/gen_defaults/bin/gen_defaults.dart index 6bda89e0a56e..fa156d82f8f4 100644 --- a/dev/tools/gen_defaults/bin/gen_defaults.dart +++ b/dev/tools/gen_defaults/bin/gen_defaults.dart @@ -35,6 +35,7 @@ import 'package:gen_defaults/filter_chip_template.dart'; import 'package:gen_defaults/icon_button_template.dart'; import 'package:gen_defaults/input_chip_template.dart'; import 'package:gen_defaults/input_decorator_template.dart'; +import 'package:gen_defaults/list_tile_template.dart'; import 'package:gen_defaults/menu_template.dart'; import 'package:gen_defaults/navigation_bar_template.dart'; import 'package:gen_defaults/navigation_drawer_template.dart'; @@ -155,6 +156,7 @@ Future main(List args) async { FilterChipTemplate('FilterChip', '$materialLib/filter_chip.dart', tokens).updateFile(); IconButtonTemplate('IconButton', '$materialLib/icon_button.dart', tokens).updateFile(); InputChipTemplate('InputChip', '$materialLib/input_chip.dart', tokens).updateFile(); + ListTileTemplate('LisTile', '$materialLib/list_tile.dart', tokens).updateFile(); InputDecoratorTemplate('InputDecorator', '$materialLib/input_decorator.dart', tokens).updateFile(); MenuTemplate('Menu', '$materialLib/menu_anchor.dart', tokens).updateFile(); NavigationBarTemplate('NavigationBar', '$materialLib/navigation_bar.dart', tokens).updateFile(); diff --git a/dev/tools/gen_defaults/lib/list_tile_template.dart b/dev/tools/gen_defaults/lib/list_tile_template.dart new file mode 100644 index 000000000000..78c99432537d --- /dev/null +++ b/dev/tools/gen_defaults/lib/list_tile_template.dart @@ -0,0 +1,48 @@ +// 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 'template.dart'; + +class ListTileTemplate extends TokenTemplate { + const ListTileTemplate(super.blockName, super.fileName, super.tokens, { + super.colorSchemePrefix = '_colors.', + super.textThemePrefix = '_textTheme.', + }); + + @override + String generate() => ''' +class _${blockName}DefaultsM3 extends ListTileThemeData { + _${blockName}DefaultsM3(this.context) + : super( + contentPadding: const EdgeInsetsDirectional.only(start: 16.0, end: 24.0), + minLeadingWidth: 24, + minVerticalPadding: 8, + shape: ${shape("md.comp.list.list-item.container")}, + ); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + late final ColorScheme _colors = _theme.colorScheme; + late final TextTheme _textTheme = _theme.textTheme; + + @override + Color? get tileColor => Colors.transparent; + + @override + TextStyle? get titleTextStyle => ${textStyle("md.comp.list.list-item.label-text")}; + + @override + TextStyle? get subtitleTextStyle => ${textStyle("md.comp.list.list-item.supporting-text")}; + + @override + TextStyle? get leadingAndTrailingTextStyle => ${textStyle("md.comp.list.list-item.trailing-supporting-text")}; + + @override + Color? get selectedColor => ${componentColor('md.comp.list.list-item.selected.trailing-icon')}; + + @override + Color? get iconColor => ${componentColor('md.comp.list.list-item.unselected.trailing-icon')}; +} +'''; +} diff --git a/packages/flutter/lib/src/material/list_tile.dart b/packages/flutter/lib/src/material/list_tile.dart index b4ddd2166b63..c44a2c06792e 100644 --- a/packages/flutter/lib/src/material/list_tile.dart +++ b/packages/flutter/lib/src/material/list_tile.dart @@ -7,6 +7,7 @@ import 'dart:math' as math; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; +import 'color_scheme.dart'; import 'colors.dart'; import 'constants.dart'; import 'debug.dart'; @@ -15,6 +16,7 @@ import 'ink_decoration.dart'; import 'ink_well.dart'; import 'list_tile_theme.dart'; import 'material_state.dart'; +import 'text_theme.dart'; import 'theme.dart'; import 'theme_data.dart'; @@ -254,10 +256,10 @@ enum ListTileControlAffinity { /// * [ListTile.divideTiles], a utility for inserting [Divider]s in between [ListTile]s. /// * [CheckboxListTile], [RadioListTile], and [SwitchListTile], widgets /// that combine [ListTile] with other controls. -/// * +/// * Material 3 [ListTile] specifications are referenced from +/// and Material 2 [ListTile] specifications are referenced from /// * Cookbook: [Use lists](https://flutter.dev/docs/cookbook/lists/basic-list) /// * Cookbook: [Implement swipe to dismiss](https://flutter.dev/docs/cookbook/gestures/dismissible) -// TODO(plg): Add link to m3 spec below m2 spec link when available class ListTile extends StatelessWidget { /// Creates a list tile. /// @@ -278,6 +280,9 @@ class ListTile extends StatelessWidget { this.selectedColor, this.iconColor, this.textColor, + this.titleTextStyle, + this.subtitleTextStyle, + this.leadingAndTrailingTextStyle, this.contentPadding, this.enabled = true, this.onTap, @@ -364,6 +369,8 @@ class ListTile extends StatelessWidget { /// If this property is null then its value is based on [ListTileTheme.dense]. /// /// Dense list tiles default to a smaller height. + /// + /// It is not recommended to set [dense] to true when [ThemeData.useMaterial3] is true. final bool? dense; /// Defines how compact the list tile's layout will be. @@ -421,6 +428,28 @@ class ListTile extends StatelessWidget { /// [ListTileThemeData]. final Color? textColor; + /// The text style for ListTile's [title]. + /// + /// If this property is null, then [ListTileThemeData.titleTextStyle] is used. + /// If that is also null and [ThemeData.useMaterial3] is true, [TextTheme.bodyLarge] + /// will be used. Otherwise, If ListTile style is [ListTileStyle.list], + /// [TextTheme.titleMedium] will be used and if ListTile style is [ListTileStyle.drawer], + /// [TextTheme.bodyLarge] will be used. + final TextStyle? titleTextStyle; + + /// The text style for ListTile's [subtitle]. + /// + /// If this property is null, then [ListTileThemeData.subtitleTextStyle] is used. + /// If that is also null, [TextTheme.bodyMedium] will be used. + final TextStyle? subtitleTextStyle; + + /// The text style for ListTile's [leading] and [trailing]. + /// + /// If this property is null, then [ListTileThemeData.leadingAndTrailingTextStyle] is used. + /// If that is also null and [ThemeData.useMaterial3] is true, [TextTheme.labelSmall] + /// will be used, otherwise [TextTheme.bodyMedium] will be used. + final TextStyle? leadingAndTrailingTextStyle; + /// Defines the font used for the [title]. /// /// If this property is null then [ListTileThemeData.style] is used. If that @@ -588,91 +617,15 @@ class ListTile extends StatelessWidget { ]; } - Color? _iconColor(ThemeData theme, ListTileThemeData tileTheme) { - if (!enabled) { - return theme.disabledColor; - } - - if (selected) { - return selectedColor ?? tileTheme.selectedColor ?? theme.listTileTheme.selectedColor ?? theme.colorScheme.primary; - } - - final Color? color = iconColor - ?? tileTheme.iconColor - ?? theme.listTileTheme.iconColor - // If [ThemeData.useMaterial3] is set to true the disabled icon color - // will be set to Theme.colorScheme.onSurface(0.38), if false, defaults to null, - // as described in: https://m3.material.io/components/icon-buttons/specs. - ?? (theme.useMaterial3 ? theme.colorScheme.onSurface.withOpacity(0.38) : null); - if (color != null) { - return color; - } - - switch (theme.brightness) { - case Brightness.light: - // For the sake of backwards compatibility, the default for unselected - // tiles is Colors.black45 rather than colorScheme.onSurface.withAlpha(0x73). - return Colors.black45; - case Brightness.dark: - return null; // null - use current icon theme color - } - } - - Color? _textColor(ThemeData theme, ListTileThemeData tileTheme, Color? defaultColor) { - if (!enabled) { - return theme.disabledColor; - } - - if (selected) { - return selectedColor ?? tileTheme.selectedColor ?? theme.listTileTheme.selectedColor ?? theme.colorScheme.primary; - } - - return textColor ?? tileTheme.textColor ?? theme.listTileTheme.textColor ?? defaultColor; - } - bool _isDenseLayout(ThemeData theme, ListTileThemeData tileTheme) { return dense ?? tileTheme.dense ?? theme.listTileTheme.dense ?? false; } - TextStyle _titleTextStyle(ThemeData theme, ListTileThemeData tileTheme) { - final TextStyle textStyle; - switch(style ?? tileTheme.style ?? theme.listTileTheme.style ?? ListTileStyle.list) { - case ListTileStyle.drawer: - textStyle = theme.useMaterial3 ? theme.textTheme.bodyMedium! : theme.textTheme.bodyLarge!; - break; - case ListTileStyle.list: - textStyle = theme.useMaterial3 ? theme.textTheme.titleMedium! : theme.textTheme.titleMedium!; - break; - } - final Color? color = _textColor(theme, tileTheme, textStyle.color); - return _isDenseLayout(theme, tileTheme) - ? textStyle.copyWith(fontSize: 13.0, color: color) - : textStyle.copyWith(color: color); - } - - TextStyle _subtitleTextStyle(ThemeData theme, ListTileThemeData tileTheme) { - final TextStyle textStyle = theme.useMaterial3 ? theme.textTheme.bodyMedium! : theme.textTheme.bodyMedium!; - final Color? color = _textColor( - theme, - tileTheme, - theme.useMaterial3 ? theme.textTheme.bodySmall!.color : theme.textTheme.bodySmall!.color, - ); - return _isDenseLayout(theme, tileTheme) - ? textStyle.copyWith(color: color, fontSize: 12.0) - : textStyle.copyWith(color: color); - } - - TextStyle _trailingAndLeadingTextStyle(ThemeData theme, ListTileThemeData tileTheme) { - final TextStyle textStyle = theme.useMaterial3 ? theme.textTheme.bodyMedium! : theme.textTheme.bodyMedium!; - final Color? color = _textColor(theme, tileTheme, textStyle.color); - return textStyle.copyWith(color: color); - } - - Color _tileBackgroundColor(ThemeData theme, ListTileThemeData tileTheme) { + Color _tileBackgroundColor(ThemeData theme, ListTileThemeData tileTheme, ListTileThemeData defaults) { final Color? color = selected ? selectedTileColor ?? tileTheme.selectedTileColor ?? theme.listTileTheme.selectedTileColor : tileColor ?? tileTheme.tileColor ?? theme.listTileTheme.tileColor; - return color ?? Colors.transparent; + return color ?? defaults.tileColor!; } @override @@ -680,23 +633,63 @@ class ListTile extends StatelessWidget { assert(debugCheckHasMaterial(context)); final ThemeData theme = Theme.of(context); final ListTileThemeData tileTheme = ListTileTheme.of(context); - final IconThemeData iconThemeData = IconThemeData(color: _iconColor(theme, tileTheme)); + final ListTileStyle listTileStyle = style + ?? tileTheme.style + ?? theme.listTileTheme.style + ?? ListTileStyle.list; + final ListTileThemeData defaults = theme.useMaterial3 + ? _LisTileDefaultsM3(context) + : _LisTileDefaultsM2(context, listTileStyle); + final Set states = { + if (!enabled) MaterialState.disabled, + if (selected) MaterialState.selected, + }; - TextStyle? leadingAndTrailingTextStyle; + Color? resolveColor(Color? explicitColor, Color? selectedColor, Color? enabledColor, [Color? disabledColor]) { + return _IndividualOverrides( + explicitColor: explicitColor, + selectedColor: selectedColor, + enabledColor: enabledColor, + disabledColor: disabledColor, + ).resolve(states); + } + + final Color? effectiveIconColor = resolveColor(iconColor, selectedColor, iconColor) + ?? resolveColor(tileTheme.iconColor, tileTheme.selectedColor, tileTheme.iconColor) + ?? resolveColor(theme.listTileTheme.iconColor, theme.listTileTheme.selectedColor, theme.listTileTheme.iconColor) + ?? resolveColor(defaults.iconColor, defaults.selectedColor, defaults.iconColor, theme.disabledColor); + final Color? effectiveColor = resolveColor(textColor, selectedColor, textColor) + ?? resolveColor(tileTheme.textColor, tileTheme.selectedColor, tileTheme.textColor) + ?? resolveColor(theme.listTileTheme.textColor, theme.listTileTheme.selectedColor, theme.listTileTheme.textColor) + ?? resolveColor(defaults.textColor, defaults.selectedColor, defaults.textColor, theme.disabledColor); + final IconThemeData iconThemeData = IconThemeData(color: effectiveIconColor); + + TextStyle? leadingAndTrailingStyle; if (leading != null || trailing != null) { - leadingAndTrailingTextStyle = _trailingAndLeadingTextStyle(theme, tileTheme); + leadingAndTrailingStyle = leadingAndTrailingTextStyle + ?? tileTheme.leadingAndTrailingTextStyle + ?? defaults.leadingAndTrailingTextStyle!; + final Color? leadingAndTrailingTextColor = effectiveColor; + leadingAndTrailingStyle = leadingAndTrailingStyle.copyWith(color: leadingAndTrailingTextColor); } Widget? leadingIcon; if (leading != null) { leadingIcon = AnimatedDefaultTextStyle( - style: leadingAndTrailingTextStyle!, + style: leadingAndTrailingStyle!, duration: kThemeChangeDuration, child: leading!, ); } - final TextStyle titleStyle = _titleTextStyle(theme, tileTheme); + TextStyle titleStyle = titleTextStyle + ?? tileTheme.titleTextStyle + ?? defaults.titleTextStyle!; + final Color? titleColor = effectiveColor; + titleStyle = titleStyle.copyWith( + color: titleColor, + fontSize: _isDenseLayout(theme, tileTheme) ? 13.0 : null, + ); final Widget titleText = AnimatedDefaultTextStyle( style: titleStyle, duration: kThemeChangeDuration, @@ -706,7 +699,14 @@ class ListTile extends StatelessWidget { Widget? subtitleText; TextStyle? subtitleStyle; if (subtitle != null) { - subtitleStyle = _subtitleTextStyle(theme, tileTheme); + subtitleStyle = subtitleTextStyle + ?? tileTheme.subtitleTextStyle + ?? defaults.subtitleTextStyle!; + final Color? subtitleColor = effectiveColor ?? theme.textTheme.bodySmall!.color; + subtitleStyle = subtitleStyle.copyWith( + color: subtitleColor, + fontSize: _isDenseLayout(theme, tileTheme) ? 12.0 : null, + ); subtitleText = AnimatedDefaultTextStyle( style: subtitleStyle, duration: kThemeChangeDuration, @@ -717,26 +717,23 @@ class ListTile extends StatelessWidget { Widget? trailingIcon; if (trailing != null) { trailingIcon = AnimatedDefaultTextStyle( - style: leadingAndTrailingTextStyle!, + style: leadingAndTrailingStyle!, duration: kThemeChangeDuration, child: trailing!, ); } - const EdgeInsets defaultContentPadding = EdgeInsets.symmetric(horizontal: 16.0); final TextDirection textDirection = Directionality.of(context); final EdgeInsets resolvedContentPadding = contentPadding?.resolve(textDirection) ?? tileTheme.contentPadding?.resolve(textDirection) - ?? defaultContentPadding; - - final Set states = { + ?? defaults.contentPadding!.resolve(textDirection); + // Show basic cursor when ListTile isn't enabled or gesture callbacks are null. + final Set mouseStates = { if (!enabled || (onTap == null && onLongPress == null)) MaterialState.disabled, - if (selected) MaterialState.selected, }; - - final MouseCursor effectiveMouseCursor = MaterialStateProperty.resolveAs(mouseCursor, states) - ?? tileTheme.mouseCursor?.resolve(states) - ?? MaterialStateMouseCursor.clickable.resolve(states); + final MouseCursor effectiveMouseCursor = MaterialStateProperty.resolveAs(mouseCursor, mouseStates) + ?? tileTheme.mouseCursor?.resolve(mouseStates) + ?? MaterialStateMouseCursor.clickable.resolve(mouseStates); return InkWell( customBorder: shape ?? tileTheme.shape, @@ -757,7 +754,7 @@ class ListTile extends StatelessWidget { child: Ink( decoration: ShapeDecoration( shape: shape ?? tileTheme.shape ?? const Border(), - color: _tileBackgroundColor(theme, tileTheme), + color: _tileBackgroundColor(theme, tileTheme, defaults), ), child: SafeArea( top: false, @@ -774,15 +771,16 @@ class ListTile extends StatelessWidget { visualDensity: visualDensity ?? tileTheme.visualDensity ?? theme.visualDensity, isThreeLine: isThreeLine, textDirection: textDirection, - titleBaselineType: titleStyle.textBaseline!, - subtitleBaselineType: subtitleStyle?.textBaseline, + titleBaselineType: titleStyle.textBaseline ?? defaults.titleTextStyle!.textBaseline!, + subtitleBaselineType: subtitleStyle?.textBaseline ?? defaults.subtitleTextStyle!.textBaseline!, horizontalTitleGap: horizontalTitleGap ?? tileTheme.horizontalTitleGap ?? 16, - minVerticalPadding: minVerticalPadding ?? tileTheme.minVerticalPadding ?? 4, - minLeadingWidth: minLeadingWidth ?? tileTheme.minLeadingWidth ?? 40, + minVerticalPadding: minVerticalPadding ?? tileTheme.minVerticalPadding ?? defaults.minVerticalPadding!, + minLeadingWidth: minLeadingWidth ?? tileTheme.minLeadingWidth ?? defaults.minLeadingWidth!, + material3: theme.useMaterial3, ), ), ), - ), + ), ), ); } @@ -802,6 +800,9 @@ class ListTile extends StatelessWidget { properties.add(ColorProperty('selectedColor', selectedColor, defaultValue: null)); properties.add(ColorProperty('iconColor', iconColor, defaultValue: null)); properties.add(ColorProperty('textColor', textColor, defaultValue: null)); + properties.add(DiagnosticsProperty('titleTextStyle', titleTextStyle, defaultValue: null)); + properties.add(DiagnosticsProperty('subtitleTextStyle', subtitleTextStyle, defaultValue: null)); + properties.add(DiagnosticsProperty('leadingAndTrailingTextStyle', leadingAndTrailingTextStyle, defaultValue: null)); properties.add(DiagnosticsProperty('contentPadding', contentPadding, defaultValue: null)); properties.add(FlagProperty('enabled', value: enabled, ifTrue: 'true', ifFalse: 'false', showName: true, defaultValue: true)); properties.add(DiagnosticsProperty('onTap', onTap, defaultValue: null)); @@ -821,6 +822,34 @@ class ListTile extends StatelessWidget { } } +class _IndividualOverrides extends MaterialStateProperty { + _IndividualOverrides({ + this.explicitColor, + this.enabledColor, + this.selectedColor, + this.disabledColor, + }); + + final Color? explicitColor; + final Color? enabledColor; + final Color? selectedColor; + final Color? disabledColor; + + @override + Color? resolve(Set states) { + if (explicitColor is MaterialStateColor) { + return MaterialStateProperty.resolveAs(explicitColor, states); + } + if (states.contains(MaterialState.disabled)) { + return disabledColor; + } + if (states.contains(MaterialState.selected)) { + return selectedColor; + } + return enabledColor; + } +} + // Identifies the children of a _ListTileElement. enum _ListTileSlot { leading, @@ -844,6 +873,7 @@ class _ListTile extends RenderObjectWidget with SlottedMultiChildRenderObjectWid required this.minVerticalPadding, required this.minLeadingWidth, this.subtitleBaselineType, + required this.material3, }) : assert(isThreeLine != null), assert(isDense != null), assert(visualDensity != null), @@ -851,7 +881,8 @@ class _ListTile extends RenderObjectWidget with SlottedMultiChildRenderObjectWid assert(titleBaselineType != null), assert(horizontalTitleGap != null), assert(minVerticalPadding != null), - assert(minLeadingWidth != null); + assert(minLeadingWidth != null), + assert(material3 != null); final Widget? leading; final Widget title; @@ -866,6 +897,7 @@ class _ListTile extends RenderObjectWidget with SlottedMultiChildRenderObjectWid final double horizontalTitleGap; final double minVerticalPadding; final double minLeadingWidth; + final bool material3; @override Iterable<_ListTileSlot> get slots => _ListTileSlot.values; @@ -896,6 +928,7 @@ class _ListTile extends RenderObjectWidget with SlottedMultiChildRenderObjectWid horizontalTitleGap: horizontalTitleGap, minVerticalPadding: minVerticalPadding, minLeadingWidth: minLeadingWidth, + material3: material3, ); } @@ -910,7 +943,8 @@ class _ListTile extends RenderObjectWidget with SlottedMultiChildRenderObjectWid ..subtitleBaselineType = subtitleBaselineType ..horizontalTitleGap = horizontalTitleGap ..minLeadingWidth = minLeadingWidth - ..minVerticalPadding = minVerticalPadding; + ..minVerticalPadding = minVerticalPadding + ..material3 = material3; } } @@ -925,6 +959,7 @@ class _RenderListTile extends RenderBox with SlottedContainerRenderObjectMixin<_ required double horizontalTitleGap, required double minVerticalPadding, required double minLeadingWidth, + required bool material3, }) : assert(isDense != null), assert(visualDensity != null), assert(isThreeLine != null), @@ -933,6 +968,7 @@ class _RenderListTile extends RenderBox with SlottedContainerRenderObjectMixin<_ assert(horizontalTitleGap != null), assert(minVerticalPadding != null), assert(minLeadingWidth != null), + assert(material3 != null), _isDense = isDense, _visualDensity = visualDensity, _isThreeLine = isThreeLine, @@ -941,7 +977,8 @@ class _RenderListTile extends RenderBox with SlottedContainerRenderObjectMixin<_ _subtitleBaselineType = subtitleBaselineType, _horizontalTitleGap = horizontalTitleGap, _minVerticalPadding = minVerticalPadding, - _minLeadingWidth = minLeadingWidth; + _minLeadingWidth = minLeadingWidth, + _material3 = material3; RenderBox? get leading => childForSlot(_ListTileSlot.leading); RenderBox? get title => childForSlot(_ListTileSlot.title); @@ -1065,6 +1102,17 @@ class _RenderListTile extends RenderBox with SlottedContainerRenderObjectMixin<_ markNeedsLayout(); } + bool get material3 => _material3; + bool _material3; + set material3(bool value) { + assert(value != null); + if (_material3 == value) { + return; + } + _material3 = value; + markNeedsLayout(); + } + @override bool get sizedByParent => false; @@ -1253,23 +1301,33 @@ class _RenderListTile extends RenderBox with SlottedContainerRenderObjectMixin<_ } } - // 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 - // The interpretation for these redlines is as follows: - // - For large tiles (> 72dp), both leading and trailing controls should be - // a fixed distance from top. As per guidelines this is set to 16dp. - // - For smaller tiles, trailing should always be centered. Leading can be - // centered or closer to the top. It should never be further than 16dp - // to the top. final double leadingY; final double trailingY; - if (tileHeight > 72.0) { - leadingY = 16.0; - trailingY = 16.0; + if (material3) { + if (isThreeLine) { + leadingY = _minVerticalPadding; + trailingY = _minVerticalPadding; + } else { + leadingY = (tileHeight - leadingSize.height) / 2.0; + trailingY = (tileHeight - trailingSize.height) / 2.0; + } } else { - leadingY = math.min((tileHeight - leadingSize.height) / 2.0, 16.0); - 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 + // The interpretation for these redlines is as follows: + // - For large tiles (> 72dp), both leading and trailing controls should be + // a fixed distance from top. As per guidelines this is set to 16dp. + // - For smaller tiles, trailing should always be centered. Leading can be + // centered or closer to the top. It should never be further than 16dp + // to the top. + if (tileHeight > 72.0) { + leadingY = 16.0; + trailingY = 16.0; + } else { + leadingY = math.min((tileHeight - leadingSize.height) / 2.0, 16.0); + trailingY = (tileHeight - trailingSize.height) / 2.0; + } } switch (textDirection) { @@ -1343,3 +1401,96 @@ class _RenderListTile extends RenderBox with SlottedContainerRenderObjectMixin<_ return false; } } + +class _LisTileDefaultsM2 extends ListTileThemeData { + _LisTileDefaultsM2(this.context, ListTileStyle style) + : super( + contentPadding: const EdgeInsets.symmetric(horizontal: 16.0), + minLeadingWidth: 40, + minVerticalPadding: 4, + shape: const Border(), + style: style, + ); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + late final TextTheme _textTheme = _theme.textTheme; + + @override + Color? get tileColor => Colors.transparent; + + @override + TextStyle? get titleTextStyle { + switch (style!) { + case ListTileStyle.drawer: + return _textTheme.bodyLarge; + case ListTileStyle.list: + return _textTheme.titleMedium; + } + } + + @override + TextStyle? get subtitleTextStyle => _textTheme.bodyMedium; + + @override + TextStyle? get leadingAndTrailingTextStyle => _textTheme.bodyMedium; + + @override + Color? get selectedColor => _theme.colorScheme.primary; + + @override + Color? get iconColor { + switch (_theme.brightness) { + case Brightness.light: + // For the sake of backwards compatibility, the default for unselected + // tiles is Colors.black45 rather than colorScheme.onSurface.withAlpha(0x73). + return Colors.black45; + case Brightness.dark: + return null; // null, Use current icon theme color + } + } +} + +// BEGIN GENERATED TOKEN PROPERTIES - LisTile + +// Do not edit by hand. The code between the "BEGIN GENERATED" and +// "END GENERATED" comments are generated from data in the Material +// Design token database by the script: +// dev/tools/gen_defaults/bin/gen_defaults.dart. + +// Token database version: v0_150 + +class _LisTileDefaultsM3 extends ListTileThemeData { + _LisTileDefaultsM3(this.context) + : super( + contentPadding: const EdgeInsetsDirectional.only(start: 16.0, end: 24.0), + minLeadingWidth: 24, + minVerticalPadding: 8, + shape: const RoundedRectangleBorder(), + ); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + late final ColorScheme _colors = _theme.colorScheme; + late final TextTheme _textTheme = _theme.textTheme; + + @override + Color? get tileColor => Colors.transparent; + + @override + TextStyle? get titleTextStyle => _textTheme.bodyLarge; + + @override + TextStyle? get subtitleTextStyle => _textTheme.bodyMedium; + + @override + TextStyle? get leadingAndTrailingTextStyle => _textTheme.labelSmall; + + @override + Color? get selectedColor => _colors.primary; + + @override + Color? get iconColor => _colors.onSurface; +} + +// END GENERATED TOKEN PROPERTIES - LisTile diff --git a/packages/flutter/lib/src/material/list_tile_theme.dart b/packages/flutter/lib/src/material/list_tile_theme.dart index 501a608dd511..b5e421708b81 100644 --- a/packages/flutter/lib/src/material/list_tile_theme.dart +++ b/packages/flutter/lib/src/material/list_tile_theme.dart @@ -51,6 +51,9 @@ class ListTileThemeData with Diagnosticable { this.selectedColor, this.iconColor, this.textColor, + this.titleTextStyle, + this.subtitleTextStyle, + this.leadingAndTrailingTextStyle, this.contentPadding, this.tileColor, this.selectedTileColor, @@ -80,6 +83,15 @@ class ListTileThemeData with Diagnosticable { /// Overrides the default value of [ListTile.textColor]. final Color? textColor; + /// Overrides the default value of [ListTile.titleTextStyle]. + final TextStyle? titleTextStyle; + + /// Overrides the default value of [ListTile.subtitleTextStyle]. + final TextStyle? subtitleTextStyle; + + /// Overrides the default value of [ListTile.leadingAndTrailingTextStyle]. + final TextStyle? leadingAndTrailingTextStyle; + /// Overrides the default value of [ListTile.contentPadding]. final EdgeInsetsGeometry? contentPadding; @@ -116,6 +128,9 @@ class ListTileThemeData with Diagnosticable { Color? selectedColor, Color? iconColor, Color? textColor, + TextStyle? titleTextStyle, + TextStyle? subtitleTextStyle, + TextStyle? leadingAndTrailingTextStyle, EdgeInsetsGeometry? contentPadding, Color? tileColor, Color? selectedTileColor, @@ -134,6 +149,9 @@ class ListTileThemeData with Diagnosticable { selectedColor: selectedColor ?? this.selectedColor, iconColor: iconColor ?? this.iconColor, textColor: textColor ?? this.textColor, + titleTextStyle: titleTextStyle ?? this.titleTextStyle, + subtitleTextStyle: titleTextStyle ?? this.subtitleTextStyle, + leadingAndTrailingTextStyle: titleTextStyle ?? this.leadingAndTrailingTextStyle, contentPadding: contentPadding ?? this.contentPadding, tileColor: tileColor ?? this.tileColor, selectedTileColor: selectedTileColor ?? this.selectedTileColor, @@ -159,6 +177,9 @@ class ListTileThemeData with Diagnosticable { selectedColor: Color.lerp(a?.selectedColor, b?.selectedColor, t), iconColor: Color.lerp(a?.iconColor, b?.iconColor, t), textColor: Color.lerp(a?.textColor, b?.textColor, t), + titleTextStyle: TextStyle.lerp(a?.titleTextStyle, b?.titleTextStyle, t), + subtitleTextStyle: TextStyle.lerp(a?.subtitleTextStyle, b?.subtitleTextStyle, t), + leadingAndTrailingTextStyle: TextStyle.lerp(a?.leadingAndTrailingTextStyle, b?.leadingAndTrailingTextStyle, t), contentPadding: EdgeInsetsGeometry.lerp(a?.contentPadding, b?.contentPadding, t), tileColor: Color.lerp(a?.tileColor, b?.tileColor, t), selectedTileColor: Color.lerp(a?.selectedTileColor, b?.selectedTileColor, t), @@ -179,6 +200,9 @@ class ListTileThemeData with Diagnosticable { selectedColor, iconColor, textColor, + titleTextStyle, + subtitleTextStyle, + leadingAndTrailingTextStyle, contentPadding, tileColor, selectedTileColor, @@ -204,6 +228,9 @@ class ListTileThemeData with Diagnosticable { && other.style == style && other.selectedColor == selectedColor && other.iconColor == iconColor + && other.titleTextStyle == titleTextStyle + && other.subtitleTextStyle == subtitleTextStyle + && other.leadingAndTrailingTextStyle == leadingAndTrailingTextStyle && other.textColor == textColor && other.contentPadding == contentPadding && other.tileColor == tileColor @@ -225,6 +252,9 @@ class ListTileThemeData with Diagnosticable { properties.add(ColorProperty('selectedColor', selectedColor, defaultValue: null)); properties.add(ColorProperty('iconColor', iconColor, defaultValue: null)); properties.add(ColorProperty('textColor', textColor, defaultValue: null)); + properties.add(DiagnosticsProperty('titleTextStyle', titleTextStyle, defaultValue: null)); + properties.add(DiagnosticsProperty('subtitleTextStyle', subtitleTextStyle, defaultValue: null)); + properties.add(DiagnosticsProperty('leadingAndTrailingTextStyle', leadingAndTrailingTextStyle, defaultValue: null)); properties.add(DiagnosticsProperty('contentPadding', contentPadding, defaultValue: null)); properties.add(ColorProperty('tileColor', tileColor, defaultValue: null)); properties.add(ColorProperty('selectedTileColor', selectedTileColor, defaultValue: null)); diff --git a/packages/flutter/test/material/list_tile_test.dart b/packages/flutter/test/material/list_tile_test.dart index 67398114ed68..c830d0237184 100644 --- a/packages/flutter/test/material/list_tile_test.dart +++ b/packages/flutter/test/material/list_tile_test.dart @@ -5,6 +5,7 @@ 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'; @@ -64,6 +65,7 @@ void main() { hasSubtitle = isTwoLine || isThreeLine; subtitleScaleFactor ??= textScaleFactor; return MaterialApp( + theme: ThemeData(useMaterial3: true), home: MediaQuery( data: MediaQueryData( padding: const EdgeInsets.only(left: leftPadding, right: rightPadding), @@ -106,15 +108,15 @@ void main() { // 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. + // 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'), 56.0 + math.max(16.0, leftPadding)); + expect(left('title'), 40.0 + math.max(16.0, leftPadding)); if (hasSubtitle) { - expect(left('subtitle'), 56.0 + math.max(16.0, leftPadding)); + expect(left('subtitle'), 40.0 + math.max(16.0, leftPadding)); } - expect(left('title'), rightKey(leadingKey) + 32.0); - expect(rightKey(trailingKey), 800.0 - math.max(16.0, rightPadding)); + expect(left('title'), rightKey(leadingKey) + 16.0); + expect(rightKey(trailingKey), 800.0 - math.max(24.0, rightPadding)); expect(widthKey(trailingKey), 24.0); } @@ -136,83 +138,60 @@ void main() { 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); + testVerticalGeometry(112.0); await tester.pumpWidget(buildFrame(isTwoLine: true, textScaleFactor: 4.0)); testChildren(); testHorizontalGeometry(); - testVerticalGeometry(128.0); + // 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(72.0); - - await tester.pumpWidget(buildFrame(isTwoLine: true, dense: true, textScaleFactor: 4.0)); - testChildren(); - testHorizontalGeometry(); - testVerticalGeometry(128.0); + testVerticalGeometry(hasIssue99933 ? 109 : 108.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); + 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(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'), + 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'), + ), ), ), ), @@ -224,8 +203,8 @@ void main() { 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)); + expect(right('title'), 800.0 - 40.0 - math.max(16.0, rightPadding)); + expect(left('T'), math.max(24.0, leftPadding)); } testHorizontalGeometry(); @@ -403,9 +382,9 @@ void main() { const Key leadingKey = ValueKey('L'); Widget buildFrame(double leadingWidth, TextDirection textDirection) { - return MediaQuery( - data: const MediaQueryData(), - child: Directionality( + return MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Directionality( textDirection: textDirection, child: Material( child: Container( @@ -427,23 +406,24 @@ void main() { // textDirection = LTR - // Two-line tile's height = 72, leading 24x32 widget is positioned 16.0 pixels from the top. + // 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, 16.0)); - expect(tester.getBottomRight(find.byKey(leadingKey)), const Offset(24.0, 16.0 + 32.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 56dps (contentPadding is zero). - expect(left('title'), 56.0); - expect(left('subtitle'), 56.0); + // 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, 16.0)); - expect(tester.getBottomRight(find.byKey(leadingKey)), const Offset(56.0, 16.0 + 32.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); @@ -451,15 +431,15 @@ 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, 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); + 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, 16.0)); - expect(tester.getBottomLeft(find.byKey(leadingKey)), const Offset(800.0 - 56.0, 16.0 + 32.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); }); @@ -468,40 +448,10 @@ void main() { // 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 [ - 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 + // "ONE"-LINE await tester.pumpWidget( MaterialApp( + theme: ThemeData(useMaterial3: true), home: Material( child: ListView( children: const [ @@ -522,94 +472,28 @@ void main() { ); 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( - home: Material( - child: ListView( - children: const [ - ListTile( - dense: true, - leading: CircleAvatar(), - trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), - title: Text('A'), - subtitle: Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), - ), - ListTile( - dense: true, - leading: CircleAvatar(), - trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), - title: Text('A'), - subtitle: Text('A'), - ), - ], - ), - ), - ), - ); - // LEFT TOP WIDTH HEIGHT - expect(tester.getRect(find.byType(ListTile).at(0)), const Rect.fromLTWH( 0.0, 0.0, 800.0, 180.0)); - expect(tester.getRect(find.byType(CircleAvatar).at(0)), const Rect.fromLTWH( 16.0, 16.0, 40.0, 40.0)); - expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 16.0, 24.0, 24.0)); - expect(tester.getRect(find.byType(ListTile).at(1)), const Rect.fromLTWH( 0.0, 180.0, 800.0, 64.0)); - expect(tester.getRect(find.byType(CircleAvatar).at(1)), const Rect.fromLTWH( 16.0, 180.0 + 12.0, 40.0, 40.0)); - expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 180.0 + 20.0, 24.0, 24.0)); - - // NON-DENSE "TWO"-LINE - await tester.pumpWidget( - MaterialApp( - home: Material( - child: ListView( - children: const [ - ListTile( - leading: CircleAvatar(), - trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), - title: Text('A'), - subtitle: Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), - ), - ListTile( - leading: CircleAvatar(), - trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), - title: Text('A'), - subtitle: Text('A'), - ), - ], - ), - ), - ), - ); - // LEFT TOP WIDTH HEIGHT - expect(tester.getRect(find.byType(ListTile).at(0)), const Rect.fromLTWH( 0.0, 0.0, 800.0, 180.0)); - expect(tester.getRect(find.byType(CircleAvatar).at(0)), const Rect.fromLTWH( 16.0, 16.0, 40.0, 40.0)); - expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 16.0, 24.0, 24.0)); - expect(tester.getRect(find.byType(ListTile).at(1)), const Rect.fromLTWH( 0.0, 180.0, 800.0, 72.0)); - expect(tester.getRect(find.byType(CircleAvatar).at(1)), const Rect.fromLTWH( 16.0, 180.0 + 16.0, 40.0, 40.0)); - expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 180.0 + 24.0, 24.0, 24.0)); - - // DENSE "THREE"-LINE + + 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 [ 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'), @@ -620,17 +504,25 @@ void main() { ), ), ); + // 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, 180.0)); - expect(tester.getRect(find.byType(CircleAvatar).at(0)), const Rect.fromLTWH( 16.0, 16.0, 40.0, 40.0)); - expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 16.0, 24.0, 24.0)); - expect(tester.getRect(find.byType(ListTile).at(1)), const Rect.fromLTWH( 0.0, 180.0, 800.0, 76.0)); - expect(tester.getRect(find.byType(CircleAvatar).at(1)), const Rect.fromLTWH( 16.0, 180.0 + 16.0, 40.0, 40.0)); - expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 180.0 + 16.0, 24.0, 24.0)); - - // NON-DENSE THREE-LINE + 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 [ @@ -654,26 +546,27 @@ void main() { ), ); // LEFT TOP WIDTH HEIGHT - expect(tester.getRect(find.byType(ListTile).at(0)), const Rect.fromLTWH( 0.0, 0.0, 800.0, 180.0)); - expect(tester.getRect(find.byType(CircleAvatar).at(0)), const Rect.fromLTWH( 16.0, 16.0, 40.0, 40.0)); - expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 16.0, 24.0, 24.0)); - expect(tester.getRect(find.byType(ListTile).at(1)), const Rect.fromLTWH( 0.0, 180.0, 800.0, 88.0)); - expect(tester.getRect(find.byType(CircleAvatar).at(1)), const Rect.fromLTWH( 16.0, 180.0 + 16.0, 40.0, 40.0)); - expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 180.0 + 16.0, 24.0, 24.0)); + 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 [ ListTile( - leading: SizedBox(height:12.0, width:24.0, child: Placeholder()), + 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()), + leading: SizedBox(height: 12.0, width: 24.0, child: Placeholder()), trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), title: Text('A'), ), @@ -684,33 +577,32 @@ void main() { ); await tester.pump(const Duration(seconds: 2)); // the text styles are animated when we change dense // LEFT TOP WIDTH HEIGHT - expect(tester.getRect(find.byType(ListTile).at(0)), const Rect.fromLTWH( 0.0, 0.0, 800.0, 216.0)); - expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH( 16.0, 16.0, 24.0, 12.0)); - expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 16.0, 24.0, 24.0)); - expect(tester.getRect(find.byType(ListTile).at(1)), const Rect.fromLTWH( 0.0, 216.0 , 800.0, 56.0)); - expect(tester.getRect(find.byType(Placeholder).at(2)), const Rect.fromLTWH( 16.0, 216.0 + 16.0, 24.0, 12.0)); - expect(tester.getRect(find.byType(Placeholder).at(3)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 216.0 + 16.0, 24.0, 24.0)); + 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()); - // Dense One line + // One line await tester.pumpWidget( MaterialApp( + theme: ThemeData(useMaterial3: true), home: Material( child: ListView( children: const [ ListTile( leading: oversizedWidget, title: Text('A'), - dense: true, ), ListTile( leading: oversizedWidget, title: Text('B'), - dense: true, ), ], ), @@ -718,24 +610,25 @@ void main() { ), ); - expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(16.0, 0.0, 24.0, 48.0)); - expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(16.0, 48.0, 24.0, 48.0)); + 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)); - // Non-dense One line + // Two line await tester.pumpWidget( MaterialApp( + theme: ThemeData(useMaterial3: true), home: Material( child: ListView( children: const [ ListTile( leading: oversizedWidget, title: Text('A'), - dense: false, + subtitle: Text('A'), ), ListTile( leading: oversizedWidget, title: Text('B'), - dense: false, + subtitle: Text('B'), ), ], ), @@ -743,12 +636,13 @@ void main() { ), ); - 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)); + expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(16.0, 8.0, 24.0, 56.0)); + expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(16.0, 72.0 + 8.0, 24.0, 56.0)); - // Dense Two line + // Three line await tester.pumpWidget( MaterialApp( + theme: ThemeData(useMaterial3: true), home: Material( child: ListView( children: const [ @@ -756,13 +650,13 @@ void main() { leading: oversizedWidget, title: Text('A'), subtitle: Text('A'), - dense: true, + isThreeLine: true, ), ListTile( leading: oversizedWidget, title: Text('B'), subtitle: Text('B'), - dense: true, + isThreeLine: true, ), ], ), @@ -770,25 +664,29 @@ void main() { ), ); - expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(16.0, 8.0, 24.0, 48.0)); - expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(16.0, 64.0 + 8.0, 24.0, 48.0)); + 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()); - // Non-dense Two line + // One line await tester.pumpWidget( MaterialApp( + theme: ThemeData(useMaterial3: true), home: Material( child: ListView( children: const [ ListTile( - leading: oversizedWidget, + trailing: oversizedWidget, title: Text('A'), - subtitle: Text('A'), dense: false, ), ListTile( - leading: oversizedWidget, + trailing: oversizedWidget, title: Text('B'), - subtitle: Text('B'), dense: false, ), ], @@ -797,28 +695,27 @@ void main() { ), ); - 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)); + 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)); - // Dense Three line + // Two line await tester.pumpWidget( MaterialApp( + theme: ThemeData(useMaterial3: true), home: Material( child: ListView( children: const [ ListTile( - leading: oversizedWidget, + trailing: oversizedWidget, title: Text('A'), subtitle: Text('A'), - isThreeLine: true, - dense: true, + dense: false, ), ListTile( - leading: oversizedWidget, + trailing: oversizedWidget, title: Text('B'), subtitle: Text('B'), - isThreeLine: true, - dense: true, + dense: false, ), ], ), @@ -826,24 +723,25 @@ void main() { ), ); - expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(16.0, 16.0, 24.0, 48.0)); - expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(16.0, 76.0 + 16.0, 24.0, 48.0)); + 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)); - // Non-dense Three line + // Three line await tester.pumpWidget( MaterialApp( + theme: ThemeData(useMaterial3: true), home: Material( child: ListView( children: const [ ListTile( - leading: oversizedWidget, + trailing: oversizedWidget, title: Text('A'), subtitle: Text('A'), isThreeLine: true, dense: false, ), ListTile( - leading: oversizedWidget, + trailing: oversizedWidget, title: Text('B'), subtitle: Text('B'), isThreeLine: true, @@ -855,54 +753,46 @@ void main() { ), ); - expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(16.0, 16.0, 24.0, 56.0)); - expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(16.0, 88.0 + 16.0, 24.0, 56.0)); + 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 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()); + testWidgets('ListTile only accepts focus when enabled', (WidgetTester tester) async { + final GlobalKey childKey = GlobalKey(); - // Dense One line await tester.pumpWidget( MaterialApp( home: Material( child: ListView( - children: const [ - ListTile( - trailing: oversizedWidget, - title: Text('A'), - dense: true, - ), + children: [ ListTile( - trailing: oversizedWidget, - title: Text('B'), + title: Text('A', key: childKey), dense: true, + onTap: () {}, ), ], ), ), ), ); + await tester.pump(); // Let the focus take effect. - expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 0, 24.0, 48.0)); - expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 48.0, 24.0, 48.0)); + final FocusNode tileNode = Focus.of(childKey.currentContext!); + tileNode.requestFocus(); + await tester.pump(); // Let the focus take effect. + expect(Focus.of(childKey.currentContext!).hasPrimaryFocus, isTrue); - // Non-dense One line + expect(tileNode.hasPrimaryFocus, isTrue); await tester.pumpWidget( MaterialApp( home: Material( child: ListView( - children: const [ - ListTile( - trailing: oversizedWidget, - title: Text('A'), - dense: false, - ), + children: [ ListTile( - trailing: oversizedWidget, - title: Text('B'), - dense: false, + title: Text('A', key: childKey), + dense: true, + enabled: false, + onTap: () {}, ), ], ), @@ -910,26 +800,23 @@ void main() { ), ); - expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 0.0, 24.0, 56.0)); - expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 56.0, 24.0, 56.0)); + 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(); - // Dense Two line await tester.pumpWidget( MaterialApp( home: Material( child: ListView( - children: const [ - ListTile( - trailing: oversizedWidget, - title: Text('A'), - subtitle: Text('A'), - dense: true, - ), + children: [ ListTile( - trailing: oversizedWidget, - title: Text('B'), - subtitle: Text('B'), + title: Text('A', key: childKey), dense: true, + autofocus: true, + onTap: () {}, ), ], ), @@ -937,26 +824,20 @@ void main() { ), ); - expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 8.0, 24.0, 48.0)); - expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 64.0 + 8.0, 24.0, 48.0)); + await tester.pump(); + expect(Focus.of(childKey.currentContext!).hasPrimaryFocus, isTrue); - // Non-dense Two line await tester.pumpWidget( MaterialApp( home: Material( child: ListView( - children: const [ - ListTile( - trailing: oversizedWidget, - title: Text('A'), - subtitle: Text('A'), - dense: false, - ), + children: [ ListTile( - trailing: oversizedWidget, - title: Text('B'), - subtitle: Text('B'), - dense: false, + title: Text('A', key: childKey), + dense: true, + enabled: false, + autofocus: true, + onTap: () {}, ), ], ), @@ -964,165 +845,15 @@ void main() { ), ); - expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 8.0, 24.0, 56.0)); - expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 72.0 + 8.0, 24.0, 56.0)); + await tester.pump(); + expect(Focus.of(childKey.currentContext!).hasPrimaryFocus, isFalse); + }); - // Dense Three line - await tester.pumpWidget( - MaterialApp( - home: Material( - child: ListView( - children: const [ - ListTile( - trailing: oversizedWidget, - title: Text('A'), - subtitle: Text('A'), - isThreeLine: true, - dense: true, - ), - ListTile( - trailing: oversizedWidget, - title: Text('B'), - subtitle: Text('B'), - isThreeLine: true, - dense: true, - ), - ], - ), - ), - ), - ); - - expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 16.0, 24.0, 48.0)); - expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 76.0 + 16.0, 24.0, 48.0)); - - // Non-dense Three line - await tester.pumpWidget( - MaterialApp( - home: Material( - child: ListView( - children: const [ - ListTile( - trailing: oversizedWidget, - title: Text('A'), - subtitle: Text('A'), - isThreeLine: true, - dense: false, - ), - ListTile( - trailing: oversizedWidget, - title: Text('B'), - subtitle: Text('B'), - isThreeLine: true, - dense: false, - ), - ], - ), - ), - ), - ); - - expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 16.0, 24.0, 56.0)); - expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 88.0 + 16.0, 24.0, 56.0)); - }); - - testWidgets('ListTile only accepts focus when enabled', (WidgetTester tester) async { - final GlobalKey childKey = GlobalKey(); - - await tester.pumpWidget( - MaterialApp( - home: Material( - child: ListView( - children: [ - 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: [ - 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: [ - 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: [ - 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( + 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) { @@ -1578,10 +1309,12 @@ void main() { 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( @@ -1609,6 +1342,59 @@ void main() { 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'); @@ -1779,9 +1565,9 @@ void main() { testWidgets('ListTile horizontalTitleGap = 0.0', (WidgetTester tester) async { Widget buildFrame(TextDirection textDirection, { double? themeHorizontalTitleGap, double? widgetHorizontalTitleGap }) { - return MediaQuery( - data: const MediaQueryData(), - child: Directionality( + return MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Directionality( textDirection: textDirection, child: Material( child: ListTileTheme( @@ -1806,34 +1592,34 @@ void main() { await tester.pumpWidget(buildFrame(TextDirection.ltr, widgetHorizontalTitleGap: 0)); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); - expect(left('title'), 56.0); + 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'), 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'), 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'), 744.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'), 744.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'), 744.0); + expect(right('title'), 760.0); }); testWidgets('ListTile horizontalTitleGap = (default) && ListTile minLeadingWidth = (default)', (WidgetTester tester) async { Widget buildFrame(TextDirection textDirection) { - return MediaQuery( - data: const MediaQueryData(), - child: Directionality( + return MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Directionality( textDirection: textDirection, child: Material( child: Container( @@ -1856,13 +1642,13 @@ void main() { expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); // horizontalTitleGap: ListTileDefaultValue.horizontalTitleGap (16.0) - expect(left('title'), 72.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'), 728.0); + expect(right('title'), 744.0); }); testWidgets('ListTile horizontalTitleGap with visualDensity', (WidgetTester tester) async { @@ -1870,9 +1656,9 @@ void main() { double? horizontalTitleGap, VisualDensity? visualDensity, }) { - return MediaQuery( - data: const MediaQueryData(), - child: Directionality( + return MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Directionality( textDirection: TextDirection.ltr, child: Material( child: Container( @@ -1897,7 +1683,7 @@ void main() { visualDensity: const VisualDensity(horizontal: VisualDensity.minimumDensity), )); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); - expect(left('title'), 58.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 @@ -1907,14 +1693,14 @@ void main() { visualDensity: const VisualDensity(horizontal: VisualDensity.minimumDensity), )); expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); - expect(left('title'), 58.0); + expect(left('title'), 42.0); }); testWidgets('ListTile minVerticalPadding = 80.0', (WidgetTester tester) async { Widget buildFrame(TextDirection textDirection, { double? themeMinVerticalPadding, double? widgetMinVerticalPadding }) { - return MediaQuery( - data: const MediaQueryData(), - child: Directionality( + return MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Directionality( textDirection: textDirection, child: Material( child: ListTileTheme( @@ -1937,23 +1723,23 @@ void main() { await tester.pumpWidget(buildFrame(TextDirection.ltr, widgetMinVerticalPadding: 80)); // 80 + 80 + 16(Title) = 176 - expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); + 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, 176.0)); + 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, 176.0)); + 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, 176.0)); + 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, 176.0)); + 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, 176.0)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 184.0)); }); testWidgets('ListTile minLeadingWidth = 60.0', (WidgetTester tester) async { @@ -2062,18 +1848,15 @@ void main() { expect(textColor(trailingKey), theme.disabledColor); }); - testWidgets('selected, enabled ListTile default icon color, light and dark themes', (WidgetTester tester) async { - const ColorScheme lightColorScheme = ColorScheme.light(); - const ColorScheme darkColorScheme = ColorScheme.dark(); + 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 Brightness brightness, required bool selected }) { - final ThemeData theme = brightness == Brightness.light - ? ThemeData.from(colorScheme: const ColorScheme.light(), useMaterial3: true) - : ThemeData.from(colorScheme: const ColorScheme.dark(), useMaterial3: true); + Widget buildFrame({required bool selected }) { return MaterialApp( theme: theme, home: Material( @@ -2092,56 +1875,32 @@ void main() { Color iconColor(Key key) => tester.state(find.byKey(key)).iconTheme.color!; - await tester.pumpWidget(buildFrame(brightness: Brightness.light, selected: true)); - expect(iconColor(leadingKey), lightColorScheme.primary); - expect(iconColor(titleKey), lightColorScheme.primary); - expect(iconColor(subtitleKey), lightColorScheme.primary); - expect(iconColor(trailingKey), lightColorScheme.primary); - - await tester.pumpWidget(buildFrame(brightness: Brightness.light, selected: false)); - expect(iconColor(leadingKey), lightColorScheme.onSurface.withOpacity(0.38)); - expect(iconColor(titleKey), lightColorScheme.onSurface.withOpacity(0.38)); - expect(iconColor(subtitleKey), lightColorScheme.onSurface.withOpacity(0.38)); - expect(iconColor(trailingKey), lightColorScheme.onSurface.withOpacity(0.38)); - - await tester.pumpWidget(buildFrame(brightness: Brightness.dark, selected: true)); - await tester.pumpAndSettle(); // Animated theme change - expect(iconColor(leadingKey), darkColorScheme.primary); - expect(iconColor(titleKey), darkColorScheme.primary); - expect(iconColor(subtitleKey), darkColorScheme.primary); - expect(iconColor(trailingKey), darkColorScheme.primary); - - // For this configuration, ListTile defers to the default IconTheme. - // The default dark theme's IconTheme has color:white - await tester.pumpWidget(buildFrame(brightness: Brightness.dark, selected: false)); - expect(iconColor(leadingKey), darkColorScheme.onSurface.withOpacity(0.38)); - expect(iconColor(titleKey), darkColorScheme.onSurface.withOpacity(0.38)); - expect(iconColor(subtitleKey), darkColorScheme.onSurface.withOpacity(0.38)); - expect(iconColor(trailingKey), darkColorScheme.onSurface.withOpacity(0.38)); + 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.onSurface); + expect(iconColor(titleKey), colorScheme.onSurface); + expect(iconColor(subtitleKey), colorScheme.onSurface); + expect(iconColor(trailingKey), colorScheme.onSurface); }); testWidgets('ListTile font size', (WidgetTester tester) async { - Widget buildFrame({ - bool dense = false, - bool enabled = true, - bool selected = false, - ListTileStyle? style, - }) { + Widget buildFrame() { return MaterialApp( theme: ThemeData(useMaterial3: true), home: Material( child: Center( child: Builder( builder: (BuildContext context) { - return ListTile( - dense: dense, - enabled: enabled, - selected: selected, - style: style, - leading: const TestText('leading'), - title: const TestText('title'), - subtitle: const TestText('subtitle') , - trailing: const TestText('trailing'), + return const ListTile( + leading: TestText('leading'), + title: TestText('title'), + subtitle: TestText('subtitle') , + trailing: TestText('trailing'), ); }, ), @@ -2150,76 +1909,31 @@ void main() { ); } - // ListTile - ListTileStyle.list (default). + // ListTile default text sizes. await tester.pumpWidget(buildFrame()); - RenderParagraph leading = _getTextRenderObject(tester, 'leading'); - expect(leading.text.style!.fontSize, 14.0); - RenderParagraph title = _getTextRenderObject(tester, 'title'); + 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); - RenderParagraph subtitle = _getTextRenderObject(tester, 'subtitle'); - expect(subtitle.text.style!.fontSize, 14.0); - RenderParagraph trailing = _getTextRenderObject(tester, 'trailing'); - expect(trailing.text.style!.fontSize, 14.0); - - // ListTile - Densed - ListTileStyle.list (default). - await tester.pumpWidget(buildFrame(dense: true)); - await tester.pumpAndSettle(); - leading = _getTextRenderObject(tester, 'leading'); - expect(leading.text.style!.fontSize, 14.0); - title = _getTextRenderObject(tester, 'title'); - expect(title.text.style!.fontSize, 13.0); - subtitle = _getTextRenderObject(tester, 'subtitle'); - expect(subtitle.text.style!.fontSize, 12.0); - trailing = _getTextRenderObject(tester, 'trailing'); - expect(trailing.text.style!.fontSize, 14.0); - - // ListTile - ListTileStyle.drawer. - await tester.pumpWidget(buildFrame(style: ListTileStyle.drawer)); - await tester.pumpAndSettle(); - leading = _getTextRenderObject(tester, 'leading'); - expect(leading.text.style!.fontSize, 14.0); - title = _getTextRenderObject(tester, 'title'); - expect(title.text.style!.fontSize, 14.0); - subtitle = _getTextRenderObject(tester, 'subtitle'); + final RenderParagraph subtitle = _getTextRenderObject(tester, 'subtitle'); expect(subtitle.text.style!.fontSize, 14.0); - trailing = _getTextRenderObject(tester, 'trailing'); - expect(trailing.text.style!.fontSize, 14.0); - - // ListTile - Densed - ListTileStyle.drawer. - await tester.pumpWidget(buildFrame(dense: true, style: ListTileStyle.drawer)); - await tester.pumpAndSettle(); - leading = _getTextRenderObject(tester, 'leading'); - expect(leading.text.style!.fontSize, 14.0); - title = _getTextRenderObject(tester, 'title'); - expect(title.text.style!.fontSize, 13.0); - subtitle = _getTextRenderObject(tester, 'subtitle'); - expect(subtitle.text.style!.fontSize, 12.0); - trailing = _getTextRenderObject(tester, 'trailing'); - expect(trailing.text.style!.fontSize, 14.0); + final RenderParagraph trailing = _getTextRenderObject(tester, 'trailing'); + expect(trailing.text.style!.fontSize, 11.0); }); testWidgets('ListTile text color', (WidgetTester tester) async { - Widget buildFrame({ - bool dense = false, - bool enabled = true, - bool selected = false, - ListTileStyle? style, - }) { + Widget buildFrame() { return MaterialApp( theme: ThemeData(useMaterial3: true), home: Material( child: Center( child: Builder( builder: (BuildContext context) { - return ListTile( - dense: dense, - enabled: enabled, - selected: selected, - style: style, - leading: const TestText('leading'), - title: const TestText('title'), - subtitle: const TestText('subtitle') , - trailing: const TestText('trailing'), + return const ListTile( + leading: TestText('leading'), + title: TestText('title'), + subtitle: TestText('subtitle') , + trailing: TestText('trailing'), ); }, ), @@ -2230,28 +1944,16 @@ void main() { final ThemeData theme = ThemeData(useMaterial3: true); - // ListTile - ListTileStyle.list (default). + // ListTile default text colors. await tester.pumpWidget(buildFrame()); - RenderParagraph leading = _getTextRenderObject(tester, 'leading'); - expect(leading.text.style!.color, theme.textTheme.bodyMedium!.color); - RenderParagraph title = _getTextRenderObject(tester, 'title'); - expect(title.text.style!.color, theme.textTheme.titleMedium!.color); - RenderParagraph subtitle = _getTextRenderObject(tester, 'subtitle'); - expect(subtitle.text.style!.color, theme.textTheme.bodySmall!.color); - RenderParagraph trailing = _getTextRenderObject(tester, 'trailing'); - expect(trailing.text.style!.color, theme.textTheme.bodyMedium!.color); - - // ListTile - ListTileStyle.drawer. - await tester.pumpWidget(buildFrame(style: ListTileStyle.drawer)); - await tester.pumpAndSettle(); - leading = _getTextRenderObject(tester, 'leading'); - expect(leading.text.style!.color, theme.textTheme.bodyMedium!.color); - title = _getTextRenderObject(tester, 'title'); + 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); - subtitle = _getTextRenderObject(tester, 'subtitle'); - expect(subtitle.text.style!.color, theme.textTheme.bodySmall!.color); - trailing = _getTextRenderObject(tester, 'trailing'); - expect(trailing.text.style!.color, theme.textTheme.bodyMedium!.color); + 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 { @@ -2281,6 +1983,9 @@ void main() { 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, @@ -2315,6 +2020,9 @@ void main() { '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', @@ -2331,18 +2039,1167 @@ void main() { ); }); - 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.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; - testWidgets('ListTile font size', (WidgetTester tester) async { - Widget buildFrame({ - bool dense = false, + 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 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 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(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.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); + }); + + 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 [ + 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 [ + 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 [ + ListTile( + dense: true, + leading: CircleAvatar(), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A'), + subtitle: Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), + ), + ListTile( + dense: true, + leading: CircleAvatar(), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A'), + subtitle: Text('A'), + ), + ], + ), + ), + ), + ); + // LEFT TOP WIDTH HEIGHT + expect(tester.getRect(find.byType(ListTile).at(0)), const Rect.fromLTWH( 0.0, 0.0, 800.0, 180.0)); + expect(tester.getRect(find.byType(CircleAvatar).at(0)), const Rect.fromLTWH( 16.0, 16.0, 40.0, 40.0)); + expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 16.0, 24.0, 24.0)); + expect(tester.getRect(find.byType(ListTile).at(1)), const Rect.fromLTWH( 0.0, 180.0, 800.0, 64.0)); + expect(tester.getRect(find.byType(CircleAvatar).at(1)), const Rect.fromLTWH( 16.0, 180.0 + 12.0, 40.0, 40.0)); + expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 180.0 + 20.0, 24.0, 24.0)); + + // NON-DENSE "TWO"-LINE + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const [ + ListTile( + leading: CircleAvatar(), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A'), + subtitle: Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), + ), + ListTile( + leading: CircleAvatar(), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A'), + subtitle: Text('A'), + ), + ], + ), + ), + ), + ); + // LEFT TOP WIDTH HEIGHT + expect(tester.getRect(find.byType(ListTile).at(0)), const Rect.fromLTWH( 0.0, 0.0, 800.0, 180.0)); + expect(tester.getRect(find.byType(CircleAvatar).at(0)), const Rect.fromLTWH( 16.0, 16.0, 40.0, 40.0)); + expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 16.0, 24.0, 24.0)); + expect(tester.getRect(find.byType(ListTile).at(1)), const Rect.fromLTWH( 0.0, 180.0, 800.0, 72.0)); + expect(tester.getRect(find.byType(CircleAvatar).at(1)), const Rect.fromLTWH( 16.0, 180.0 + 16.0, 40.0, 40.0)); + expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 180.0 + 24.0, 24.0, 24.0)); + + // DENSE "THREE"-LINE + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const [ + ListTile( + dense: true, + isThreeLine: true, + leading: CircleAvatar(), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A'), + subtitle: Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), + ), + ListTile( + dense: true, + isThreeLine: true, + leading: CircleAvatar(), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A'), + subtitle: Text('A'), + ), + ], + ), + ), + ), + ); + // LEFT TOP WIDTH HEIGHT + expect(tester.getRect(find.byType(ListTile).at(0)), const Rect.fromLTWH( 0.0, 0.0, 800.0, 180.0)); + expect(tester.getRect(find.byType(CircleAvatar).at(0)), const Rect.fromLTWH( 16.0, 16.0, 40.0, 40.0)); + expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 16.0, 24.0, 24.0)); + expect(tester.getRect(find.byType(ListTile).at(1)), const Rect.fromLTWH( 0.0, 180.0, 800.0, 76.0)); + expect(tester.getRect(find.byType(CircleAvatar).at(1)), const Rect.fromLTWH( 16.0, 180.0 + 16.0, 40.0, 40.0)); + expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 180.0 + 16.0, 24.0, 24.0)); + + // NON-DENSE THREE-LINE + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const [ + ListTile( + isThreeLine: true, + leading: CircleAvatar(), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A'), + subtitle: Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), + ), + ListTile( + isThreeLine: true, + leading: CircleAvatar(), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A'), + subtitle: Text('A'), + ), + ], + ), + ), + ), + ); + // LEFT TOP WIDTH HEIGHT + expect(tester.getRect(find.byType(ListTile).at(0)), const Rect.fromLTWH( 0.0, 0.0, 800.0, 180.0)); + expect(tester.getRect(find.byType(CircleAvatar).at(0)), const Rect.fromLTWH( 16.0, 16.0, 40.0, 40.0)); + expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 16.0, 24.0, 24.0)); + expect(tester.getRect(find.byType(ListTile).at(1)), const Rect.fromLTWH( 0.0, 180.0, 800.0, 88.0)); + expect(tester.getRect(find.byType(CircleAvatar).at(1)), const Rect.fromLTWH( 16.0, 180.0 + 16.0, 40.0, 40.0)); + expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 180.0 + 16.0, 24.0, 24.0)); + + // "ONE-LINE" with Small Leading Widget + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const [ + ListTile( + leading: SizedBox(height:12.0, width:24.0, child: Placeholder()), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), + ), + ListTile( + leading: SizedBox(height:12.0, width:24.0, child: Placeholder()), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A'), + ), + ], + ), + ), + ), + ); + await tester.pump(const Duration(seconds: 2)); // the text styles are animated when we change dense + // LEFT TOP WIDTH HEIGHT + expect(tester.getRect(find.byType(ListTile).at(0)), const Rect.fromLTWH( 0.0, 0.0, 800.0, 216.0)); + expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH( 16.0, 16.0, 24.0, 12.0)); + expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 16.0, 24.0, 24.0)); + expect(tester.getRect(find.byType(ListTile).at(1)), const Rect.fromLTWH( 0.0, 216.0 , 800.0, 56.0)); + expect(tester.getRect(find.byType(Placeholder).at(2)), const Rect.fromLTWH( 16.0, 216.0 + 16.0, 24.0, 12.0)); + expect(tester.getRect(find.byType(Placeholder).at(3)), const Rect.fromLTWH(800.0 - 24.0 - 16.0, 216.0 + 16.0, 24.0, 24.0)); + }); + + testWidgets('ListTile leading icon height does not exceed ListTile height', (WidgetTester tester) async { + // regression test for https://github.com/flutter/flutter/issues/28765 + const SizedBox oversizedWidget = SizedBox(height: 80.0, width: 24.0, child: Placeholder()); + + // Dense One line + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const [ + ListTile( + leading: oversizedWidget, + title: Text('A'), + dense: true, + ), + ListTile( + leading: oversizedWidget, + title: Text('B'), + dense: true, + ), + ], + ), + ), + ), + ); + + expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(16.0, 0.0, 24.0, 48.0)); + expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(16.0, 48.0, 24.0, 48.0)); + + // Non-dense One line + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const [ + ListTile( + leading: oversizedWidget, + title: Text('A'), + dense: false, + ), + ListTile( + leading: oversizedWidget, + title: Text('B'), + dense: false, + ), + ], + ), + ), + ), + ); + + expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(16.0, 0.0, 24.0, 56.0)); + expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(16.0, 56.0, 24.0, 56.0)); + + // Dense Two line + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const [ + ListTile( + leading: oversizedWidget, + title: Text('A'), + subtitle: Text('A'), + dense: true, + ), + ListTile( + leading: oversizedWidget, + title: Text('B'), + subtitle: Text('B'), + dense: true, + ), + ], + ), + ), + ), + ); + + expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(16.0, 8.0, 24.0, 48.0)); + expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(16.0, 64.0 + 8.0, 24.0, 48.0)); + + // Non-dense Two line + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const [ + ListTile( + leading: oversizedWidget, + title: Text('A'), + subtitle: Text('A'), + dense: false, + ), + ListTile( + leading: oversizedWidget, + title: Text('B'), + subtitle: Text('B'), + dense: false, + ), + ], + ), + ), + ), + ); + + expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(16.0, 8.0, 24.0, 56.0)); + expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(16.0, 72.0 + 8.0, 24.0, 56.0)); + + // Dense Three line + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const [ + ListTile( + leading: oversizedWidget, + title: Text('A'), + subtitle: Text('A'), + isThreeLine: true, + dense: true, + ), + ListTile( + leading: oversizedWidget, + title: Text('B'), + subtitle: Text('B'), + isThreeLine: true, + dense: true, + ), + ], + ), + ), + ), + ); + + expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(16.0, 16.0, 24.0, 48.0)); + expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(16.0, 76.0 + 16.0, 24.0, 48.0)); + + // Non-dense Three line + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const [ + ListTile( + leading: oversizedWidget, + title: Text('A'), + subtitle: Text('A'), + isThreeLine: true, + dense: false, + ), + ListTile( + leading: oversizedWidget, + title: Text('B'), + subtitle: Text('B'), + isThreeLine: true, + dense: false, + ), + ], + ), + ), + ), + ); + + expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(16.0, 16.0, 24.0, 56.0)); + expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(16.0, 88.0 + 16.0, 24.0, 56.0)); + }); + + testWidgets('ListTile trailing icon height does not exceed ListTile height', (WidgetTester tester) async { + // regression test for https://github.com/flutter/flutter/issues/28765 + const SizedBox oversizedWidget = SizedBox(height: 80.0, width: 24.0, child: Placeholder()); + + // Dense One line + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const [ + ListTile( + trailing: oversizedWidget, + title: Text('A'), + dense: true, + ), + ListTile( + trailing: oversizedWidget, + title: Text('B'), + dense: true, + ), + ], + ), + ), + ), + ); + + expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 0, 24.0, 48.0)); + expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 48.0, 24.0, 48.0)); + + // Non-dense One line + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const [ + ListTile( + trailing: oversizedWidget, + title: Text('A'), + dense: false, + ), + ListTile( + trailing: oversizedWidget, + title: Text('B'), + dense: false, + ), + ], + ), + ), + ), + ); + + expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 0.0, 24.0, 56.0)); + expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 56.0, 24.0, 56.0)); + + // Dense Two line + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const [ + ListTile( + trailing: oversizedWidget, + title: Text('A'), + subtitle: Text('A'), + dense: true, + ), + ListTile( + trailing: oversizedWidget, + title: Text('B'), + subtitle: Text('B'), + dense: true, + ), + ], + ), + ), + ), + ); + + expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 8.0, 24.0, 48.0)); + expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 64.0 + 8.0, 24.0, 48.0)); + + // Non-dense Two line + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const [ + ListTile( + trailing: oversizedWidget, + title: Text('A'), + subtitle: Text('A'), + dense: false, + ), + ListTile( + trailing: oversizedWidget, + title: Text('B'), + subtitle: Text('B'), + dense: false, + ), + ], + ), + ), + ), + ); + + expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 8.0, 24.0, 56.0)); + expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 72.0 + 8.0, 24.0, 56.0)); + + // Dense Three line + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const [ + ListTile( + trailing: oversizedWidget, + title: Text('A'), + subtitle: Text('A'), + isThreeLine: true, + dense: true, + ), + ListTile( + trailing: oversizedWidget, + title: Text('B'), + subtitle: Text('B'), + isThreeLine: true, + dense: true, + ), + ], + ), + ), + ), + ); + + expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 16.0, 24.0, 48.0)); + expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 76.0 + 16.0, 24.0, 48.0)); + + // Non-dense Three line + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const [ + ListTile( + trailing: oversizedWidget, + title: Text('A'), + subtitle: Text('A'), + isThreeLine: true, + dense: false, + ), + ListTile( + trailing: oversizedWidget, + title: Text('B'), + subtitle: Text('B'), + isThreeLine: true, + dense: false, + ), + ], + ), + ), + ), + ); + + expect(tester.getRect(find.byType(Placeholder).at(0)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 16.0, 24.0, 56.0)); + expect(tester.getRect(find.byType(Placeholder).at(1)), const Rect.fromLTWH(800.0 - 16.0 - 24.0, 88.0 + 16.0, 24.0, 56.0)); + }); + + testWidgets('ListTile wide leading Widget', (WidgetTester tester) async { + const Key leadingKey = ValueKey('L'); + + Widget buildFrame(double leadingWidth, TextDirection textDirection) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Directionality( + textDirection: textDirection, + child: Material( + child: Container( + alignment: Alignment.topLeft, + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: SizedBox(key: leadingKey, width: leadingWidth, height: 32.0), + title: const Text('title'), + subtitle: const Text('subtitle'), + ), + ), + ), + ), + ); + } + + double left(String text) => tester.getTopLeft(find.text(text)).dx; + double right(String text) => tester.getTopRight(find.text(text)).dx; + + // textDirection = LTR + + // Two-line tile's height = 72, leading 24x32 widget is positioned 16.0 pixels from the top. + await tester.pumpWidget(buildFrame(24.0, TextDirection.ltr)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 72.0)); + expect(tester.getTopLeft(find.byKey(leadingKey)), const Offset(0.0, 16.0)); + expect(tester.getBottomRight(find.byKey(leadingKey)), const Offset(24.0, 16.0 + 32.0)); + + // Leading widget's width is 20, so default layout: the left edges of the + // title and subtitle are at 56dps (contentPadding is zero). + expect(left('title'), 56.0); + expect(left('subtitle'), 56.0); + + // If the leading widget is wider than 40 it is separated from the + // title and subtitle by 16. + await tester.pumpWidget(buildFrame(56.0, TextDirection.ltr)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 72.0)); + expect(tester.getTopLeft(find.byKey(leadingKey)), const Offset(0.0, 16.0)); + expect(tester.getBottomRight(find.byKey(leadingKey)), const Offset(56.0, 16.0 + 32.0)); + expect(left('title'), 72.0); + expect(left('subtitle'), 72.0); + + // Same tests, textDirection = RTL + + await tester.pumpWidget(buildFrame(24.0, TextDirection.rtl)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 72.0)); + expect(tester.getTopRight(find.byKey(leadingKey)), const Offset(800.0, 16.0)); + expect(tester.getBottomLeft(find.byKey(leadingKey)), const Offset(800.0 - 24.0, 16.0 + 32.0)); + expect(right('title'), 800.0 - 56.0); + expect(right('subtitle'), 800.0 - 56.0); + + await tester.pumpWidget(buildFrame(56.0, TextDirection.rtl)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 72.0)); + expect(tester.getTopRight(find.byKey(leadingKey)), const Offset(800.0, 16.0)); + expect(tester.getBottomLeft(find.byKey(leadingKey)), const Offset(800.0 - 56.0, 16.0 + 32.0)); + expect(right('title'), 800.0 - 72.0); + expect(right('subtitle'), 800.0 - 72.0); + }); + + testWidgets('ListTile horizontalTitleGap = 0.0', (WidgetTester tester) async { + Widget buildFrame(TextDirection textDirection, { double? themeHorizontalTitleGap, double? widgetHorizontalTitleGap }) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Directionality( + textDirection: textDirection, + child: Material( + child: ListTileTheme( + data: ListTileThemeData(horizontalTitleGap: themeHorizontalTitleGap), + child: Container( + alignment: Alignment.topLeft, + child: ListTile( + horizontalTitleGap: widgetHorizontalTitleGap, + leading: const Text('L'), + title: const Text('title'), + trailing: const Text('T'), + ), + ), + ), + ), + ), + ); + } + + double left(String text) => tester.getTopLeft(find.text(text)).dx; + double right(String text) => tester.getTopRight(find.text(text)).dx; + + await tester.pumpWidget(buildFrame(TextDirection.ltr, widgetHorizontalTitleGap: 0)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 56.0); + + await tester.pumpWidget(buildFrame(TextDirection.ltr, themeHorizontalTitleGap: 0)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 56.0); + + await tester.pumpWidget(buildFrame(TextDirection.ltr, themeHorizontalTitleGap: 10, widgetHorizontalTitleGap: 0)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 56.0); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, widgetHorizontalTitleGap: 0)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(right('title'), 744.0); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, themeHorizontalTitleGap: 0)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(right('title'), 744.0); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, themeHorizontalTitleGap: 10, widgetHorizontalTitleGap: 0)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(right('title'), 744.0); + }); + + testWidgets('ListTile horizontalTitleGap = (default) && ListTile minLeadingWidth = (default)', (WidgetTester tester) async { + Widget buildFrame(TextDirection textDirection) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Directionality( + textDirection: textDirection, + child: Material( + child: Container( + alignment: Alignment.topLeft, + child: const ListTile( + leading: Text('L'), + title: Text('title'), + trailing: Text('T'), + ), + ), + ), + ), + ); + } + + double left(String text) => tester.getTopLeft(find.text(text)).dx; + double right(String text) => tester.getTopRight(find.text(text)).dx; + + await tester.pumpWidget(buildFrame(TextDirection.ltr)); + + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + // horizontalTitleGap: ListTileDefaultValue.horizontalTitleGap (16.0) + expect(left('title'), 72.0); + + await tester.pumpWidget(buildFrame(TextDirection.rtl)); + + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + // horizontalTitleGap: ListTileDefaultValue.horizontalTitleGap (16.0) + expect(right('title'), 728.0); + }); + + testWidgets('ListTile horizontalTitleGap with visualDensity', (WidgetTester tester) async { + Widget buildFrame({ + double? horizontalTitleGap, + VisualDensity? visualDensity, + }) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Container( + alignment: Alignment.topLeft, + child: ListTile( + visualDensity: visualDensity, + horizontalTitleGap: horizontalTitleGap, + leading: const Text('L'), + title: const Text('title'), + trailing: const Text('T'), + ), + ), + ), + ), + ); + } + + double left(String text) => tester.getTopLeft(find.text(text)).dx; + + await tester.pumpWidget(buildFrame( + horizontalTitleGap: 10.0, + visualDensity: const VisualDensity(horizontal: VisualDensity.minimumDensity), + )); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 58.0); + + // Pump another frame of the same widget to ensure the underlying render + // object did not cache the original horizontalTitleGap calculation based on the + // visualDensity + await tester.pumpWidget(buildFrame( + horizontalTitleGap: 10.0, + visualDensity: const VisualDensity(horizontal: VisualDensity.minimumDensity), + )); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 58.0); + }); + + testWidgets('ListTile minVerticalPadding = 80.0', (WidgetTester tester) async { + Widget buildFrame(TextDirection textDirection, { double? themeMinVerticalPadding, double? widgetMinVerticalPadding }) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Directionality( + textDirection: textDirection, + child: Material( + child: ListTileTheme( + data: ListTileThemeData(minVerticalPadding: themeMinVerticalPadding), + child: Container( + alignment: Alignment.topLeft, + child: ListTile( + minVerticalPadding: widgetMinVerticalPadding, + leading: const Text('L'), + title: const Text('title'), + trailing: const Text('T'), + ), + ), + ), + ), + ), + ); + } + + + await tester.pumpWidget(buildFrame(TextDirection.ltr, widgetMinVerticalPadding: 80)); + // 80 + 80 + 16(Title) = 176 + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); + + await tester.pumpWidget(buildFrame(TextDirection.ltr, themeMinVerticalPadding: 80)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); + + await tester.pumpWidget(buildFrame(TextDirection.ltr, themeMinVerticalPadding: 0, widgetMinVerticalPadding: 80)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, widgetMinVerticalPadding: 80)); + // 80 + 80 + 16(Title) = 176 + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, themeMinVerticalPadding: 80)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, themeMinVerticalPadding: 0, widgetMinVerticalPadding: 80)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); + }); + + testWidgets('ListTile font size', (WidgetTester tester) async { + Widget buildFrame({ + bool dense = false, bool enabled = true, bool selected = false, ListTileStyle? style, }) { return MaterialApp( + theme: ThemeData(useMaterial3: false), home: Material( child: Center( child: Builder( @@ -2420,6 +3277,7 @@ void main() { ListTileStyle? style, }) { return MaterialApp( + theme: ThemeData(useMaterial3: false), home: Material( child: Center( child: Builder( @@ -2479,8 +3337,8 @@ void main() { Widget buildFrame({ required Brightness brightness, required bool selected }) { final ThemeData theme = brightness == Brightness.light - ? ThemeData.from(colorScheme: const ColorScheme.light()) - : ThemeData.from(colorScheme: const ColorScheme.dark()); + ? ThemeData.from(colorScheme: const ColorScheme.light(), useMaterial3: false) + : ThemeData.from(colorScheme: const ColorScheme.dark(), useMaterial3: false); return MaterialApp( theme: theme, home: Material( @@ -2526,6 +3384,40 @@ void main() { expect(iconColor(subtitleKey), Colors.white); expect(iconColor(trailingKey), Colors.white); }); + + testWidgets('ListTile default tile color', (WidgetTester tester) async { + bool isSelected = false; + const Color defaultColor = Colors.transparent; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return ListTile( + selected: isSelected, + onTap: () { + setState(()=> isSelected = !isSelected); + }, + title: const Text('Title'), + ); + }, + ), + ), + ), + ), + ); + + expect(find.byType(Material), paints..rect(color: defaultColor)); + + // Tap on tile to change isSelected. + await tester.tap(find.byType(ListTile)); + await tester.pumpAndSettle(); + + expect(find.byType(Material), paints..rect(color: defaultColor)); + }); }); } diff --git a/packages/flutter/test/material/list_tile_theme_test.dart b/packages/flutter/test/material/list_tile_theme_test.dart index 89d43e840189..c2e84b204a43 100644 --- a/packages/flutter/test/material/list_tile_theme_test.dart +++ b/packages/flutter/test/material/list_tile_theme_test.dart @@ -59,6 +59,9 @@ void main() { expect(themeData.selectedColor, null); expect(themeData.iconColor, null); expect(themeData.textColor, null); + expect(themeData.titleTextStyle, null); + expect(themeData.subtitleTextStyle, null); + expect(themeData.leadingAndTrailingTextStyle, null); expect(themeData.contentPadding, null); expect(themeData.tileColor, null); expect(themeData.selectedTileColor, null); @@ -91,9 +94,12 @@ void main() { selectedColor: Color(0x00000001), iconColor: Color(0x00000002), textColor: Color(0x00000003), + titleTextStyle: TextStyle(color: Color(0x00000004)), + subtitleTextStyle: TextStyle(color: Color(0x00000005)), + leadingAndTrailingTextStyle: TextStyle(color: Color(0x00000006)), contentPadding: EdgeInsets.all(100), - tileColor: Color(0x00000004), - selectedTileColor: Color(0x00000005), + tileColor: Color(0x00000007), + selectedTileColor: Color(0x00000008), horizontalTitleGap: 200, minVerticalPadding: 300, minLeadingWidth: 400, @@ -116,9 +122,12 @@ void main() { 'selectedColor: Color(0x00000001)', 'iconColor: Color(0x00000002)', 'textColor: Color(0x00000003)', + 'titleTextStyle: TextStyle(inherit: true, color: Color(0x00000004))', + 'subtitleTextStyle: TextStyle(inherit: true, color: Color(0x00000005))', + 'leadingAndTrailingTextStyle: TextStyle(inherit: true, color: Color(0x00000006))', 'contentPadding: EdgeInsets.all(100.0)', - 'tileColor: Color(0x00000004)', - 'selectedTileColor: Color(0x00000005)', + 'tileColor: Color(0x00000007)', + 'selectedTileColor: Color(0x00000008)', 'horizontalTitleGap: 200.0', 'minVerticalPadding: 300.0', 'minLeadingWidth: 400.0', @@ -365,6 +374,99 @@ void main() { expect(textColor(trailingKey), theme.disabledColor); }); + testWidgets( + "ListTile respects ListTileTheme's titleTextStyle, subtitleTextStyle & leadingAndTrailingTextStyle", + (WidgetTester tester) async { + final ThemeData theme = ThemeData( + useMaterial3: true, + listTileTheme: const ListTileThemeData( + titleTextStyle: TextStyle(fontSize: 20.0), + subtitleTextStyle: TextStyle(fontSize: 17.5), + leadingAndTrailingTextStyle: TextStyle(fontSize: 15.0), + ), + ); + + Widget buildFrame() { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return const ListTile( + leading: TestText('leading'), + title: TestText('title'), + subtitle: TestText('subtitle'), + trailing: TestText('trailing'), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame()); + final RenderParagraph leading = _getTextRenderObject(tester, 'leading'); + expect(leading.text.style!.fontSize, 15.0); + final RenderParagraph title = _getTextRenderObject(tester, 'title'); + expect(title.text.style!.fontSize, 20.0); + final RenderParagraph subtitle = _getTextRenderObject(tester, 'subtitle'); + expect(subtitle.text.style!.fontSize, 17.5); + final RenderParagraph trailing = _getTextRenderObject(tester, 'trailing'); + expect(trailing.text.style!.fontSize, 15.0); + }); + + testWidgets( + "ListTile's titleTextStyle, subtitleTextStyle & leadingAndTrailingTextStyle are overridden by ListTile properties", + (WidgetTester tester) async { + final ThemeData theme = ThemeData( + useMaterial3: true, + listTileTheme: const ListTileThemeData( + titleTextStyle: TextStyle(fontSize: 20.0), + subtitleTextStyle: TextStyle(fontSize: 17.5), + leadingAndTrailingTextStyle: TextStyle(fontSize: 15.0), + ), + ); + + const TextStyle titleTextStyle = TextStyle(fontSize: 23.0); + const TextStyle subtitleTextStyle = TextStyle(fontSize: 20.0); + const TextStyle leadingAndTrailingTextStyle = TextStyle(fontSize: 18.0); + + Widget buildFrame() { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return const ListTile( + titleTextStyle: titleTextStyle, + subtitleTextStyle: subtitleTextStyle, + leadingAndTrailingTextStyle: leadingAndTrailingTextStyle, + leading: TestText('leading'), + title: TestText('title'), + subtitle: TestText('subtitle'), + trailing: TestText('trailing'), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame()); + final RenderParagraph leading = _getTextRenderObject(tester, 'leading'); + expect(leading.text.style!.fontSize, 18.0); + final RenderParagraph title = _getTextRenderObject(tester, 'title'); + expect(title.text.style!.fontSize, 23.0); + final RenderParagraph subtitle = _getTextRenderObject(tester, 'subtitle'); + expect(subtitle.text.style!.fontSize, 20.0); + final RenderParagraph trailing = _getTextRenderObject(tester, 'trailing'); + expect(trailing.text.style!.fontSize, 18.0); + }); + testWidgets("ListTile respects ListTileTheme's tileColor & selectedTileColor", (WidgetTester tester) async { late ListTileThemeData theme; bool isSelected = false; @@ -479,4 +581,130 @@ void main() { // Test shape. expect(inkWellBorder, shapeBorder); }); + + testWidgets('ListTile respects MaterialStateColor LisTileTheme.textColor', (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 ThemeData theme = ThemeData( + listTileTheme: ListTileThemeData( + textColor: MaterialStateColor.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return disabledColor; + } + if (states.contains(MaterialState.selected)) { + return selectedColor; + } + return defaultColor; + }), + ), + ); + Widget buildFrame() { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ListTile( + enabled: enabled, + selected: selected, + 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 respects MaterialStateColor LisTileTheme.iconColor', (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(); + + final ThemeData theme = ThemeData( + listTileTheme: ListTileThemeData( + iconColor: MaterialStateColor.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return disabledColor; + } + if (states.contains(MaterialState.selected)) { + return selectedColor; + } + return defaultColor; + }), + ), + ); + Widget buildFrame() { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ListTile( + enabled: enabled, + selected: selected, + leading: TestIcon(key: leadingKey), + ); + }, + ), + ), + ), + ); + } + + Color iconColor(Key key) => tester.state(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); + }); +} + +RenderParagraph _getTextRenderObject(WidgetTester tester, String text) { + return tester.renderObject(find.descendant( + of: find.byType(ListTile), + matching: find.text(text), + )); }