Commit 5ceaaeef authored by Hans Muller's avatar Hans Muller Committed by GitHub

Add dense layout support to dropdown (#6906)

parent b7f8ec66
......@@ -20,6 +20,7 @@ import 'material.dart';
const Duration _kDropdownMenuDuration = const Duration(milliseconds: 300);
const double _kMenuItemHeight = 48.0;
const double _kDenseButtonHeight = 24.0;
const EdgeInsets _kMenuVerticalPadding = const EdgeInsets.symmetric(vertical: 8.0);
const EdgeInsets _kMenuHorizontalPadding = const EdgeInsets.symmetric(horizontal: 16.0);
......@@ -226,7 +227,7 @@ class _DropdownMenuRouteLayout<T> extends SingleChildLayoutDelegate {
Offset getPositionForChild(Size size, Size childSize) {
final double buttonTop = buttonRect.top;
final double selectedItemOffset = selectedIndex * _kMenuItemHeight + _kMenuVerticalPadding.top;
double top = buttonTop - selectedItemOffset;
double top = (buttonTop - selectedItemOffset) - (_kMenuItemHeight - buttonRect.height) / 2.0;
final double topPreferredLimit = _kMenuItemHeight;
if (top < topPreferredLimit)
top = math.min(buttonTop, topPreferredLimit);
......@@ -403,8 +404,9 @@ class DropdownButtonHideUnderline extends InheritedWidget {
///
/// See also:
///
/// * [RaisedButton]
/// * [FlatButton]
/// * [DropdownButtonHideUnderline], which prevents its descendant drop down buttons
/// from displaying their underlines.
/// * [RaisedButton], [FlatButton], ordinary buttons that trigger a single action.
/// * <https://material.google.com/components/buttons.html#buttons-dropdown-buttons>
class DropdownButton<T> extends StatefulWidget {
/// Creates a dropdown button.
......@@ -420,7 +422,8 @@ class DropdownButton<T> extends StatefulWidget {
@required this.onChanged,
this.elevation: 8,
this.style,
this.iconSize: 24.0
this.iconSize: 24.0,
this.isDense: false,
}) : super(key: key) {
assert(items != null);
assert(items.where((DropdownMenuItem<T> item) => item.value == value).length == 1);
......@@ -454,6 +457,14 @@ class DropdownButton<T> extends StatefulWidget {
/// Defaults to 24.0.
final double iconSize;
/// Reduce the button's height.
///
/// By default this button's height is the same as its menu items' heights.
/// If isDense is true, the button's height is reduced by about half. This
/// can be useful when the button is embedded in a container that adds
/// its own decorations, like [InputContainer].
final bool isDense;
@override
_DropdownButtonState<T> createState() => new _DropdownButtonState<T>();
}
......@@ -466,7 +477,7 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> {
assert(_selectedIndex != null);
}
@override
@override
void didUpdateConfig(DropdownButton<T> oldConfig) {
if (config.items[_selectedIndex].value != config.value)
_updateSelectedIndex();
......@@ -503,39 +514,49 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> {
});
}
// When isDense is true, reduce the height of this button from _kMenuItemHeight to
// _kDenseButtonHeight, but don't make it smaller than the text that it contains.
// Similarly, we don't reduce the height of the button so much that its icon
// would be clipped.
double get _denseButtonHeight {
return math.max(_textStyle.fontSize, math.max(config.iconSize, _kDenseButtonHeight));
}
@override
Widget build(BuildContext context) {
assert(debugCheckHasMaterial(context));
Widget result = new DefaultTextStyle(
style: _textStyle,
child: new Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
// We use an IndexedStack to make sure we have enough width to show any
// possible item as the selected item without changing size.
new IndexedStack(
index: _selectedIndex,
alignment: FractionalOffset.centerLeft,
children: config.items
),
new Icon(Icons.arrow_drop_down,
size: config.iconSize,
// These colors are not defined in the Material Design spec.
color: Theme.of(context).brightness == Brightness.light ? Colors.grey[700] : Colors.white70
)
]
)
child: new SizedBox(
height: config.isDense ? _denseButtonHeight : null,
child: new Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
new IndexedStack(
index: _selectedIndex,
alignment: FractionalOffset.centerLeft,
children: config.items
),
new Icon(Icons.arrow_drop_down,
size: config.iconSize,
// These colors are not defined in the Material Design spec.
color: Theme.of(context).brightness == Brightness.light ? Colors.grey[700] : Colors.white70
),
],
),
),
);
if (!DropdownButtonHideUnderline.at(context)) {
final double bottom = config.isDense ? 0.0 : 8.0;
result = new Stack(
children: <Widget>[
result,
new Positioned(
left: 0.0,
right: 0.0,
bottom: 8.0,
bottom: bottom,
child: new Container(
height: 1.0,
decoration: const BoxDecoration(
......
......@@ -2,36 +2,62 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:math' as math;
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
Widget buildFrame({ Key buttonKey, String value: 'two', ValueChanged<String> onChanged, bool isDense: false }) {
final List<String> items = <String>['one', 'two', 'three', 'four'];
return new MaterialApp(
home: new Material(
child: new Center(
child: new DropdownButton<String>(
key: buttonKey,
value: value,
onChanged: onChanged,
isDense: isDense,
items: items.map((String item) {
return new DropdownMenuItem<String>(
key: new ValueKey<String>(item),
value: item,
child: new Text(item, key: new ValueKey<String>(item + "Text")),
);
}).toList(),
),
),
),
);
}
// When the dropdown's menu is popped up, a RenderParagraph for the selected
// menu's text item will appear both in the dropdown button and in the menu.
// The RenderParagraphs should be aligned, i.e. they should have the same
// size and location.
void checkSelectedItemTextGeometry(WidgetTester tester, String value) {
final List<RenderBox> boxes = tester.renderObjectList(find.byKey(new ValueKey<String>(value + 'Text'))).toList();
expect(boxes.length, equals(2));
final RenderBox box0 = boxes[0];
final RenderBox box1 = boxes[1];
expect(box0.localToGlobal(Point.origin), equals(box1.localToGlobal(Point.origin)));
expect(box0.size, equals(box1.size));
}
bool sameGeometry(RenderBox box1, RenderBox box2) {
expect(box1.localToGlobal(Point.origin), equals(box2.localToGlobal(Point.origin)));
expect(box1.size.height, equals(box2.size.height));
return true;
}
void main() {
testWidgets('Drop down button control test', (WidgetTester tester) async {
List<String> items = <String>['one', 'two', 'three', 'four'];
String value = items.first;
String value = 'one';
void didChangeValue(String newValue) {
value = newValue;
}
Widget build() {
return new MaterialApp(
home: new Material(
child: new Center(
child: new DropdownButton<String>(
value: value,
items: items.map((String item) {
return new DropdownMenuItem<String>(
value: item,
child: new Text(item),
);
}).toList(),
onChanged: didChangeValue,
),
),
),
);
}
Widget build() => buildFrame(value: value, onChanged: didChangeValue);
await tester.pumpWidget(build());
......@@ -65,9 +91,7 @@ void main() {
});
testWidgets('Drop down button with no app', (WidgetTester tester) async {
List<String> items = <String>['one', 'two', 'three', 'four'];
String value = items.first;
String value = 'one';
void didChangeValue(String newValue) {
value = newValue;
}
......@@ -80,18 +104,7 @@ void main() {
settings: settings,
builder: (BuildContext context) {
return new Material(
child: new Center(
child: new DropdownButton<String>(
value: value,
items: items.map((String item) {
return new DropdownMenuItem<String>(
value: item,
child: new Text(item),
);
}).toList(),
onChanged: didChangeValue,
),
)
child: buildFrame(value: 'one', onChanged: didChangeValue),
);
},
);
......@@ -181,4 +194,75 @@ void main() {
await tester.pump();
await tester.pump(const Duration(seconds: 1)); // finish the menu animation
});
testWidgets('Drop down button aligns selected menu item', (WidgetTester tester) async {
Key buttonKey = new UniqueKey();
String value = 'two';
Widget build() => buildFrame(buttonKey: buttonKey, value: value);
await tester.pumpWidget(build());
RenderBox buttonBox = tester.renderObject(find.byKey(buttonKey));
assert(buttonBox.attached);
Point buttonOriginBeforeTap = buttonBox.localToGlobal(Point.origin);
await tester.tap(find.text('two'));
await tester.pump();
await tester.pump(const Duration(seconds: 1)); // finish the menu animation
// Tapping the dropdown button should not cause it to move.
expect(buttonBox.localToGlobal(Point.origin), equals(buttonOriginBeforeTap));
// The selected dropdown item is both in menu we just popped up, and in
// the IndexedStack contained by the dropdown button. Both of them should
// have the same origin and height as the dropdown button.
List<RenderObject> itemBoxes = tester.renderObjectList(find.byKey(new ValueKey<String>('two'))).toList();
expect(itemBoxes.length, equals(2));
for(RenderBox itemBox in itemBoxes) {
assert(itemBox.attached);
expect(buttonBox.localToGlobal(Point.origin), equals(itemBox.localToGlobal(Point.origin)));
expect(buttonBox.size.height, equals(itemBox.size.height));
}
// The two RenderParagraph objects, for the 'two' items' Text children,
// should have the same size and location.
checkSelectedItemTextGeometry(tester, 'two');
});
testWidgets('Drop down button with isDense:true aligns selected menu item', (WidgetTester tester) async {
Key buttonKey = new UniqueKey();
String value = 'two';
Widget build() => buildFrame(buttonKey: buttonKey, value: value, isDense: true);
await tester.pumpWidget(build());
RenderBox buttonBox = tester.renderObject(find.byKey(buttonKey));
assert(buttonBox.attached);
await tester.tap(find.text('two'));
await tester.pump();
await tester.pump(const Duration(seconds: 1)); // finish the menu animation
// The selected dropdown item is both in menu we just popped up, and in
// the IndexedStack contained by the dropdown button. Both of them should
// have the same vertical center as the button.
List<RenderBox> itemBoxes = tester.renderObjectList(find.byKey(new ValueKey<String>('two'))).toList();
expect(itemBoxes.length, equals(2));
// When isDense is true, the button's height is reduced. The menu items'
// heights are not.
double menuItemHeight = itemBoxes.map((RenderBox box) => box.size.height).reduce(math.max);
expect(menuItemHeight, greaterThan(buttonBox.size.height));
for(RenderBox itemBox in itemBoxes) {
assert(itemBox.attached);
Point buttonBoxCenter = buttonBox.size.center(buttonBox.localToGlobal(Point.origin));
Point itemBoxCenter = itemBox.size.center(itemBox.localToGlobal(Point.origin));
expect(buttonBoxCenter.y, equals(itemBoxCenter.y));
}
// The two RenderParagraph objects, for the 'two' items' Text children,
// should have the same size and location.
checkSelectedItemTextGeometry(tester, 'two');
});
}
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