From 5ceaaeefce0d7a6fa9a5d13620bda936b038d6ec Mon Sep 17 00:00:00 2001 From: Hans Muller Date: Fri, 18 Nov 2016 10:30:58 -0800 Subject: [PATCH] Add dense layout support to dropdown (#6906) --- .../flutter/lib/src/material/drop_down.dart | 69 +++++--- .../flutter/test/material/drop_down_test.dart | 156 ++++++++++++++---- 2 files changed, 165 insertions(+), 60 deletions(-) diff --git a/packages/flutter/lib/src/material/drop_down.dart b/packages/flutter/lib/src/material/drop_down.dart index 7b9ba9ae02..03be021fa8 100644 --- a/packages/flutter/lib/src/material/drop_down.dart +++ b/packages/flutter/lib/src/material/drop_down.dart @@ -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 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. /// * class DropdownButton extends StatefulWidget { /// Creates a dropdown button. @@ -420,7 +422,8 @@ class DropdownButton 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 item) => item.value == value).length == 1); @@ -454,6 +457,14 @@ class DropdownButton 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 createState() => new _DropdownButtonState(); } @@ -466,7 +477,7 @@ class _DropdownButtonState extends State> { assert(_selectedIndex != null); } - @override + @override void didUpdateConfig(DropdownButton oldConfig) { if (config.items[_selectedIndex].value != config.value) _updateSelectedIndex(); @@ -503,39 +514,49 @@ class _DropdownButtonState extends State> { }); } + // 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: [ - // 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: [ + 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: [ result, new Positioned( left: 0.0, right: 0.0, - bottom: 8.0, + bottom: bottom, child: new Container( height: 1.0, decoration: const BoxDecoration( diff --git a/packages/flutter/test/material/drop_down_test.dart b/packages/flutter/test/material/drop_down_test.dart index 87e07939cb..db01686794 100644 --- a/packages/flutter/test/material/drop_down_test.dart +++ b/packages/flutter/test/material/drop_down_test.dart @@ -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 onChanged, bool isDense: false }) { + final List items = ['one', 'two', 'three', 'four']; + return new MaterialApp( + home: new Material( + child: new Center( + child: new DropdownButton( + key: buttonKey, + value: value, + onChanged: onChanged, + isDense: isDense, + items: items.map((String item) { + return new DropdownMenuItem( + key: new ValueKey(item), + value: item, + child: new Text(item, key: new ValueKey(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 boxes = tester.renderObjectList(find.byKey(new ValueKey(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 items = ['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( - value: value, - items: items.map((String item) { - return new DropdownMenuItem( - 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 items = ['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( - value: value, - items: items.map((String item) { - return new DropdownMenuItem( - 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 itemBoxes = tester.renderObjectList(find.byKey(new ValueKey('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 itemBoxes = tester.renderObjectList(find.byKey(new ValueKey('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'); + }); }