Unverified Commit e1c38aa0 authored by Hans Muller's avatar Hans Muller Committed by GitHub

Enable aligning a dropdown button's width with its menu's width (#14849)

parent 01d8e0a1
...@@ -64,16 +64,19 @@ class ButtonTheme extends InheritedWidget { ...@@ -64,16 +64,19 @@ class ButtonTheme extends InheritedWidget {
double height: 36.0, double height: 36.0,
EdgeInsetsGeometry padding, EdgeInsetsGeometry padding,
ShapeBorder shape, ShapeBorder shape,
bool alignedDropdown: false,
Widget child, Widget child,
}) : assert(textTheme != null), }) : assert(textTheme != null),
assert(minWidth != null && minWidth >= 0.0), assert(minWidth != null && minWidth >= 0.0),
assert(height != null && height >= 0.0), assert(height != null && height >= 0.0),
assert(alignedDropdown != null),
data = new ButtonThemeData( data = new ButtonThemeData(
textTheme: textTheme, textTheme: textTheme,
minWidth: minWidth, minWidth: minWidth,
height: height, height: height,
padding: padding, padding: padding,
shape: shape, shape: shape,
alignedDropdown: alignedDropdown
), ),
super(key: key, child: child); super(key: key, child: child);
...@@ -98,16 +101,19 @@ class ButtonTheme extends InheritedWidget { ...@@ -98,16 +101,19 @@ class ButtonTheme extends InheritedWidget {
double height: 36.0, double height: 36.0,
EdgeInsetsGeometry padding: const EdgeInsets.symmetric(horizontal: 8.0), EdgeInsetsGeometry padding: const EdgeInsets.symmetric(horizontal: 8.0),
ShapeBorder shape, ShapeBorder shape,
bool alignedDropdown: false,
Widget child, Widget child,
}) : assert(textTheme != null), }) : assert(textTheme != null),
assert(minWidth != null && minWidth >= 0.0), assert(minWidth != null && minWidth >= 0.0),
assert(height != null && height >= 0.0), assert(height != null && height >= 0.0),
assert(alignedDropdown != null),
data = new ButtonThemeData( data = new ButtonThemeData(
textTheme: textTheme, textTheme: textTheme,
minWidth: minWidth, minWidth: minWidth,
height: height, height: height,
padding: padding, padding: padding,
shape: shape, shape: shape,
alignedDropdown: alignedDropdown,
), ),
super(key: key, child: child); super(key: key, child: child);
...@@ -146,9 +152,11 @@ class ButtonThemeData extends Diagnosticable { ...@@ -146,9 +152,11 @@ class ButtonThemeData extends Diagnosticable {
this.height: 36.0, this.height: 36.0,
EdgeInsetsGeometry padding, EdgeInsetsGeometry padding,
ShapeBorder shape, ShapeBorder shape,
this.alignedDropdown: false,
}) : assert(textTheme != null), }) : assert(textTheme != null),
assert(minWidth != null && minWidth >= 0.0), assert(minWidth != null && minWidth >= 0.0),
assert(height != null && height >= 0.0), assert(height != null && height >= 0.0),
assert(alignedDropdown != null),
_padding = padding, _padding = padding,
_shape = shape; _shape = shape;
...@@ -229,6 +237,17 @@ class ButtonThemeData extends Diagnosticable { ...@@ -229,6 +237,17 @@ class ButtonThemeData extends Diagnosticable {
} }
final ShapeBorder _shape; 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 @override
bool operator ==(dynamic other) { bool operator ==(dynamic other) {
if (other.runtimeType != runtimeType) if (other.runtimeType != runtimeType)
...@@ -238,7 +257,8 @@ class ButtonThemeData extends Diagnosticable { ...@@ -238,7 +257,8 @@ class ButtonThemeData extends Diagnosticable {
&& minWidth == typedOther.minWidth && minWidth == typedOther.minWidth
&& height == typedOther.height && height == typedOther.height
&& padding == typedOther.padding && padding == typedOther.padding
&& shape == typedOther.shape; && shape == typedOther.shape
&& alignedDropdown == typedOther.alignedDropdown;
} }
@override @override
...@@ -249,6 +269,7 @@ class ButtonThemeData extends Diagnosticable { ...@@ -249,6 +269,7 @@ class ButtonThemeData extends Diagnosticable {
height, height,
padding, padding,
shape, shape,
alignedDropdown,
); );
} }
...@@ -256,13 +277,15 @@ class ButtonThemeData extends Diagnosticable { ...@@ -256,13 +277,15 @@ class ButtonThemeData extends Diagnosticable {
void debugFillProperties(DiagnosticPropertiesBuilder description) { void debugFillProperties(DiagnosticPropertiesBuilder description) {
super.debugFillProperties(description); super.debugFillProperties(description);
final ButtonThemeData defaultTheme = const ButtonThemeData(); final ButtonThemeData defaultTheme = const ButtonThemeData();
description.add(new EnumProperty<ButtonTextTheme>('textTheme', textTheme, description.add(new EnumProperty<ButtonTextTheme>('textTheme', textTheme, defaultValue: defaultTheme.textTheme));
defaultValue: defaultTheme.textTheme));
description.add(new DoubleProperty('minWidth', minWidth, defaultValue: defaultTheme.minWidth)); description.add(new DoubleProperty('minWidth', minWidth, defaultValue: defaultTheme.minWidth));
description.add(new DoubleProperty('height', height, defaultValue: defaultTheme.height)); description.add(new DoubleProperty('height', height, defaultValue: defaultTheme.height));
description.add(new DiagnosticsProperty<EdgeInsetsGeometry>('padding', padding, description.add(new DiagnosticsProperty<EdgeInsetsGeometry>('padding', padding, defaultValue: defaultTheme.padding));
defaultValue: defaultTheme.padding)); description.add(new DiagnosticsProperty<ShapeBorder>('shape', shape, defaultValue: defaultTheme.shape));
description.add( description.add(new FlagProperty('alignedDropdown',
new DiagnosticsProperty<ShapeBorder>('shape', shape, defaultValue: defaultTheme.shape)); value: alignedDropdown,
defaultValue: defaultTheme.alignedDropdown,
ifTrue: 'dropdown width matches button',
));
} }
} }
...@@ -7,6 +7,7 @@ import 'dart:math' as math; ...@@ -7,6 +7,7 @@ import 'dart:math' as math;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'button_theme.dart';
import 'colors.dart'; import 'colors.dart';
import 'constants.dart'; import 'constants.dart';
import 'debug.dart'; import 'debug.dart';
...@@ -21,7 +22,11 @@ import 'theme.dart'; ...@@ -21,7 +22,11 @@ import 'theme.dart';
const Duration _kDropdownMenuDuration = const Duration(milliseconds: 300); const Duration _kDropdownMenuDuration = const Duration(milliseconds: 300);
const double _kMenuItemHeight = 48.0; const double _kMenuItemHeight = 48.0;
const double _kDenseButtonHeight = 24.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 { class _DropdownMenuPainter extends CustomPainter {
_DropdownMenuPainter({ _DropdownMenuPainter({
...@@ -91,10 +96,12 @@ class _DropdownScrollBehavior extends ScrollBehavior { ...@@ -91,10 +96,12 @@ class _DropdownScrollBehavior extends ScrollBehavior {
class _DropdownMenu<T> extends StatefulWidget { class _DropdownMenu<T> extends StatefulWidget {
const _DropdownMenu({ const _DropdownMenu({
Key key, Key key,
this.padding,
this.route, this.route,
}) : super(key: key); }) : super(key: key);
final _DropdownRoute<T> route; final _DropdownRoute<T> route;
final EdgeInsets padding;
@override @override
_DropdownMenuState<T> createState() => new _DropdownMenuState<T>(); _DropdownMenuState<T> createState() => new _DropdownMenuState<T>();
...@@ -149,7 +156,7 @@ class _DropdownMenuState<T> extends State<_DropdownMenu<T>> { ...@@ -149,7 +156,7 @@ class _DropdownMenuState<T> extends State<_DropdownMenu<T>> {
opacity: opacity, opacity: opacity,
child: new InkWell( child: new InkWell(
child: new Container( child: new Container(
padding: _kMenuHorizontalPadding, padding: widget.padding,
child: route.items[itemIndex], child: route.items[itemIndex],
), ),
onTap: () => Navigator.pop( onTap: () => Navigator.pop(
...@@ -212,7 +219,7 @@ class _DropdownMenuRouteLayout<T> extends SingleChildLayoutDelegate { ...@@ -212,7 +219,7 @@ class _DropdownMenuRouteLayout<T> extends SingleChildLayoutDelegate {
final double maxHeight = math.max(0.0, constraints.maxHeight - 2 * _kMenuItemHeight); 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 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. // 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( return new BoxConstraints(
minWidth: width, minWidth: width,
maxWidth: width, maxWidth: width,
...@@ -238,7 +245,7 @@ class _DropdownMenuRouteLayout<T> extends SingleChildLayoutDelegate { ...@@ -238,7 +245,7 @@ class _DropdownMenuRouteLayout<T> extends SingleChildLayoutDelegate {
double left; double left;
switch (textDirection) { switch (textDirection) {
case TextDirection.rtl: 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; break;
case TextDirection.ltr: case TextDirection.ltr:
left = buttonRect.left.clamp(0.0, size.width - childSize.width); left = buttonRect.left.clamp(0.0, size.width - childSize.width);
...@@ -279,6 +286,7 @@ class _DropdownRouteResult<T> { ...@@ -279,6 +286,7 @@ class _DropdownRouteResult<T> {
class _DropdownRoute<T> extends PopupRoute<_DropdownRouteResult<T>> { class _DropdownRoute<T> extends PopupRoute<_DropdownRouteResult<T>> {
_DropdownRoute({ _DropdownRoute({
this.items, this.items,
this.padding,
this.buttonRect, this.buttonRect,
this.selectedIndex, this.selectedIndex,
this.elevation: 8, this.elevation: 8,
...@@ -288,6 +296,7 @@ class _DropdownRoute<T> extends PopupRoute<_DropdownRouteResult<T>> { ...@@ -288,6 +296,7 @@ class _DropdownRoute<T> extends PopupRoute<_DropdownRouteResult<T>> {
}) : assert(style != null); }) : assert(style != null);
final List<DropdownMenuItem<T>> items; final List<DropdownMenuItem<T>> items;
final EdgeInsetsGeometry padding;
final Rect buttonRect; final Rect buttonRect;
final int selectedIndex; final int selectedIndex;
final int elevation; final int elevation;
...@@ -336,7 +345,12 @@ class _DropdownRoute<T> extends PopupRoute<_DropdownRouteResult<T>> { ...@@ -336,7 +345,12 @@ class _DropdownRoute<T> extends PopupRoute<_DropdownRouteResult<T>> {
scrollController = new ScrollController(initialScrollOffset: scrollOffset); scrollController = new ScrollController(initialScrollOffset: scrollOffset);
} }
Widget menu = new _DropdownMenu<T>(route: this); final TextDirection textDirection = Directionality.of(context);
Widget menu = new _DropdownMenu<T>(
route: this,
padding: padding.resolve(textDirection),
);
if (theme != null) if (theme != null)
menu = new Theme(data: theme, child: menu); menu = new Theme(data: theme, child: menu);
...@@ -353,7 +367,7 @@ class _DropdownRoute<T> extends PopupRoute<_DropdownRouteResult<T>> { ...@@ -353,7 +367,7 @@ class _DropdownRoute<T> extends PopupRoute<_DropdownRouteResult<T>> {
buttonRect: buttonRect, buttonRect: buttonRect,
menuTop: menuTop, menuTop: menuTop,
menuHeight: menuHeight, menuHeight: menuHeight,
textDirection: Directionality.of(context), textDirection: textDirection,
), ),
child: menu, child: menu,
); );
...@@ -566,11 +580,16 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi ...@@ -566,11 +580,16 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi
void _handleTap() { void _handleTap() {
final RenderBox itemBox = context.findRenderObject(); final RenderBox itemBox = context.findRenderObject();
final Rect itemRect = itemBox.localToGlobal(Offset.zero) & itemBox.size; 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); assert(_dropdownRoute == null);
_dropdownRoute = new _DropdownRoute<T>( _dropdownRoute = new _DropdownRoute<T>(
items: widget.items, items: widget.items,
buttonRect: _kMenuHorizontalPadding.inflateRect(itemRect), buttonRect: menuMargin.resolve(textDirection).inflateRect(itemRect),
padding: _kMenuItemPadding.resolve(textDirection),
selectedIndex: _selectedIndex ?? 0, selectedIndex: _selectedIndex ?? 0,
elevation: widget.elevation, elevation: widget.elevation,
theme: Theme.of(context, shadowThemeOnly: true), theme: Theme.of(context, shadowThemeOnly: true),
...@@ -613,9 +632,14 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi ...@@ -613,9 +632,14 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi
)); ));
} }
final EdgeInsetsGeometry padding = ButtonTheme.of(context).alignedDropdown
? _kAlignedButtonPadding
: _kUnalignedButtonPadding;
Widget result = new DefaultTextStyle( Widget result = new DefaultTextStyle(
style: _textStyle, style: _textStyle,
child: new SizedBox( child: new Container(
padding: padding.resolve(Directionality.of(context)),
height: widget.isDense ? _denseButtonHeight : null, height: widget.isDense ? _denseButtonHeight : null,
child: new Row( child: new Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
......
...@@ -491,7 +491,7 @@ class ThemeData extends Diagnosticable { ...@@ -491,7 +491,7 @@ class ThemeData extends Diagnosticable {
Color unselectedWidgetColor, Color unselectedWidgetColor,
Color disabledColor, Color disabledColor,
Color buttonColor, Color buttonColor,
Color buttonTheme, ButtonThemeData buttonTheme,
Color secondaryHeaderColor, Color secondaryHeaderColor,
Color textSelectionColor, Color textSelectionColor,
Color textSelectionHandleColor, Color textSelectionHandleColor,
......
...@@ -14,6 +14,7 @@ void main() { ...@@ -14,6 +14,7 @@ void main() {
expect(theme.shape, const RoundedRectangleBorder( expect(theme.shape, const RoundedRectangleBorder(
borderRadius: const BorderRadius.all(const Radius.circular(2.0)), borderRadius: const BorderRadius.all(const Radius.circular(2.0)),
)); ));
expect(theme.alignedDropdown, false);
}); });
test('ButtonThemeData default overrides', () { test('ButtonThemeData default overrides', () {
...@@ -23,11 +24,13 @@ void main() { ...@@ -23,11 +24,13 @@ void main() {
height: 200.0, height: 200.0,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
shape: const RoundedRectangleBorder(), shape: const RoundedRectangleBorder(),
alignedDropdown: true,
); );
expect(theme.textTheme, ButtonTextTheme.primary); expect(theme.textTheme, ButtonTextTheme.primary);
expect(theme.constraints, const BoxConstraints(minWidth: 100.0, minHeight: 200.0)); expect(theme.constraints, const BoxConstraints(minWidth: 100.0, minHeight: 200.0));
expect(theme.padding, EdgeInsets.zero); expect(theme.padding, EdgeInsets.zero);
expect(theme.shape, const RoundedRectangleBorder()); expect(theme.shape, const RoundedRectangleBorder());
expect(theme.alignedDropdown, true);
}); });
testWidgets('ButtonTheme defaults', (WidgetTester tester) async { testWidgets('ButtonTheme defaults', (WidgetTester tester) async {
...@@ -173,4 +176,109 @@ void main() { ...@@ -173,4 +176,109 @@ void main() {
expect(tester.widget<Material>(find.byType(Material)).color, const Color(0xFF00FF00)); expect(tester.widget<Material>(find.byType(Material)).color, const Color(0xFF00FF00));
expect(tester.getSize(find.byType(Material)), const Size(100.0, 200.0)); 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<String>(
key: dropdownKey,
onChanged: (String value) { },
value: 'foo',
items: const <DropdownMenuItem<String>>[
const DropdownMenuItem<String>(
value: 'foo',
child: const Text('foo'),
),
const DropdownMenuItem<String>(
value: 'bar',
child: const Text('bar'),
),
],
),
),
),
);
},
),
),
),
);
}
final Finder button = find.byKey(dropdownKey);
final Finder menu = find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DropdownMenu<String>');
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)));
});
} }
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