Unverified Commit d64955ab authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Add visualDensity and focus support to ListTile (#53888)

parent de84c1e1
......@@ -252,7 +252,10 @@ class _OptionsState extends State<Options> {
min: VisualDensity.minimumDensity,
max: VisualDensity.maximumDensity,
onChanged: (double value) {
widget.model.density = widget.model.density.copyWith(horizontal: value, vertical: widget.model.density.vertical);
widget.model.density = widget.model.density.copyWith(
horizontal: value,
vertical: widget.model.density.vertical,
);
},
value: widget.model.density.horizontal,
),
......@@ -278,7 +281,10 @@ class _OptionsState extends State<Options> {
min: VisualDensity.minimumDensity,
max: VisualDensity.maximumDensity,
onChanged: (double value) {
widget.model.density = widget.model.density.copyWith(horizontal: widget.model.density.horizontal, vertical: value);
widget.model.density = widget.model.density.copyWith(
horizontal: widget.model.density.horizontal,
vertical: value,
);
},
value: widget.model.density.vertical,
),
......@@ -376,7 +382,13 @@ class _ControlTile extends StatelessWidget {
padding: const EdgeInsets.all(8.0),
child: Column(
children: <Widget>[
Align(alignment: AlignmentDirectional.topStart, child: Text(label, textAlign: TextAlign.start)),
Align(
alignment: AlignmentDirectional.topStart,
child: Text(
label,
textAlign: TextAlign.start,
),
),
child,
],
),
......@@ -419,9 +431,59 @@ class _MyHomePageState extends State<MyHomePage> {
primarySwatch: m2Swatch,
);
final Widget label = Text(_model.rtl ? 'اضغط علي' : 'Press Me');
textController.text = _model.rtl ? 'يعتمد القرار الجيد على المعرفة وليس على الأرقام.' : 'A good decision is based on knowledge and not on numbers.';
textController.text = _model.rtl
? 'يعتمد القرار الجيد على المعرفة وليس على الأرقام.'
: 'A good decision is based on knowledge and not on numbers.';
final List<Widget> tiles = <Widget>[
_ControlTile(
label: _model.rtl ? 'حقل النص' : 'List Tile',
child: SizedBox(
width: 400,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
ListTile(
title: Text(_model.rtl ? 'هذا عنوان طويل نسبيا' : 'This is a relatively long title'),
onTap: () {},
),
ListTile(
title: Text(_model.rtl ? 'هذا عنوان قصير' : 'This is a short title'),
subtitle:
Text(_model.rtl ? 'هذا عنوان فرعي مناسب.' : 'This is an appropriate subtitle.'),
trailing: Icon(Icons.check_box),
onTap: () {},
),
ListTile(
title: Text(_model.rtl ? 'هذا عنوان قصير' : 'This is a short title'),
subtitle:
Text(_model.rtl ? 'هذا عنوان فرعي مناسب.' : 'This is an appropriate subtitle.'),
leading: Icon(Icons.check_box),
dense: true,
onTap: () {},
),
ListTile(
title: Text(_model.rtl ? 'هذا عنوان قصير' : 'This is a short title'),
subtitle:
Text(_model.rtl ? 'هذا عنوان فرعي مناسب.' : 'This is an appropriate subtitle.'),
dense: true,
leading: Icon(Icons.add_box),
trailing: Icon(Icons.check_box),
onTap: () {},
),
ListTile(
title: Text(_model.rtl ? 'هذا عنوان قصير' : 'This is a short title'),
subtitle:
Text(_model.rtl ? 'هذا عنوان فرعي مناسب.' : 'This is an appropriate subtitle.'),
isThreeLine: true,
leading: Icon(Icons.add_box),
trailing: Icon(Icons.check_box),
onTap: () {},
),
],
),
),
),
_ControlTile(
label: _model.rtl ? 'حقل النص' : 'Text Field',
child: SizedBox(
......
......@@ -14,6 +14,7 @@ import 'debug.dart';
import 'divider.dart';
import 'ink_well.dart';
import 'theme.dart';
import 'theme_data.dart';
/// Defines the title font used for [ListTile] descendants of a [ListTileTheme].
///
......@@ -633,11 +634,15 @@ class ListTile extends StatelessWidget {
this.trailing,
this.isThreeLine = false,
this.dense,
this.visualDensity,
this.contentPadding,
this.enabled = true,
this.onTap,
this.onLongPress,
this.selected = false,
this.focusColor,
this.hoverColor,
this.focusNode,
this.autofocus = false,
}) : assert(isThreeLine != null),
assert(enabled != null),
......@@ -695,6 +700,16 @@ class ListTile extends StatelessWidget {
/// Dense list tiles default to a smaller height.
final bool dense;
/// Defines how compact the list tile's layout will be.
///
/// {@macro flutter.material.themedata.visualDensity}
///
/// See also:
///
/// * [ThemeData.visualDensity], which specifies the [density] for all widgets
/// within a [Theme].
final VisualDensity visualDensity;
/// The tile's internal padding.
///
/// Insets a [ListTile]'s contents: its [leading], [title], [subtitle],
......@@ -726,6 +741,15 @@ class ListTile extends StatelessWidget {
/// can be overridden with a [ListTileTheme].
final bool selected;
/// The color for the tile's [Material] when it has the input focus.
final Color focusColor;
/// The color for the tile's [Material] when a pointer is hovering over it.
final Color hoverColor;
/// {@macro flutter.widgets.Focus.focusNode}
final FocusNode focusNode;
/// {@macro flutter.widgets.Focus.autofocus}
final bool autofocus;
......@@ -888,6 +912,9 @@ class ListTile extends StatelessWidget {
onTap: enabled ? onTap : null,
onLongPress: enabled ? onLongPress : null,
canRequestFocus: enabled,
focusNode: focusNode,
focusColor: focusColor,
hoverColor: hoverColor,
autofocus: autofocus,
child: Semantics(
selected: selected,
......@@ -902,6 +929,7 @@ class ListTile extends StatelessWidget {
subtitle: subtitleText,
trailing: trailingIcon,
isDense: _isDenseLayout(tileTheme),
visualDensity: visualDensity ?? theme.visualDensity,
isThreeLine: isThreeLine,
textDirection: textDirection,
titleBaselineType: titleStyle.textBaseline,
......@@ -930,11 +958,13 @@ class _ListTile extends RenderObjectWidget {
this.trailing,
@required this.isThreeLine,
@required this.isDense,
@required this.visualDensity,
@required this.textDirection,
@required this.titleBaselineType,
this.subtitleBaselineType,
}) : assert(isThreeLine != null),
assert(isDense != null),
assert(visualDensity != null),
assert(textDirection != null),
assert(titleBaselineType != null),
super(key: key);
......@@ -945,6 +975,7 @@ class _ListTile extends RenderObjectWidget {
final Widget trailing;
final bool isThreeLine;
final bool isDense;
final VisualDensity visualDensity;
final TextDirection textDirection;
final TextBaseline titleBaselineType;
final TextBaseline subtitleBaselineType;
......@@ -957,6 +988,7 @@ class _ListTile extends RenderObjectWidget {
return _RenderListTile(
isThreeLine: isThreeLine,
isDense: isDense,
visualDensity: visualDensity,
textDirection: textDirection,
titleBaselineType: titleBaselineType,
subtitleBaselineType: subtitleBaselineType,
......@@ -968,6 +1000,7 @@ class _ListTile extends RenderObjectWidget {
renderObject
..isThreeLine = isThreeLine
..isDense = isDense
..visualDensity = visualDensity
..textDirection = textDirection
..titleBaselineType = titleBaselineType
..subtitleBaselineType = subtitleBaselineType;
......@@ -1091,15 +1124,18 @@ class _ListTileElement extends RenderObjectElement {
class _RenderListTile extends RenderBox {
_RenderListTile({
@required bool isDense,
@required VisualDensity visualDensity,
@required bool isThreeLine,
@required TextDirection textDirection,
@required TextBaseline titleBaselineType,
TextBaseline subtitleBaselineType,
}) : assert(isDense != null),
assert(visualDensity != null),
assert(isThreeLine != null),
assert(textDirection != null),
assert(titleBaselineType != null),
_isDense = isDense,
_visualDensity = visualDensity,
_isThreeLine = isThreeLine,
_textDirection = textDirection,
_titleBaselineType = titleBaselineType,
......@@ -1107,7 +1143,7 @@ class _RenderListTile extends RenderBox {
static const double _minLeadingWidth = 40.0;
// The horizontal gap between the titles and the leading/trailing widgets
static const double _horizontalTitleGap = 16.0;
double get _horizontalTitleGap => 16.0 + visualDensity.horizontal * 2.0;
// The minimum padding on the top and bottom of the title and subtitle widgets.
static const double _minVerticalPadding = 4.0;
......@@ -1174,6 +1210,16 @@ class _RenderListTile extends RenderBox {
markNeedsLayout();
}
VisualDensity get visualDensity => _visualDensity;
VisualDensity _visualDensity;
set visualDensity(VisualDensity value) {
assert(value != null);
if (_visualDensity == value)
return;
_visualDensity = value;
markNeedsLayout();
}
bool get isThreeLine => _isThreeLine;
bool _isThreeLine;
set isThreeLine(bool value) {
......@@ -1287,11 +1333,12 @@ class _RenderListTile extends RenderBox {
final bool isTwoLine = !isThreeLine && hasSubtitle;
final bool isOneLine = !isThreeLine && !hasSubtitle;
final Offset baseDensity = visualDensity.baseSizeAdjustment;
if (isOneLine)
return isDense ? 48.0 : 56.0;
return (isDense ? 48.0 : 56.0) + baseDensity.dy;
if (isTwoLine)
return isDense ? 64.0 : 72.0;
return isDense ? 76.0 : 88.0;
return (isDense ? 64.0 : 72.0) + baseDensity.dy;
return (isDense ? 76.0 : 88.0) + baseDensity.dy;
}
@override
......@@ -1340,6 +1387,7 @@ class _RenderListTile extends RenderBox {
final bool hasTrailing = trailing != null;
final bool isTwoLine = !isThreeLine && hasSubtitle;
final bool isOneLine = !isThreeLine && !hasSubtitle;
final Offset densityAdjustment = visualDensity.baseSizeAdjustment;
final BoxConstraints maxIconHeightConstraint = BoxConstraints(
// One-line trailing and leading widget heights do not follow
......@@ -1347,7 +1395,7 @@ class _RenderListTile extends RenderBox {
// to accessibility requirements for smallest tappable widget.
// Two- and three-line trailing widget heights are constrained
// properly according to the Material spec.
maxHeight: isDense ? 48.0 : 56.0,
maxHeight: (isDense ? 48.0 : 56.0) + densityAdjustment.dy,
);
final BoxConstraints looseConstraints = constraints.loosen();
final BoxConstraints iconConstraints = looseConstraints.enforce(maxIconHeightConstraint);
......@@ -1367,8 +1415,11 @@ class _RenderListTile extends RenderBox {
final double titleStart = hasLeading
? math.max(_minLeadingWidth, leadingSize.width) + _horizontalTitleGap
: 0.0;
final double adjustedTrailingWidth = hasTrailing
? math.max(trailingSize.width + _horizontalTitleGap, 32.0)
: 0.0;
final BoxConstraints textConstraints = looseConstraints.tighten(
width: tileWidth - titleStart - (hasTrailing ? trailingSize.width + _horizontalTitleGap : 0.0),
width: tileWidth - titleStart - adjustedTrailingWidth,
);
final Size titleSize = _layoutBox(title, textConstraints);
final Size subtitleSize = _layoutBox(subtitle, textConstraints);
......@@ -1396,7 +1447,7 @@ class _RenderListTile extends RenderBox {
} else {
assert(subtitleBaselineType != null);
titleY = titleBaseline - _boxBaseline(title, titleBaselineType);
subtitleY = subtitleBaseline - _boxBaseline(subtitle, subtitleBaselineType);
subtitleY = subtitleBaseline - _boxBaseline(subtitle, subtitleBaselineType) + visualDensity.vertical * 2.0;
tileHeight = defaultTileHeight;
// If the title and subtitle overlap, move the title upwards by half
......@@ -1442,10 +1493,9 @@ class _RenderListTile extends RenderBox {
case TextDirection.rtl: {
if (hasLeading)
_positionBox(leading, Offset(tileWidth - leadingSize.width, leadingY));
final double titleX = hasTrailing ? trailingSize.width + _horizontalTitleGap : 0.0;
_positionBox(title, Offset(titleX, titleY));
_positionBox(title, Offset(adjustedTrailingWidth, titleY));
if (hasSubtitle)
_positionBox(subtitle, Offset(titleX, subtitleY));
_positionBox(subtitle, Offset(adjustedTrailingWidth, subtitleY));
if (hasTrailing)
_positionBox(trailing, Offset(0.0, trailingY));
break;
......
......@@ -3,11 +3,15 @@
// found in the LICENSE file.
import 'dart:math' as math;
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../rendering/mock_canvas.dart';
import '../widgets/semantics_tester.dart';
class TestIcon extends StatefulWidget {
......@@ -1231,4 +1235,212 @@ void main() {
await tester.pump();
expect(Focus.of(childKey.currentContext, nullOk: true).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;
const Key tileKey = Key('listTile');
Widget buildApp({bool enabled = true}) {
return MaterialApp(
home: Material(
child: Center(
child: StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
return Container(
width: 100,
height: 100,
color: Colors.white,
child: ListTile(
key: tileKey,
onTap: enabled ? () {} : null,
focusColor: Colors.orange[500],
autofocus: true,
focusNode: focusNode,
),
);
}),
),
),
);
}
await tester.pumpWidget(buildApp());
await tester.pumpAndSettle();
expect(focusNode.hasPrimaryFocus, isTrue);
expect(
Material.of(tester.element(find.byKey(tileKey))),
paints
..rect(
color: Colors.orange[500],
rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0),
)
..rect(
color: const Color(0xffffffff),
rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0),
),
);
// Check when the list tile is disabled.
await tester.pumpWidget(buildApp(enabled: false));
await tester.pumpAndSettle();
expect(focusNode.hasPrimaryFocus, isFalse);
expect(
Material.of(tester.element(find.byKey(tileKey))),
paints
..rect(
color: const Color(0xffffffff),
rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0)),
);
});
testWidgets('ListTile can be hovered and has correct hover color', (WidgetTester tester) async {
tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
const Key tileKey = Key('ListTile');
Widget buildApp({bool enabled = true}) {
return MaterialApp(
home: Material(
child: Center(
child: StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
return Container(
width: 100,
height: 100,
color: Colors.white,
child: ListTile(
key: tileKey,
onTap: enabled ? () {} : null,
hoverColor: Colors.orange[500],
autofocus: true,
),
);
}),
),
),
);
}
await tester.pumpWidget(buildApp());
await tester.pump();
await tester.pumpAndSettle();
expect(
Material.of(tester.element(find.byKey(tileKey))),
paints
..rect(
color: const Color(0x1f000000),
rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0))
..rect(
color: const Color(0xffffffff),
rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0)),
);
// Start hovering
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.moveTo(tester.getCenter(find.byKey(tileKey)));
await tester.pumpWidget(buildApp());
await tester.pump();
await tester.pumpAndSettle();
expect(
Material.of(tester.element(find.byKey(tileKey))),
paints
..rect(
color: const Color(0x1f000000),
rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0))
..rect(
color: Colors.orange[500],
rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0))
..rect(
color: const Color(0xffffffff),
rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0)),
);
await tester.pumpWidget(buildApp(enabled: false));
await tester.pump();
await tester.pumpAndSettle();
expect(
Material.of(tester.element(find.byKey(tileKey))),
paints
..rect(
color: Colors.orange[500],
rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0))
..rect(
color: const Color(0xffffffff),
rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0)),
);
});
testWidgets('ListTile can be triggerd by keyboard shortcuts', (WidgetTester tester) async {
tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
const Key tileKey = Key('ListTile');
bool tapped = false;
Widget buildApp({bool enabled = true}) {
return MaterialApp(
home: Material(
child: Center(
child: StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
return Container(
width: 200,
height: 100,
color: Colors.white,
child: ListTile(
key: tileKey,
onTap: enabled ? () {
setState((){
tapped = true;
});
} : null,
hoverColor: Colors.orange[500],
autofocus: true,
),
);
}),
),
),
);
}
await tester.pumpWidget(buildApp());
await tester.pumpAndSettle();
await tester.sendKeyEvent(LogicalKeyboardKey.space);
await tester.pumpAndSettle();
expect(tapped, isTrue);
});
testWidgets('ListTile responds to density changes.', (WidgetTester tester) async {
const Key key = Key('test');
Future<void> buildTest(VisualDensity visualDensity) async {
return await tester.pumpWidget(
MaterialApp(
home: Material(
child: Center(
child: ListTile(
key: key,
onTap: () {},
autofocus: true,
visualDensity: visualDensity,
),
),
),
),
);
}
await buildTest(const VisualDensity());
final RenderBox box = tester.renderObject(find.byKey(key));
await tester.pumpAndSettle();
expect(box.size, equals(const Size(800, 56)));
await buildTest(const VisualDensity(horizontal: 3.0, vertical: 3.0));
await tester.pumpAndSettle();
expect(box.size, equals(const Size(800, 68)));
await buildTest(const VisualDensity(horizontal: -3.0, vertical: -3.0));
await tester.pumpAndSettle();
expect(box.size, equals(const Size(800, 44)));
await buildTest(const VisualDensity(horizontal: 3.0, vertical: -3.0));
await tester.pumpAndSettle();
expect(box.size, equals(const Size(800, 44)));
});
}
......@@ -418,7 +418,7 @@ void main() {
);
});
testWidgets('Radio can be hovered and has correct focus color', (WidgetTester tester) async {
testWidgets('Radio can be hovered and has correct hover color', (WidgetTester tester) async {
tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
int groupValue = 0;
const Key radioKey = Key('radio');
......@@ -479,7 +479,7 @@ void main() {
color: const Color(0xffffffff),
rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0))
..circle(color: Colors.orange[500])
..circle(color: const Color(0x8a000000), style: PaintingStyle.stroke, strokeWidth: 2.0)
..circle(color: const Color(0x8a000000), style: PaintingStyle.stroke, strokeWidth: 2.0),
);
// Check when the radio is selected, but disabled.
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment