From 6b79d3779772e3dcf7851a5f5188475f9078c8fb Mon Sep 17 00:00:00 2001 From: escamoteur Date: Tue, 16 Oct 2018 19:16:28 +0200 Subject: [PATCH] Add `disabledHint` to DropdownButton (#18770) If `items` or `onChanged` is null the button will be disabled, the down arrow will be grayed out, and the new `disabledHint` will be shown (if provided). --- .../flutter/lib/src/material/dropdown.dart | 53 +++++++++--- .../flutter/test/material/dropdown_test.dart | 84 ++++++++++++++----- 2 files changed, 105 insertions(+), 32 deletions(-) diff --git a/packages/flutter/lib/src/material/dropdown.dart b/packages/flutter/lib/src/material/dropdown.dart index 90006bb37f..0d5828d580 100644 --- a/packages/flutter/lib/src/material/dropdown.dart +++ b/packages/flutter/lib/src/material/dropdown.dart @@ -493,6 +493,8 @@ class DropdownButton extends StatefulWidget { /// Creates a dropdown button. /// /// The [items] must have distinct values and if [value] isn't null it must be among them. + /// If [items] or [onChanged] is null, the button will be disabled, the down arrow will be grayed out, and + /// the [disabledHint] will be shown (if provided). /// /// The [elevation] and [iconSize] arguments must not be null (they both have /// defaults, so do not need to be specified). @@ -501,14 +503,14 @@ class DropdownButton extends StatefulWidget { @required this.items, this.value, this.hint, + this.disabledHint, @required this.onChanged, this.elevation = 8, this.style, this.iconSize = 24.0, this.isDense = false, this.isExpanded = false, - }) : assert(items != null), - assert(value == null || items.where((DropdownMenuItem item) => item.value == value).length == 1), + }) : assert(items == null || value == null || items.where((DropdownMenuItem item) => item.value == value).length == 1), super(key: key); /// The list of possible items to select among. @@ -522,6 +524,11 @@ class DropdownButton extends StatefulWidget { /// Displayed if [value] is null. final Widget hint; + /// A message to show when the dropdown is disabled. + /// + /// Displayed if [items] or [onChanged] is null. + final Widget disabledHint; + /// Called when the user selects an item. final ValueChanged onChanged; @@ -600,6 +607,10 @@ class _DropdownButtonState extends State> with WidgetsBindi } void _updateSelectedIndex() { + if (!_enabled) { + return; + } + assert(widget.value == null || widget.items.where((DropdownMenuItem item) => item.value == widget.value).length == 1); _selectedIndex = null; @@ -650,6 +661,25 @@ class _DropdownButtonState extends State> with WidgetsBindi return math.max(_textStyle.fontSize, math.max(widget.iconSize, _kDenseButtonHeight)); } + Color get _downArrowColor { + // These colors are not defined in the Material Design spec. + if (_enabled) { + if (Theme.of(context).brightness == Brightness.light) { + return Colors.grey.shade700; + } else { + return Colors.white70; + } + } else { + if (Theme.of(context).brightness == Brightness.light) { + return Colors.grey.shade400; + } else { + return Colors.white10; + } + } + } + + bool get _enabled => widget.items != null && widget.items.isNotEmpty && widget.onChanged != null; + @override Widget build(BuildContext context) { assert(debugCheckHasMaterial(context)); @@ -657,14 +687,16 @@ class _DropdownButtonState extends State> with WidgetsBindi // The width of the button and the menu are defined by the widest // item and the width of the hint. - final List items = List.from(widget.items); + final List items = _enabled ? List.from(widget.items) : []; int hintIndex; - if (widget.hint != null) { + if (widget.hint != null || (!_enabled && widget.disabledHint != null)) { + 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), child: IgnorePointer( - child: widget.hint, + child: emplacedHint, ignoringSemantics: false, ), )); @@ -674,10 +706,10 @@ class _DropdownButtonState extends State> with WidgetsBindi ? _kAlignedButtonPadding : _kUnalignedButtonPadding; - // If value is null (then _selectedIndex is null) then we display - // the hint or nothing at all. + // If value is null (then _selectedIndex is null) or if disabled then we + // display the hint or nothing at all. final IndexedStack innerItemsWidget = IndexedStack( - index: _selectedIndex ?? hintIndex, + index: _enabled ? (_selectedIndex ?? hintIndex) : hintIndex, alignment: AlignmentDirectional.centerStart, children: items, ); @@ -694,8 +726,7 @@ class _DropdownButtonState extends State> with WidgetsBindi widget.isExpanded ? Expanded(child: innerItemsWidget) : innerItemsWidget, Icon(Icons.arrow_drop_down, size: widget.iconSize, - // These colors are not defined in the Material Design spec. - color: Theme.of(context).brightness == Brightness.light ? Colors.grey.shade700 : Colors.white70 + color: _downArrowColor, ), ], ), @@ -725,7 +756,7 @@ class _DropdownButtonState extends State> with WidgetsBindi return Semantics( button: true, child: GestureDetector( - onTap: _handleTap, + onTap: _enabled ? _handleTap : null, behavior: HitTestBehavior.opaque, child: result ), diff --git a/packages/flutter/test/material/dropdown_test.dart b/packages/flutter/test/material/dropdown_test.dart index fcd3b21755..4c2bf8fed5 100644 --- a/packages/flutter/test/material/dropdown_test.dart +++ b/packages/flutter/test/material/dropdown_test.dart @@ -13,6 +13,7 @@ import 'package:flutter/rendering.dart'; import '../widgets/semantics_tester.dart'; const List menuItems = ['one', 'two', 'three', 'four']; +final ValueChanged onChanged = (_) {}; final Type dropdownButtonType = DropdownButton( onChanged: (_) { }, @@ -26,6 +27,7 @@ Widget buildFrame({ bool isDense = false, bool isExpanded = false, Widget hint, + Widget disabledHint, List items = menuItems, Alignment alignment = Alignment.center, TextDirection textDirection = TextDirection.ltr, @@ -40,10 +42,11 @@ Widget buildFrame({ key: buttonKey, value: value, hint: hint, + disabledHint: disabledHint, onChanged: onChanged, isDense: isDense, isExpanded: isExpanded, - items: items.map>((String item) { + items: items == null ? null : items.map>((String item) { return DropdownMenuItem( key: ValueKey(item), value: item, @@ -115,7 +118,7 @@ bool sameGeometry(RenderBox box1, RenderBox box2) { void main() { testWidgets('Default dropdown golden', (WidgetTester tester) async { final Key buttonKey = UniqueKey(); - Widget build() => buildFrame(buttonKey: buttonKey, value: 'two'); + Widget build() => buildFrame(buttonKey: buttonKey, value: 'two', onChanged: onChanged); await tester.pumpWidget(build()); final Finder buttonFinder = find.byKey(buttonKey); assert(tester.renderObject(buttonFinder).attached); @@ -128,7 +131,7 @@ void main() { testWidgets('Expanded dropdown golden', (WidgetTester tester) async { final Key buttonKey = UniqueKey(); - Widget build() => buildFrame(buttonKey: buttonKey, value: 'two', isExpanded: true); + Widget build() => buildFrame(buttonKey: buttonKey, value: 'two', isExpanded: true, onChanged: onChanged); await tester.pumpWidget(build()); final Finder buttonFinder = find.byKey(buttonKey); assert(tester.renderObject(buttonFinder).attached); @@ -326,7 +329,7 @@ void main() { final Key buttonKey = UniqueKey(); const String value = 'two'; - Widget build() => buildFrame(buttonKey: buttonKey, value: value, textDirection: textDirection); + Widget build() => buildFrame(buttonKey: buttonKey, value: value, textDirection: textDirection, onChanged: onChanged); await tester.pumpWidget(build()); final RenderBox buttonBox = tester.renderObject(find.byKey(buttonKey)); @@ -371,7 +374,7 @@ void main() { testWidgets('Arrow icon aligns with the edge of button when expanded', (WidgetTester tester) async { final Key buttonKey = UniqueKey(); - Widget build() => buildFrame(buttonKey: buttonKey, value: 'two', isExpanded: true); + Widget build() => buildFrame(buttonKey: buttonKey, value: 'two', isExpanded: true, onChanged: onChanged); await tester.pumpWidget(build()); final RenderBox buttonBox = tester.renderObject(find.byKey(buttonKey)); @@ -389,7 +392,7 @@ void main() { final Key buttonKey = UniqueKey(); const String value = 'two'; - Widget build() => buildFrame(buttonKey: buttonKey, value: value, isDense: true); + Widget build() => buildFrame(buttonKey: buttonKey, value: value, isDense: true, onChanged: onChanged); await tester.pumpWidget(build()); final RenderBox buttonBox = tester.renderObject(find.byKey(buttonKey)); @@ -428,7 +431,8 @@ void main() { await tester.pumpWidget(buildFrame( buttonKey: buttonKey, value: null, // nothing selected - items: List.generate(/*length=*/ 100, (int index) => index.toString()) + items: List.generate(/*length=*/ 100, (int index) => index.toString()), + onChanged: onChanged, )); await tester.tap(find.byKey(buttonKey)); await tester.pump(); @@ -455,7 +459,8 @@ void main() { await tester.pumpWidget(buildFrame( buttonKey: buttonKey, value: '50', - items: List.generate(/*length=*/ 100, (int index) => index.toString()) + items: List.generate(/*length=*/ 100, (int index) => index.toString()), + onChanged: onChanged, )); final RenderBox buttonBox = tester.renderObject(find.byKey(buttonKey)); await tester.tap(find.byKey(buttonKey)); @@ -477,7 +482,7 @@ void main() { final Key buttonKey = UniqueKey(); String value; - Widget build() => buildFrame(buttonKey: buttonKey, value: value); + Widget build() => buildFrame(buttonKey: buttonKey, value: value, onChanged: onChanged); await tester.pumpWidget(build()); final RenderBox buttonBoxNullValue = tester.renderObject(find.byKey(buttonKey)); @@ -594,19 +599,19 @@ void main() { // so that it fits within the frame. await popUpAndDown( - buildFrame(alignment: Alignment.topLeft, value: menuItems.last) + buildFrame(alignment: Alignment.topLeft, value: menuItems.last, onChanged: onChanged) ); expect(menuRect.topLeft, Offset.zero); expect(menuRect.topRight, Offset(menuRect.width, 0.0)); await popUpAndDown( - buildFrame(alignment: Alignment.topCenter, value: menuItems.last) + buildFrame(alignment: Alignment.topCenter, value: menuItems.last, onChanged: onChanged) ); expect(menuRect.topLeft, Offset(buttonRect.left, 0.0)); expect(menuRect.topRight, Offset(buttonRect.right, 0.0)); await popUpAndDown( - buildFrame(alignment: Alignment.topRight, value: menuItems.last) + buildFrame(alignment: Alignment.topRight, value: menuItems.last, onChanged: onChanged) ); expect(menuRect.topLeft, Offset(800.0 - menuRect.width, 0.0)); expect(menuRect.topRight, const Offset(800.0, 0.0)); @@ -616,19 +621,19 @@ void main() { // is selected) and shifted horizontally so that it fits within the frame. await popUpAndDown( - buildFrame(alignment: Alignment.centerLeft, value: menuItems.first) + buildFrame(alignment: Alignment.centerLeft, value: menuItems.first, onChanged: onChanged) ); expect(menuRect.topLeft, Offset(0.0, buttonRect.top)); expect(menuRect.topRight, Offset(menuRect.width, buttonRect.top)); await popUpAndDown( - buildFrame(alignment: Alignment.center, value: menuItems.first) + buildFrame(alignment: Alignment.center, value: menuItems.first, onChanged: onChanged) ); expect(menuRect.topLeft, buttonRect.topLeft); expect(menuRect.topRight, buttonRect.topRight); await popUpAndDown( - buildFrame(alignment: Alignment.centerRight, value: menuItems.first) + buildFrame(alignment: Alignment.centerRight, value: menuItems.first, onChanged: onChanged) ); expect(menuRect.topLeft, Offset(800.0 - menuRect.width, buttonRect.top)); expect(menuRect.topRight, Offset(800.0, buttonRect.top)); @@ -638,26 +643,26 @@ void main() { // so that it fits within the frame. await popUpAndDown( - buildFrame(alignment: Alignment.bottomLeft, value: menuItems.first) + buildFrame(alignment: Alignment.bottomLeft, value: menuItems.first, onChanged: onChanged) ); expect(menuRect.bottomLeft, const Offset(0.0, 600.0)); expect(menuRect.bottomRight, Offset(menuRect.width, 600.0)); await popUpAndDown( - buildFrame(alignment: Alignment.bottomCenter, value: menuItems.first) + buildFrame(alignment: Alignment.bottomCenter, value: menuItems.first, onChanged: onChanged) ); expect(menuRect.bottomLeft, Offset(buttonRect.left, 600.0)); expect(menuRect.bottomRight, Offset(buttonRect.right, 600.0)); await popUpAndDown( - buildFrame(alignment: Alignment.bottomRight, value: menuItems.first) + buildFrame(alignment: Alignment.bottomRight, value: menuItems.first, onChanged: onChanged) ); expect(menuRect.bottomLeft, Offset(800.0 - menuRect.width, 600.0)); expect(menuRect.bottomRight, const Offset(800.0, 600.0)); }); testWidgets('Dropdown menus are dismissed on screen orientation changes', (WidgetTester tester) async { - await tester.pumpWidget(buildFrame()); + await tester.pumpWidget(buildFrame(onChanged: onChanged)); await tester.tap(find.byType(dropdownButtonType)); await tester.pumpAndSettle(); expect(find.byType(ListView), findsOneWidget); @@ -670,7 +675,7 @@ void main() { testWidgets('Semantics Tree contains only selected element', (WidgetTester tester) async { final SemanticsTester semantics = SemanticsTester(tester); - await tester.pumpWidget(buildFrame(items: menuItems)); + await tester.pumpWidget(buildFrame(items: menuItems, onChanged: onChanged)); expect(semantics, isNot(includesNodeWith(label: menuItems[0]))); expect(semantics, includesNodeWith(label: menuItems[1])); @@ -702,7 +707,7 @@ void main() { buttonKey: key, value: 'three', items: menuItems, - onChanged: null, + onChanged: onChanged, hint: const Text('test'), )); @@ -722,6 +727,7 @@ void main() { buttonKey: key, value: null, items: menuItems, + onChanged: onChanged, )); await tester.tap(find.byKey(key)); await tester.pumpAndSettle(); @@ -778,6 +784,42 @@ void main() { semantics.dispose(); }); + testWidgets('disabledHint displays on empty items or onChanged', (WidgetTester tester) async { + final Key buttonKey = UniqueKey(); + + Widget build({List items, ValueChanged onChanged}) => buildFrame( + items: items, + onChanged: onChanged, + 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, onChanged: onChanged)); + expect(find.text('enabled'), findsNothing); + expect(find.text('disabled'), findsOneWidget); + + // [disabledHint] should display when [items] is an empty list. + await tester.pumpWidget(build(items: [], onChanged: onChanged)); + expect(find.text('enabled'), findsNothing); + expect(find.text('disabled'), findsOneWidget); + + // [disabledHint] should display when [onChanged] is null + await tester.pumpWidget(build(items: menuItems, onChanged: null)); + expect(find.text('enabled'), findsNothing); + expect(find.text('disabled'), findsOneWidget); + final RenderBox disabledHintBox = tester.renderObject(find.byKey(buttonKey)); + + // A Dropdown button with a disabled hint should be the same size as a + // one with a regular enabled hint. + await tester.pumpWidget(build(items: menuItems, onChanged: onChanged)); + expect(find.text('disabled'), findsNothing); + expect(find.text('enabled'), findsOneWidget); + 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 in middle showing middle item', (WidgetTester tester) async { final List> items = List>.generate(100, (int i) =>