diff --git a/packages/flutter/lib/src/material/button_theme.dart b/packages/flutter/lib/src/material/button_theme.dart index 9529ceb344..16be95f165 100644 --- a/packages/flutter/lib/src/material/button_theme.dart +++ b/packages/flutter/lib/src/material/button_theme.dart @@ -64,16 +64,19 @@ class ButtonTheme extends InheritedWidget { double height: 36.0, EdgeInsetsGeometry padding, ShapeBorder shape, + bool alignedDropdown: false, Widget child, }) : assert(textTheme != null), assert(minWidth != null && minWidth >= 0.0), assert(height != null && height >= 0.0), + assert(alignedDropdown != null), data = new ButtonThemeData( textTheme: textTheme, minWidth: minWidth, height: height, padding: padding, shape: shape, + alignedDropdown: alignedDropdown ), super(key: key, child: child); @@ -98,16 +101,19 @@ class ButtonTheme extends InheritedWidget { double height: 36.0, EdgeInsetsGeometry padding: const EdgeInsets.symmetric(horizontal: 8.0), ShapeBorder shape, + bool alignedDropdown: false, Widget child, }) : assert(textTheme != null), assert(minWidth != null && minWidth >= 0.0), assert(height != null && height >= 0.0), + assert(alignedDropdown != null), data = new ButtonThemeData( textTheme: textTheme, minWidth: minWidth, height: height, padding: padding, shape: shape, + alignedDropdown: alignedDropdown, ), super(key: key, child: child); @@ -146,9 +152,11 @@ class ButtonThemeData extends Diagnosticable { this.height: 36.0, EdgeInsetsGeometry padding, ShapeBorder shape, + this.alignedDropdown: false, }) : assert(textTheme != null), assert(minWidth != null && minWidth >= 0.0), assert(height != null && height >= 0.0), + assert(alignedDropdown != null), _padding = padding, _shape = shape; @@ -229,6 +237,17 @@ class ButtonThemeData extends Diagnosticable { } final ShapeBorder _shape; + /// If true, then a [DropdownButton] menu's width will match the button's + /// width. + /// + /// If false (the default), then the dropdown's menu will be wider than + /// its button. In either case the dropdown button will line up the leading + /// edge of the menu's value with the leading edge of the values + /// displayed by the menu items. + /// + /// This property only affects [DropdownButton] and its menu. + final bool alignedDropdown; + @override bool operator ==(dynamic other) { if (other.runtimeType != runtimeType) @@ -238,7 +257,8 @@ class ButtonThemeData extends Diagnosticable { && minWidth == typedOther.minWidth && height == typedOther.height && padding == typedOther.padding - && shape == typedOther.shape; + && shape == typedOther.shape + && alignedDropdown == typedOther.alignedDropdown; } @override @@ -249,6 +269,7 @@ class ButtonThemeData extends Diagnosticable { height, padding, shape, + alignedDropdown, ); } @@ -256,13 +277,15 @@ class ButtonThemeData extends Diagnosticable { void debugFillProperties(DiagnosticPropertiesBuilder description) { super.debugFillProperties(description); final ButtonThemeData defaultTheme = const ButtonThemeData(); - description.add(new EnumProperty('textTheme', textTheme, - defaultValue: defaultTheme.textTheme)); + description.add(new EnumProperty('textTheme', textTheme, defaultValue: defaultTheme.textTheme)); description.add(new DoubleProperty('minWidth', minWidth, defaultValue: defaultTheme.minWidth)); description.add(new DoubleProperty('height', height, defaultValue: defaultTheme.height)); - description.add(new DiagnosticsProperty('padding', padding, - defaultValue: defaultTheme.padding)); - description.add( - new DiagnosticsProperty('shape', shape, defaultValue: defaultTheme.shape)); + description.add(new DiagnosticsProperty('padding', padding, defaultValue: defaultTheme.padding)); + description.add(new DiagnosticsProperty('shape', shape, defaultValue: defaultTheme.shape)); + description.add(new FlagProperty('alignedDropdown', + value: alignedDropdown, + defaultValue: defaultTheme.alignedDropdown, + ifTrue: 'dropdown width matches button', + )); } } diff --git a/packages/flutter/lib/src/material/dropdown.dart b/packages/flutter/lib/src/material/dropdown.dart index 35077889f6..3d8e3bcd9a 100644 --- a/packages/flutter/lib/src/material/dropdown.dart +++ b/packages/flutter/lib/src/material/dropdown.dart @@ -7,6 +7,7 @@ import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; +import 'button_theme.dart'; import 'colors.dart'; import 'constants.dart'; import 'debug.dart'; @@ -21,7 +22,11 @@ import 'theme.dart'; const Duration _kDropdownMenuDuration = const Duration(milliseconds: 300); const double _kMenuItemHeight = 48.0; const double _kDenseButtonHeight = 24.0; -const EdgeInsets _kMenuHorizontalPadding = const EdgeInsets.symmetric(horizontal: 16.0); +const EdgeInsets _kMenuItemPadding = const EdgeInsets.symmetric(horizontal: 16.0); +const EdgeInsetsGeometry _kAlignedButtonPadding = const EdgeInsetsDirectional.only(start: 16.0, end: 4.0); +const EdgeInsets _kUnalignedButtonPadding = EdgeInsets.zero; +const EdgeInsets _kAlignedMenuMargin = EdgeInsets.zero; +const EdgeInsetsGeometry _kUnalignedMenuMargin = const EdgeInsetsDirectional.only(start: 16.0, end: 24.0); class _DropdownMenuPainter extends CustomPainter { _DropdownMenuPainter({ @@ -91,10 +96,12 @@ class _DropdownScrollBehavior extends ScrollBehavior { class _DropdownMenu extends StatefulWidget { const _DropdownMenu({ Key key, + this.padding, this.route, }) : super(key: key); final _DropdownRoute route; + final EdgeInsets padding; @override _DropdownMenuState createState() => new _DropdownMenuState(); @@ -149,7 +156,7 @@ class _DropdownMenuState extends State<_DropdownMenu> { opacity: opacity, child: new InkWell( child: new Container( - padding: _kMenuHorizontalPadding, + padding: widget.padding, child: route.items[itemIndex], ), onTap: () => Navigator.pop( @@ -212,7 +219,7 @@ class _DropdownMenuRouteLayout extends SingleChildLayoutDelegate { final double maxHeight = math.max(0.0, constraints.maxHeight - 2 * _kMenuItemHeight); // The width of a menu should be at most the view width. This ensures that // the menu does not extend past the left and right edges of the screen. - final double width = math.min(constraints.maxWidth, buttonRect.width + 8.0); + final double width = math.min(constraints.maxWidth, buttonRect.width); return new BoxConstraints( minWidth: width, maxWidth: width, @@ -238,7 +245,7 @@ class _DropdownMenuRouteLayout extends SingleChildLayoutDelegate { double left; switch (textDirection) { case TextDirection.rtl: - left = buttonRect.right.clamp(0.0, size.width - childSize.width) - childSize.width; + left = buttonRect.right.clamp(0.0, size.width) - childSize.width; break; case TextDirection.ltr: left = buttonRect.left.clamp(0.0, size.width - childSize.width); @@ -279,6 +286,7 @@ class _DropdownRouteResult { class _DropdownRoute extends PopupRoute<_DropdownRouteResult> { _DropdownRoute({ this.items, + this.padding, this.buttonRect, this.selectedIndex, this.elevation: 8, @@ -288,6 +296,7 @@ class _DropdownRoute extends PopupRoute<_DropdownRouteResult> { }) : assert(style != null); final List> items; + final EdgeInsetsGeometry padding; final Rect buttonRect; final int selectedIndex; final int elevation; @@ -336,7 +345,12 @@ class _DropdownRoute extends PopupRoute<_DropdownRouteResult> { scrollController = new ScrollController(initialScrollOffset: scrollOffset); } - Widget menu = new _DropdownMenu(route: this); + final TextDirection textDirection = Directionality.of(context); + Widget menu = new _DropdownMenu( + route: this, + padding: padding.resolve(textDirection), + ); + if (theme != null) menu = new Theme(data: theme, child: menu); @@ -353,7 +367,7 @@ class _DropdownRoute extends PopupRoute<_DropdownRouteResult> { buttonRect: buttonRect, menuTop: menuTop, menuHeight: menuHeight, - textDirection: Directionality.of(context), + textDirection: textDirection, ), child: menu, ); @@ -566,11 +580,16 @@ class _DropdownButtonState extends State> with WidgetsBindi void _handleTap() { final RenderBox itemBox = context.findRenderObject(); final Rect itemRect = itemBox.localToGlobal(Offset.zero) & itemBox.size; + final TextDirection textDirection = Directionality.of(context); + final EdgeInsetsGeometry menuMargin = ButtonTheme.of(context).alignedDropdown + ?_kAlignedMenuMargin + : _kUnalignedMenuMargin; assert(_dropdownRoute == null); _dropdownRoute = new _DropdownRoute( items: widget.items, - buttonRect: _kMenuHorizontalPadding.inflateRect(itemRect), + buttonRect: menuMargin.resolve(textDirection).inflateRect(itemRect), + padding: _kMenuItemPadding.resolve(textDirection), selectedIndex: _selectedIndex ?? 0, elevation: widget.elevation, theme: Theme.of(context, shadowThemeOnly: true), @@ -613,9 +632,14 @@ class _DropdownButtonState extends State> with WidgetsBindi )); } + final EdgeInsetsGeometry padding = ButtonTheme.of(context).alignedDropdown + ? _kAlignedButtonPadding + : _kUnalignedButtonPadding; + Widget result = new DefaultTextStyle( style: _textStyle, - child: new SizedBox( + child: new Container( + padding: padding.resolve(Directionality.of(context)), height: widget.isDense ? _denseButtonHeight : null, child: new Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, diff --git a/packages/flutter/lib/src/material/theme_data.dart b/packages/flutter/lib/src/material/theme_data.dart index 318151a16b..d9dda735c2 100644 --- a/packages/flutter/lib/src/material/theme_data.dart +++ b/packages/flutter/lib/src/material/theme_data.dart @@ -491,7 +491,7 @@ class ThemeData extends Diagnosticable { Color unselectedWidgetColor, Color disabledColor, Color buttonColor, - Color buttonTheme, + ButtonThemeData buttonTheme, Color secondaryHeaderColor, Color textSelectionColor, Color textSelectionHandleColor, diff --git a/packages/flutter/test/material/button_theme_test.dart b/packages/flutter/test/material/button_theme_test.dart index 0391f531d0..b5ca8af3a1 100644 --- a/packages/flutter/test/material/button_theme_test.dart +++ b/packages/flutter/test/material/button_theme_test.dart @@ -14,6 +14,7 @@ void main() { expect(theme.shape, const RoundedRectangleBorder( borderRadius: const BorderRadius.all(const Radius.circular(2.0)), )); + expect(theme.alignedDropdown, false); }); test('ButtonThemeData default overrides', () { @@ -23,11 +24,13 @@ void main() { height: 200.0, padding: EdgeInsets.zero, shape: const RoundedRectangleBorder(), + alignedDropdown: true, ); expect(theme.textTheme, ButtonTextTheme.primary); expect(theme.constraints, const BoxConstraints(minWidth: 100.0, minHeight: 200.0)); expect(theme.padding, EdgeInsets.zero); expect(theme.shape, const RoundedRectangleBorder()); + expect(theme.alignedDropdown, true); }); testWidgets('ButtonTheme defaults', (WidgetTester tester) async { @@ -173,4 +176,109 @@ void main() { expect(tester.widget(find.byType(Material)).color, const Color(0xFF00FF00)); expect(tester.getSize(find.byType(Material)), const Size(100.0, 200.0)); }); + + testWidgets('ButtonTheme alignedDropdown', (WidgetTester tester) async { + final Key dropdownKey = new UniqueKey(); + + Widget buildFrame({ bool alignedDropdown, TextDirection textDirection }) { + return new MaterialApp( + builder: (BuildContext context, Widget child) { + return new Directionality( + textDirection: textDirection, + child: child, + ); + }, + home: new ButtonTheme( + alignedDropdown: alignedDropdown, + child: new Material( + child: new Builder( + builder: (BuildContext context) { + return new Container( + alignment: Alignment.center, + child: new DropdownButtonHideUnderline( + child: new Container( + width: 200.0, + child: new DropdownButton( + key: dropdownKey, + onChanged: (String value) { }, + value: 'foo', + items: const >[ + const DropdownMenuItem( + value: 'foo', + child: const Text('foo'), + ), + const DropdownMenuItem( + value: 'bar', + child: const Text('bar'), + ), + ], + ), + ), + ), + ); + }, + ), + ), + ), + ); + } + + final Finder button = find.byKey(dropdownKey); + final Finder menu = find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DropdownMenu'); + + await tester.pumpWidget( + buildFrame( + alignedDropdown: false, + textDirection: TextDirection.ltr, + ), + ); + await tester.tap(button); + await tester.pumpAndSettle(); + + // 240 = 200.0 (button width) + _kUnalignedMenuMargin (20.0 left and right) + expect(tester.getSize(button).width, 200.0); + expect(tester.getSize(menu).width, 240.0); + + // Dismiss the menu. + await tester.tapAt(Offset.zero); + await tester.pumpAndSettle(); + expect(menu, findsNothing); + + await tester.pumpWidget( + buildFrame( + alignedDropdown: true, + textDirection: TextDirection.ltr, + ), + ); + await tester.tap(button); + await tester.pumpAndSettle(); + + // Aligneddropdown: true means the button and menu widths match + expect(tester.getSize(button).width, 200.0); + expect(tester.getSize(menu).width, 200.0); + + // There are two 'foo' widgets: the selected menu item's label and the drop + // down button's label. The should both appear at the same location. + final Finder fooText = find.text('foo'); + expect(fooText, findsNWidgets(2)); + expect(tester.getRect(fooText.at(0)), tester.getRect(fooText.at(1))); + + // Dismiss the menu. + await tester.tapAt(Offset.zero); + await tester.pumpAndSettle(); + expect(menu, findsNothing); + + // Same test as above execpt RTL + await tester.pumpWidget( + buildFrame( + alignedDropdown: true, + textDirection: TextDirection.rtl, + ), + ); + await tester.tap(button); + await tester.pumpAndSettle(); + + expect(fooText, findsNWidgets(2)); + expect(tester.getRect(fooText.at(0)), tester.getRect(fooText.at(1))); + }); }