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

This commit is contained in:
Hans Muller 2018-03-06 17:37:27 -08:00 committed by GitHub
parent 01d8e0a143
commit e1c38aa024
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 171 additions and 16 deletions

View file

@ -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<ButtonTextTheme>('textTheme', textTheme,
defaultValue: defaultTheme.textTheme));
description.add(new EnumProperty<ButtonTextTheme>('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<EdgeInsetsGeometry>('padding', padding,
defaultValue: defaultTheme.padding));
description.add(
new DiagnosticsProperty<ShapeBorder>('shape', shape, defaultValue: defaultTheme.shape));
description.add(new DiagnosticsProperty<EdgeInsetsGeometry>('padding', padding, defaultValue: defaultTheme.padding));
description.add(new DiagnosticsProperty<ShapeBorder>('shape', shape, defaultValue: defaultTheme.shape));
description.add(new FlagProperty('alignedDropdown',
value: alignedDropdown,
defaultValue: defaultTheme.alignedDropdown,
ifTrue: 'dropdown width matches button',
));
}
}

View file

@ -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<T> extends StatefulWidget {
const _DropdownMenu({
Key key,
this.padding,
this.route,
}) : super(key: key);
final _DropdownRoute<T> route;
final EdgeInsets padding;
@override
_DropdownMenuState<T> createState() => new _DropdownMenuState<T>();
@ -149,7 +156,7 @@ class _DropdownMenuState<T> extends State<_DropdownMenu<T>> {
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<T> 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<T> 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<T> {
class _DropdownRoute<T> extends PopupRoute<_DropdownRouteResult<T>> {
_DropdownRoute({
this.items,
this.padding,
this.buttonRect,
this.selectedIndex,
this.elevation: 8,
@ -288,6 +296,7 @@ class _DropdownRoute<T> extends PopupRoute<_DropdownRouteResult<T>> {
}) : assert(style != null);
final List<DropdownMenuItem<T>> items;
final EdgeInsetsGeometry padding;
final Rect buttonRect;
final int selectedIndex;
final int elevation;
@ -336,7 +345,12 @@ class _DropdownRoute<T> extends PopupRoute<_DropdownRouteResult<T>> {
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)
menu = new Theme(data: theme, child: menu);
@ -353,7 +367,7 @@ class _DropdownRoute<T> extends PopupRoute<_DropdownRouteResult<T>> {
buttonRect: buttonRect,
menuTop: menuTop,
menuHeight: menuHeight,
textDirection: Directionality.of(context),
textDirection: textDirection,
),
child: menu,
);
@ -566,11 +580,16 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> 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<T>(
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<T> extends State<DropdownButton<T>> 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,

View file

@ -491,7 +491,7 @@ class ThemeData extends Diagnosticable {
Color unselectedWidgetColor,
Color disabledColor,
Color buttonColor,
Color buttonTheme,
ButtonThemeData buttonTheme,
Color secondaryHeaderColor,
Color textSelectionColor,
Color textSelectionHandleColor,

View file

@ -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<Material>(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<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)));
});
}