Add dense layout support to dropdown (#6906)
This commit is contained in:
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user