diff --git a/AUTHORS b/AUTHORS index ccb644693ec..2a36d97be93 100644 --- a/AUTHORS +++ b/AUTHORS @@ -39,4 +39,5 @@ Marco Scannadinari Frederik Schweiger Martin Staadecker Igor Katsuba -Diego Velásquez \ No newline at end of file +Diego Velásquez +Sarbagya Dhaubanjar \ No newline at end of file diff --git a/packages/flutter/lib/src/material/dropdown.dart b/packages/flutter/lib/src/material/dropdown.dart index 840d0513b5c..b8e54bd53a3 100644 --- a/packages/flutter/lib/src/material/dropdown.dart +++ b/packages/flutter/lib/src/material/dropdown.dart @@ -184,24 +184,24 @@ class _DropdownMenuState extends State<_DropdownMenu> { explicitChildNodes: true, label: localizations.popupMenuLabel, child: Material( - type: MaterialType.transparency, - textStyle: route.style, - child: ScrollConfiguration( - behavior: const _DropdownScrollBehavior(), - child: Scrollbar( - child: ListView( - controller: widget.route.scrollController, - padding: kMaterialListPadding, - itemExtent: _kMenuItemHeight, - shrinkWrap: true, - children: children, - ), + type: MaterialType.transparency, + textStyle: route.style, + child: ScrollConfiguration( + behavior: const _DropdownScrollBehavior(), + child: Scrollbar( + child: ListView( + controller: widget.route.scrollController, + padding: kMaterialListPadding, + itemExtent: _kMenuItemHeight, + shrinkWrap: true, + children: children, ), ), ), ), ), - ); + ), + ); } } @@ -253,7 +253,7 @@ class _DropdownMenuRouteLayout extends SingleChildLayoutDelegate { double left; switch (textDirection) { case TextDirection.rtl: - left = buttonRect.right.clamp(0.0, size.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); @@ -637,7 +637,7 @@ class DropdownButton extends StatefulWidget { /// if the first item were selected. final T value; - /// Displayed if [value] is null. + /// A placeholder widget that is displayed if no item is selected, i.e. if [value] is null. final Widget hint; /// A message to show when the dropdown is disabled. @@ -645,12 +645,14 @@ class DropdownButton extends StatefulWidget { /// Displayed if [items] or [onChanged] is null. final Widget disabledHint; + /// {@template flutter.material.dropdownButton.onChanged} /// Called when the user selects an item. /// /// If the [onChanged] callback is null or the list of [items] is null /// then the dropdown button will be disabled, i.e. its arrow will be /// displayed in grey and it will not respond to input. A disabled button /// will display the [disabledHint] widget if it is non-null. + /// {@endtemplate} final ValueChanged onChanged; /// The z-coordinate at which to place the menu when open. @@ -777,7 +779,7 @@ class _DropdownButtonState extends State> with WidgetsBindi final Rect itemRect = itemBox.localToGlobal(Offset.zero) & itemBox.size; final TextDirection textDirection = Directionality.of(context); final EdgeInsetsGeometry menuMargin = ButtonTheme.of(context).alignedDropdown - ?_kAlignedMenuMargin + ? _kAlignedMenuMargin : _kUnalignedMenuMargin; assert(_dropdownRoute == null); @@ -813,22 +815,20 @@ class _DropdownButtonState extends State> with WidgetsBindi Color get _iconColor { // These colors are not defined in the Material Design spec. if (_enabled) { - if (widget.iconEnabledColor != null) { + if (widget.iconEnabledColor != null) return widget.iconEnabledColor; - } - switch(Theme.of(context).brightness) { + switch (Theme.of(context).brightness) { case Brightness.light: return Colors.grey.shade700; case Brightness.dark: return Colors.white70; } } else { - if (widget.iconDisabledColor != null) { + if (widget.iconDisabledColor != null) return widget.iconDisabledColor; - } - switch(Theme.of(context).brightness) { + switch (Theme.of(context).brightness) { case Brightness.light: return Colors.grey.shade400; case Brightness.dark: @@ -852,8 +852,9 @@ class _DropdownButtonState extends State> with WidgetsBindi final List items = _enabled ? List.from(widget.items) : []; int hintIndex; if (widget.hint != null || (!_enabled && widget.disabledHint != null)) { - final Widget emplacedHint = - _enabled ? widget.hint : DropdownMenuItem(child: widget.disabledHint ?? widget.hint); + final Widget emplacedHint = _enabled + ? widget.hint + : DropdownMenuItem(child: widget.disabledHint ?? widget.hint); hintIndex = items.length; items.add(DefaultTextStyle( style: _textStyle.copyWith(color: Theme.of(context).hintColor), @@ -893,7 +894,9 @@ class _DropdownButtonState extends State> with WidgetsBindi mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisSize: MainAxisSize.min, children: [ - widget.isExpanded ? Expanded(child: innerItemsWidget) : innerItemsWidget, + widget.isExpanded + ? Expanded(child: innerItemsWidget) + : innerItemsWidget, IconTheme( data: IconThemeData( color: _iconColor, @@ -918,7 +921,12 @@ class _DropdownButtonState extends State> with WidgetsBindi child: widget.underline ?? Container( height: 1.0, decoration: const BoxDecoration( - border: Border(bottom: BorderSide(color: Color(0xFFBDBDBD), width: 0.0)) + border: Border( + bottom: BorderSide( + color: Color(0xFFBDBDBD), + width: 0.0, + ), + ), ), ), ), @@ -947,39 +955,73 @@ class DropdownButtonFormField extends FormField { Key key, T value, @required List> items, - this.onChanged, - InputDecoration decoration = const InputDecoration(), + Widget hint, + @required this.onChanged, + this.decoration = const InputDecoration(), FormFieldSetter onSaved, FormFieldValidator validator, - Widget hint, - }) : assert(decoration != null), + bool autovalidate = false, + Widget disabledHint, + int elevation = 8, + TextStyle style, + Widget icon, + Color iconDisabledColor, + Color iconEnabledColor, + double iconSize = 24.0, + bool isDense = false, + bool isExpanded = false, + }) : assert(items == null || items.isEmpty || value == null || items.where((DropdownMenuItem item) => item.value == value).length == 1), + assert(decoration != null), + assert(elevation != null), + assert(iconSize != null), + assert(isDense != null), + assert(isExpanded != null), super( key: key, onSaved: onSaved, initialValue: value, validator: validator, + autovalidate: autovalidate, builder: (FormFieldState field) { - final InputDecoration effectiveDecoration = decoration - .applyDefaults(Theme.of(field.context).inputDecorationTheme); + final InputDecoration effectiveDecoration = decoration.applyDefaults( + Theme.of(field.context).inputDecorationTheme, + ); return InputDecorator( decoration: effectiveDecoration.copyWith(errorText: field.errorText), isEmpty: value == null, child: DropdownButtonHideUnderline( child: DropdownButton( - isDense: true, value: value, items: items, hint: hint, - onChanged: field.didChange, + onChanged: onChanged == null ? null : field.didChange, + disabledHint: disabledHint, + elevation: elevation, + style: style, + icon: icon, + iconDisabledColor: iconDisabledColor, + iconEnabledColor: iconEnabledColor, + iconSize: iconSize, + isDense: isDense, + isExpanded: isExpanded, ), ), ); } ); - /// Called when the user selects an item. + /// {@macro flutter.material.dropdownButton.onChanged} final ValueChanged onChanged; + /// The decoration to show around the dropdown button form field. + /// + /// By default, draws a horizontal line under the dropdown button field but can be + /// configured to show an icon, label, hint text, and error text. + /// + /// Specify null to remove the decoration entirely (including the + /// extra padding introduced by the decoration to save space for the labels). + final InputDecoration decoration; + @override FormFieldState createState() => _DropdownButtonFormFieldState(); } @@ -991,7 +1033,7 @@ class _DropdownButtonFormFieldState extends FormFieldState { @override void didChange(T value) { super.didChange(value); - if (widget.onChanged != null) - widget.onChanged(value); + assert(widget.onChanged != null); + widget.onChanged(value); } } diff --git a/packages/flutter/test/material/dropdown_test.dart b/packages/flutter/test/material/dropdown_test.dart index 03c480ce187..30487092079 100644 --- a/packages/flutter/test/material/dropdown_test.dart +++ b/packages/flutter/test/material/dropdown_test.dart @@ -9,6 +9,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import '../rendering/mock_canvas.dart'; import '../widgets/semantics_tester.dart'; const List menuItems = ['one', 'two', 'three', 'four']; @@ -76,6 +77,59 @@ Widget buildFrame({ ); } +Widget buildFormFrame({ + Key buttonKey, + bool autovalidate = false, + int elevation = 8, + String value = 'two', + ValueChanged onChanged, + Widget icon, + Color iconDisabledColor, + Color iconEnabledColor, + double iconSize = 24.0, + bool isDense = false, + bool isExpanded = false, + Widget hint, + Widget disabledHint, + Widget underline, + List items = menuItems, + Alignment alignment = Alignment.center, + TextDirection textDirection = TextDirection.ltr, +}) { + return TestApp( + textDirection: textDirection, + child: Material( + child: Align( + alignment: alignment, + child: RepaintBoundary( + child: DropdownButtonFormField( + key: buttonKey, + autovalidate: autovalidate, + elevation: elevation, + value: value, + hint: hint, + disabledHint: disabledHint, + onChanged: onChanged, + icon: icon, + iconSize: iconSize, + iconDisabledColor: iconDisabledColor, + iconEnabledColor: iconEnabledColor, + isDense: isDense, + isExpanded: isExpanded, + items: items == null ? null : items.map>((String item) { + return DropdownMenuItem( + key: ValueKey(item), + value: item, + child: Text(item, key: ValueKey(item + 'Text')), + ); + }).toList(), + ), + ), + ), + ), + ); +} + class TestApp extends StatefulWidget { const TestApp({ this.textDirection, this.child }); final TextDirection textDirection; @@ -125,6 +179,29 @@ void checkSelectedItemTextGeometry(WidgetTester tester, String value) { expect(box0.size, equals(box1.size)); } +void verifyPaintedShadow(Finder customPaint, int elevation) { + const Rect originalRectangle = Rect.fromLTRB(0.0, 0.0, 800, 208.0); + + final List boxShadows = List.generate(3, (int index) => kElevationToShadow[elevation][index]); + final List rrects = List.generate(3, (int index) { + return RRect.fromRectAndRadius( + originalRectangle.shift( + boxShadows[index].offset + ).inflate(boxShadows[index].spreadRadius), + const Radius.circular(2.0), + ); + }); + + expect( + customPaint, + paints + ..save() + ..rrect(rrect: rrects[0], color: boxShadows[0].color, hasMaskFilter: true) + ..rrect(rrect: rrects[1], color: boxShadows[1].color, hasMaskFilter: true) + ..rrect(rrect: rrects[2], color: boxShadows[2].color, hasMaskFilter: true), + ); +} + bool sameGeometry(RenderBox box1, RenderBox box2) { expect(box1.localToGlobal(Offset.zero), equals(box2.localToGlobal(Offset.zero))); expect(box1.size.height, equals(box2.size.height)); @@ -257,48 +334,6 @@ void main() { expect(value, equals('two')); }); - testWidgets('Dropdown form field', (WidgetTester tester) async { - String value = 'one'; - - await tester.pumpWidget( - StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MaterialApp( - home: Material( - child: DropdownButtonFormField( - value: value, - hint: const Text('Select Value'), - decoration: const InputDecoration( - prefixIcon: Icon(Icons.fastfood) - ), - items: menuItems.map((String val) { - return DropdownMenuItem( - value: val, - child: Text(val), - ); - }).toList(), - onChanged: (String v) { - setState(() { - value = v; - }); - }, - validator: (String v) => v == null ? 'Must select value' : null, - ), - ), - ); - } - ) - ); - - expect(value, equals('one')); - await tester.tap(find.text('one')); - await tester.pumpAndSettle(); - await tester.tap(find.text('three').last); - await tester.pump(); - await tester.pumpAndSettle(); - expect(value, equals('three')); - }); - testWidgets('Dropdown in ListView', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/12053 // Positions a DropdownButton at the left and right edges of the screen, @@ -1281,4 +1316,329 @@ void main() { await tester.pumpWidget(buildFrame(buttonKey: buttonKey, value: 'two', onChanged: onChanged)); expect(tester.widget(decoratedBox).decoration, defaultDecoration); }); + + testWidgets('Dropdown form field with autovalidation test', (WidgetTester tester) async { + String value = 'one'; + int _validateCalled = 0; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MaterialApp( + home: Material( + child: DropdownButtonFormField( + value: value, + hint: const Text('Select Value'), + decoration: const InputDecoration( + prefixIcon: Icon(Icons.fastfood) + ), + items: menuItems.map((String value) { + return DropdownMenuItem( + value: value, + child: Text(value), + ); + }).toList(), + onChanged: (String newValue) { + setState(() { + value = newValue; + }); + }, + validator: (String currentValue) { + _validateCalled++; + return currentValue == null ? 'Must select value' : null; + }, + autovalidate: true, + ), + ), + ); + }, + ), + ); + + expect(_validateCalled, 1); + expect(value, equals('one')); + await tester.tap(find.text('one')); + await tester.pumpAndSettle(); + await tester.tap(find.text('three').last); + await tester.pump(); + expect(_validateCalled, 2); + await tester.pumpAndSettle(); + expect(value, equals('three')); + }); + + testWidgets('Arrow icon aligns with the edge of button in form field when expanded', (WidgetTester tester) async { + final Key buttonKey = UniqueKey(); + + // There shouldn't be overflow when expanded although list contains longer items. + final List items = [ + '1234567890', + 'abcdefghijklmnopqrstuvwxyz1234567890abcdefghijklmnopqrstuvwxyz1234567890', + ]; + + await tester.pumpWidget( + buildFormFrame( + buttonKey: buttonKey, + value: '1234567890', + isExpanded: true, + onChanged: onChanged, + items: items, + ), + ); + final RenderBox buttonBox = tester.renderObject( + find.byKey(buttonKey), + ); + expect(buttonBox.attached, isTrue); + + final RenderBox arrowIcon = tester.renderObject( + find.byIcon(Icons.arrow_drop_down), + ); + expect(arrowIcon.attached, isTrue); + + // Arrow icon should be aligned with far right of button when expanded + expect( + arrowIcon.localToGlobal(Offset.zero).dx, + buttonBox.size.centerRight(Offset(-arrowIcon.size.width, 0.0)).dx, + ); + }); + + testWidgets('Dropdown button form field with isDense:true aligns selected menu item', (WidgetTester tester) async { + final Key buttonKey = UniqueKey(); + const String value = 'two'; + + await tester.pumpWidget( + buildFormFrame( + buttonKey: buttonKey, + value: value, + isDense: true, + onChanged: onChanged, + ), + ); + final RenderBox buttonBox = tester.renderObject( + find.byKey(buttonKey), + ); + expect(buttonBox.attached, isTrue); + + 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. + final List itemBoxes = tester.renderObjectList( + find.byKey(const ValueKey('two')), + ).toList(); + expect(itemBoxes.length, equals(2)); + + // When isDense is true, the button's height is reduced. The menu items' + // heights are not. + final List itemBoxesHeight = itemBoxes.map((RenderBox box) => box.size.height).toList(); + final double menuItemHeight = itemBoxesHeight.reduce(math.max); + expect(menuItemHeight, greaterThanOrEqualTo(buttonBox.size.height)); + + for (RenderBox itemBox in itemBoxes) { + expect(itemBox.attached, isTrue); + final Offset buttonBoxCenter = buttonBox.size.center(buttonBox.localToGlobal(Offset.zero)); + final Offset itemBoxCenter = itemBox.size.center(itemBox.localToGlobal(Offset.zero)); + expect(buttonBoxCenter.dy, equals(itemBoxCenter.dy)); + } + }); + + testWidgets('Dropdown button form field - custom text style', (WidgetTester tester) async { + const String value = 'foo'; + final UniqueKey itemKey = UniqueKey(); + + await tester.pumpWidget( + TestApp( + textDirection: TextDirection.ltr, + child: Material( + child: DropdownButtonFormField( + value: value, + items: >[ + DropdownMenuItem( + key: itemKey, + value: 'foo', + child: const Text(value), + ), + ], + isDense: true, + onChanged: (_) { }, + style: const TextStyle( + color: Colors.amber, + fontSize: 20.0, + ), + ), + ), + ), + ); + + final RichText richText = tester.widget( + find.descendant( + of: find.byKey(itemKey), + matching: find.byType(RichText), + ), + ); + + expect(richText.text.style.color, Colors.amber); + expect(richText.text.style.fontSize, 20.0); + }); + + testWidgets('Dropdown form field - disabledHint displays when the items list is empty, when items is null', (WidgetTester tester) async { + final Key buttonKey = UniqueKey(); + + Widget build({ List items }){ + return buildFormFrame( + items: items, + buttonKey: buttonKey, + value: null, + hint: const Text('enabled'), + disabledHint: const Text('disabled'), + ); + } + // [disabledHint] should display when [items] is null + await tester.pumpWidget(build(items: null)); + expect(find.text('enabled'), findsNothing); + expect(find.text('disabled'), findsOneWidget); + + // [disabledHint] should display when [items] is an empty list. + await tester.pumpWidget(build(items: [])); + expect(find.text('enabled'), findsNothing); + expect(find.text('disabled'), findsOneWidget); + }); + + testWidgets('Dropdown form field - disabledHint displays when onChanged is null', (WidgetTester tester) async { + final Key buttonKey = UniqueKey(); + + Widget build({ List items, ValueChanged onChanged }){ + return buildFormFrame( + items: items, + buttonKey: buttonKey, + value: null, + onChanged: onChanged, + hint: const Text('enabled'), + disabledHint: const Text('disabled'), + ); + } + await tester.pumpWidget(build(items: menuItems, onChanged: null)); + expect(find.text('enabled'), findsNothing); + expect(find.text('disabled'), findsOneWidget); + }); + + testWidgets('Dropdown form field - disabled hint should be of same size as enabled hint', (WidgetTester tester) async { + final Key buttonKey = UniqueKey(); + + Widget build({ List items}){ + return buildFormFrame( + items: items, + buttonKey: buttonKey, + value: null, + hint: const Text('enabled'), + disabledHint: const Text('disabled'), + ); + } + await tester.pumpWidget(build(items: null)); + final RenderBox disabledHintBox = tester.renderObject( + find.byKey(buttonKey), + ); + + await tester.pumpWidget(build(items: menuItems)); + final RenderBox enabledHintBox = tester.renderObject( + find.byKey(buttonKey), + ); + expect(enabledHintBox.localToGlobal(Offset.zero), equals(disabledHintBox.localToGlobal(Offset.zero))); + expect(enabledHintBox.size, equals(disabledHintBox.size)); + }); + + testWidgets('Dropdown form field - Custom icon size and colors', (WidgetTester tester) async { + final Key iconKey = UniqueKey(); + final Icon customIcon = Icon(Icons.assessment, key: iconKey); + + await tester.pumpWidget(buildFormFrame( + icon: customIcon, + iconSize: 30.0, + iconEnabledColor: Colors.pink, + iconDisabledColor: Colors.orange, + onChanged: onChanged, + )); + + // test for size + final RenderBox icon = tester.renderObject(find.byKey(iconKey)); + expect(icon.size, const Size(30.0, 30.0)); + + // test for enabled color + final RichText enabledRichText = tester.widget(_iconRichText(iconKey)); + expect(enabledRichText.text.style.color, Colors.pink); + + // test for disabled color + await tester.pumpWidget(buildFormFrame( + icon: customIcon, + iconSize: 30.0, + iconEnabledColor: Colors.pink, + iconDisabledColor: Colors.orange, + items: null, + )); + + final RichText disabledRichText = tester.widget(_iconRichText(iconKey)); + expect(disabledRichText.text.style.color, Colors.orange); + }); + + testWidgets('Dropdown form field - default elevation', (WidgetTester tester) async { + final Key buttonKey = UniqueKey(); + debugDisableShadows = false; + await tester.pumpWidget(buildFormFrame( + buttonKey: buttonKey, + items: menuItems, + onChanged: onChanged, + )); + await tester.tap(find.byKey(buttonKey)); + await tester.pumpAndSettle(); + + final Finder customPaint = find.ancestor( + of: find.text('one').last, + matching: find.byType(CustomPaint), + ).last; + + // Verifying whether or not default elevation(i.e. 8) paints desired shadow + verifyPaintedShadow(customPaint, 8); + debugDisableShadows = true; + }); + + testWidgets('Dropdown form field - custom elevation', (WidgetTester tester) async { + debugDisableShadows = false; + final Key buttonKeyOne = UniqueKey(); + final Key buttonKeyTwo = UniqueKey(); + + await tester.pumpWidget(buildFormFrame( + buttonKey: buttonKeyOne, + items: menuItems, + elevation: 16, + onChanged: onChanged, + )); + await tester.tap(find.byKey(buttonKeyOne)); + await tester.pumpAndSettle(); + + final Finder customPaintOne = find.ancestor( + of: find.text('one').last, + matching: find.byType(CustomPaint), + ).last; + + verifyPaintedShadow(customPaintOne, 16); + await tester.tap(find.text('one').last); + await tester.pumpWidget(buildFormFrame( + buttonKey: buttonKeyTwo, + items: menuItems, + elevation: 24, + onChanged: onChanged, + )); + await tester.tap(find.byKey(buttonKeyTwo)); + await tester.pumpAndSettle(); + + final Finder customPaintTwo = find.ancestor( + of: find.text('one').last, + matching: find.byType(CustomPaint), + ).last; + + verifyPaintedShadow(customPaintTwo, 24); + debugDisableShadows = true; + }); }