diff --git a/README.md b/README.md index 73002ca..972f0c4 100644 --- a/README.md +++ b/README.md @@ -65,32 +65,33 @@ customize to your needs. ### DropdownButton2: -| Option | Description | Type | Required | -| -------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------- | :------: | -| [items](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/items.html) | The list of items the user can select | List> | Yes | -| [selectedItemBuilder](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/selectedItemBuilder.html) | A builder to customize how the selected item will be displayed on the button | DropdownButtonBuilder | No | -| [value](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/value.html) | The value of the currently selected [DropdownItem] | T | No | -| [hint](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/hint.html) | The placeholder displayed before the user choose an item | Widget | No | -| [disabledHint](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/disabledHint.html) | The placeholder displayed if the dropdown is disabled | Widget | No | -| [onChanged](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/onChanged.html) | Called when the user selects an item | ValueChanged | No | -| [onMenuStateChange](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/onMenuStateChange.html) | Called when the dropdown menu opens or closes | OnMenuStateChangeFn | No | -| [style](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/style.html) | The text style to use for text in the dropdown button and the dropdown menu | TextStyle | No | -| [underline](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/underline.html) | The widget to use for drawing the drop-down button's underline | Widget | No | -| [isDense](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/isDense.html) | Reduce the button's height | bool | No | -| [isExpanded](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/isExpanded.html) | Makes the button's inner contents expanded (set true to avoid long text overflowing) | bool | No | -| [alignment](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/alignment.html) | Defines how the hint or the selected item is positioned within the button | AlignmentGeometry | No | -| [buttonStyleData](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/buttonStyleData.html) | Used to configure the theme of the button | ButtonStyleData | No | -| [iconStyleData](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/iconStyleData.html) | Used to configure the theme of the button's icon | IconStyleData | No | -| [dropdownStyleData](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/dropdownStyleData.html) | Used to configure the theme of the dropdown menu | DropdownStyleData | No | -| [menuItemStyleData](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/menuItemStyleData.html) | Used to configure the theme of the dropdown menu items | MenuItemStyleData | No | -| [dropdownSearchData](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/dropdownSearchData.html) | Used to configure searchable dropdowns | DropdownSearchData | No | -| [dropdownSeparator](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/dropdownSeparator.html) | Adds separator widget to the dropdown menu | DropdownSeparator | No | -| [customButton](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/customButton.html) | Uses custom widget like icon,image,etc.. instead of the default button | Widget | No | -| [openWithLongPress](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/openWithLongPress.html) | Opens the dropdown menu on long-pressing instead of tapping | bool | No | -| [barrierDismissible](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/barrierDismissible.html) | Whether you can dismiss this route by tapping the modal barrier | bool | No | -| [barrierColor](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/barrierColor.html) | The color to use for the modal barrier. If this is null, the barrier will be transparent | Color | No | -| [barrierLabel](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/barrierLabel.html) | The semantic label used for a dismissible barrier | String | No | -| [barrierCoversButton](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/barrierCoversButton.html) | Specifies whether the modal barrier should cover the dropdown button or not. | bool | No | +| Option | Description | Type | Required | +| ---------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -------------------------- | :------: | +| [items](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/items.html) | The list of items the user can select | List> | Yes | +| [selectedItemBuilder](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/selectedItemBuilder.html) | A builder to customize how the selected item will be displayed on the button | DropdownButtonBuilder | No | +| [valueListenable](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/valueListenable.html) | A [ValueListenable] that represents the value of the currently selected [DropdownItem]. | ValueListenable? | No | +| [multiValueListenable](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/multiValueListenable.html) | A [ValueListenable] that represents a list of the currently selected [DropdownItem]s | ValueListenable>? | No | +| [hint](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/hint.html) | The placeholder displayed before the user choose an item | Widget | No | +| [disabledHint](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/disabledHint.html) | The placeholder displayed if the dropdown is disabled | Widget | No | +| [onChanged](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/onChanged.html) | Called when the user selects an item | ValueChanged | No | +| [onMenuStateChange](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/onMenuStateChange.html) | Called when the dropdown menu opens or closes | OnMenuStateChangeFn | No | +| [style](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/style.html) | The text style to use for text in the dropdown button and the dropdown menu | TextStyle | No | +| [underline](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/underline.html) | The widget to use for drawing the drop-down button's underline | Widget | No | +| [isDense](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/isDense.html) | Reduce the button's height | bool | No | +| [isExpanded](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/isExpanded.html) | Makes the button's inner contents expanded (set true to avoid long text overflowing) | bool | No | +| [alignment](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/alignment.html) | Defines how the hint or the selected item is positioned within the button | AlignmentGeometry | No | +| [buttonStyleData](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/buttonStyleData.html) | Used to configure the theme of the button | ButtonStyleData | No | +| [iconStyleData](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/iconStyleData.html) | Used to configure the theme of the button's icon | IconStyleData | No | +| [dropdownStyleData](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/dropdownStyleData.html) | Used to configure the theme of the dropdown menu | DropdownStyleData | No | +| [menuItemStyleData](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/menuItemStyleData.html) | Used to configure the theme of the dropdown menu items | MenuItemStyleData | No | +| [dropdownSearchData](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/dropdownSearchData.html) | Used to configure searchable dropdowns | DropdownSearchData | No | +| [dropdownSeparator](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/dropdownSeparator.html) | Adds separator widget to the dropdown menu | DropdownSeparator | No | +| [customButton](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/customButton.html) | Uses custom widget like icon,image,etc.. instead of the default button | Widget | No | +| [openWithLongPress](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/openWithLongPress.html) | Opens the dropdown menu on long-pressing instead of tapping | bool | No | +| [barrierDismissible](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/barrierDismissible.html) | Whether you can dismiss this route by tapping the modal barrier | bool | No | +| [barrierColor](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/barrierColor.html) | The color to use for the modal barrier. If this is null, the barrier will be transparent | Color | No | +| [barrierLabel](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/barrierLabel.html) | The semantic label used for a dismissible barrier | String | No | +| [barrierCoversButton](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButton2/barrierCoversButton.html) | Specifies whether the modal barrier should cover the dropdown button or not. | bool | No | #### Subclass ButtonStyleData: @@ -190,7 +191,7 @@ final List items = [ 'Item3', 'Item4', ]; -String? selectedValue; +final valueListenable = ValueNotifier(null); @override Widget build(BuildContext context) { @@ -218,11 +219,9 @@ Widget build(BuildContext context) { ), )) .toList(), - value: selectedValue, + valueListenable: valueListenable, onChanged: (String? value) { - setState(() { - selectedValue = value; - }); + valueListenable.value = value; }, buttonStyleData: const ButtonStyleData( padding: EdgeInsets.symmetric(horizontal: 16), @@ -251,7 +250,7 @@ final List items = [ 'Item7', 'Item8', ]; -String? selectedValue; +final valueListenable = ValueNotifier(null); @override Widget build(BuildContext context) { @@ -298,11 +297,9 @@ Widget build(BuildContext context) { ), )) .toList(), - value: selectedValue, + valueListenable: valueListenable, onChanged: (value) { - setState(() { - selectedValue = value; - }); + valueListenable.value = value; }, buttonStyleData: ButtonStyleData( height: 50, @@ -360,7 +357,7 @@ final List items = [ 'Item3', 'Item4', ]; -String? selectedValue; +final valueListenable = ValueNotifier(null); @override Widget build(BuildContext context) { @@ -398,11 +395,9 @@ Widget build(BuildContext context) { child: Divider(), ), ), - value: selectedValue, - onChanged: (String? value) { - setState(() { - selectedValue = value; - }); + valueListenable: valueListenable, + onChanged: (value) { + valueListenable.value = value; }, buttonStyleData: const ButtonStyleData( padding: EdgeInsets.symmetric(horizontal: 16), @@ -431,12 +426,13 @@ Widget build(BuildContext context) { ```dart final List items = [ + 'All', 'Item1', 'Item2', 'Item3', 'Item4', ]; -List selectedItems = []; +final multiValueListenable = ValueNotifier>([]); @override Widget build(BuildContext context) { @@ -458,16 +454,21 @@ Widget build(BuildContext context) { height: 40, //disable default onTap to avoid closing menu when selecting an item enabled: false, - child: StatefulBuilder( - builder: (context, menuSetState) { - final isSelected = selectedItems.contains(item); + child: ValueListenableBuilder>( + valueListenable: multiValueListenable, + builder: (context, multiValue, _) { + final isSelected = multiValue.contains(item); return InkWell( onTap: () { - isSelected ? selectedItems.remove(item) : selectedItems.add(item); - //This rebuilds the StatefulWidget to update the button's text - setState(() {}); - //This rebuilds the dropdownMenu Widget to update the check mark - menuSetState(() {}); + if (item == 'All') { + isSelected + ? multiValueListenable.value = [] + : multiValueListenable.value = List.from(items); + } else { + multiValueListenable.value = isSelected + ? ([...multiValue]..remove(item)) + : [...multiValue, item]; + } }, child: Container( height: double.infinity, @@ -495,23 +496,28 @@ Widget build(BuildContext context) { ), ); }).toList(), - //Use last selected item as the current value so if we've limited menu height, it scroll to last item. - value: selectedItems.isEmpty ? null : selectedItems.last, + multiValueListenable: multiValueListenable, onChanged: (value) {}, selectedItemBuilder: (context) { return items.map( (item) { - return Container( - alignment: AlignmentDirectional.center, - child: Text( - selectedItems.join(', '), - style: const TextStyle( - fontSize: 14, - overflow: TextOverflow.ellipsis, - ), - maxLines: 1, - ), - ); + return ValueListenableBuilder>( + valueListenable: multiValueListenable, + builder: (context, multiValue, _) { + return Container( + alignment: AlignmentDirectional.center, + child: Text( + multiValue + .where((item) => item != 'All') + .join(', '), + style: const TextStyle( + fontSize: 14, + overflow: TextOverflow.ellipsis, + ), + maxLines: 1, + ), + ); + }); }, ).toList(); }, @@ -546,7 +552,7 @@ final List items = [ 'B_Item4', ]; -String? selectedValue; +final valueListenable = ValueNotifier(null); final TextEditingController textEditingController = TextEditingController(); @override @@ -581,11 +587,9 @@ Widget build(BuildContext context) { ), )) .toList(), - value: selectedValue, + valueListenable: valueListenable, onChanged: (value) { - setState(() { - selectedValue = value; - }); + valueListenable.value = value; }, buttonStyleData: const ButtonStyleData( padding: EdgeInsets.symmetric(horizontal: 16), @@ -923,8 +927,7 @@ final List genderItems = [ 'Male', 'Female', ]; - -String? selectedValue; +final valueListenable = ValueNotifier(null); final _formKey = GlobalKey(); @@ -975,6 +978,7 @@ Widget build(BuildContext context) { ), )) .toList(), + valueListenable: valueListenable, validator: (value) { if (value == null) { return 'Please select gender.'; @@ -982,10 +986,7 @@ Widget build(BuildContext context) { return null; }, onChanged: (value) { - //Do something when selected item is changed. - }, - onSaved: (value) { - selectedValue = value.toString(); + valueListenable.value = value; }, iconStyleData: const IconStyleData( icon: Icon( @@ -1006,7 +1007,7 @@ Widget build(BuildContext context) { TextButton( onPressed: () { if (_formKey.currentState!.validate()) { - _formKey.currentState!.save(); + // Do something. } }, child: const Text('Submit Button'), @@ -1025,7 +1026,7 @@ Widget build(BuildContext context) { class CustomDropdownButton2 extends StatelessWidget { const CustomDropdownButton2({ required this.hint, - required this.value, + required this.valueListenable, required this.dropdownItems, required this.onChanged, this.selectedItemBuilder, @@ -1054,7 +1055,7 @@ class CustomDropdownButton2 extends StatelessWidget { super.key, }); final String hint; - final String? value; + final ValueListenable? valueListenable; final List dropdownItems; final ValueChanged? onChanged; final DropdownButtonBuilder? selectedItemBuilder; @@ -1097,7 +1098,7 @@ class CustomDropdownButton2 extends StatelessWidget { ), ), ), - value: value, + valueListenable: valueListenable, items: dropdownItems .map((String item) => DropdownItem( value: item, diff --git a/packages/dropdown_button2/example/custom_dropdown_button2.dart b/packages/dropdown_button2/example/custom_dropdown_button2.dart index 021cf53..06fe032 100644 --- a/packages/dropdown_button2/example/custom_dropdown_button2.dart +++ b/packages/dropdown_button2/example/custom_dropdown_button2.dart @@ -1,10 +1,11 @@ import 'package:dropdown_button2/dropdown_button2.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; class CustomDropdownButton2 extends StatelessWidget { const CustomDropdownButton2({ required this.hint, - required this.value, + required this.valueListenable, required this.dropdownItems, required this.onChanged, this.selectedItemBuilder, @@ -33,7 +34,7 @@ class CustomDropdownButton2 extends StatelessWidget { super.key, }); final String hint; - final String? value; + final ValueListenable? valueListenable; final List dropdownItems; final ValueChanged? onChanged; final DropdownButtonBuilder? selectedItemBuilder; @@ -76,7 +77,7 @@ class CustomDropdownButton2 extends StatelessWidget { ), ), ), - value: value, + valueListenable: valueListenable, items: dropdownItems .map((String item) => DropdownItem( value: item, diff --git a/packages/dropdown_button2/example/example.dart b/packages/dropdown_button2/example/example.dart index f747df4..7c543eb 100644 --- a/packages/dropdown_button2/example/example.dart +++ b/packages/dropdown_button2/example/example.dart @@ -35,7 +35,7 @@ class _MyHomePageState extends State { 'Item7', 'Item8', ]; - String? selectedValue; + final valueListenable = ValueNotifier(null); @override Widget build(BuildContext context) { @@ -82,11 +82,9 @@ class _MyHomePageState extends State { ), )) .toList(), - value: selectedValue, - onChanged: (String? value) { - setState(() { - selectedValue = value; - }); + valueListenable: valueListenable, + onChanged: (value) { + valueListenable.value = value; }, buttonStyleData: ButtonStyleData( height: 50, diff --git a/packages/dropdown_button2/lib/src/dropdown_button2.dart b/packages/dropdown_button2/lib/src/dropdown_button2.dart index 4c35ca3..dff85b4 100644 --- a/packages/dropdown_button2/lib/src/dropdown_button2.dart +++ b/packages/dropdown_button2/lib/src/dropdown_button2.dart @@ -48,7 +48,7 @@ typedef SearchMatchFn = bool Function( /// One ancestor must be a [Material] widget and typically this is /// provided by the app's [Scaffold]. /// -/// The type `T` is the type of the [value] that each dropdown item represents. +/// The type `T` is the type of the value that each dropdown item represents. /// All the entries in a given menu must represent values with consistent types. /// Typically, an enum is used. Each [DropdownItem] in [items] must be /// specialized with that same type argument. @@ -76,22 +76,24 @@ class DropdownButton2 extends StatefulWidget { /// Creates a DropdownButton2. /// It's customizable DropdownButton with steady dropdown menu and many other features. /// - /// The [items] must have distinct values. If [value] isn't null then it - /// must be equal to one of the [DropdownItem] values. If [items] or - /// [onChanged] is null, the button will be disabled, the down arrow + /// The [items] must have distinct values. If [valueListenable] isn't null then its value + /// must be equal to one of the [DropdownItem] values. If [multiValueListenable] isn't null + /// then its value must be equal to one or more of the [DropdownItem] values. + /// If [items] or [onChanged] is null, the button will be disabled, the down arrow /// will be greyed out. /// - /// If [value] is null and the button is enabled, [hint] will be displayed + /// If no [DropdownItem] is selected and the button is enabled, [hint] will be displayed /// if it is non-null. /// - /// If [value] is null and the button is disabled, [disabledHint] will be displayed + /// If no [DropdownItem] is selected and the button is disabled, [disabledHint] will be displayed /// if it is non-null. If [disabledHint] is null, then [hint] will be displayed /// if it is non-null. - DropdownButton2({ + const DropdownButton2({ super.key, required this.items, this.selectedItemBuilder, - this.value, + this.valueListenable, + this.multiValueListenable, this.hint, this.disabledHint, this.onChanged, @@ -119,27 +121,19 @@ class DropdownButton2 extends StatefulWidget { // When adding new arguments, consider adding similar arguments to // DropdownButtonFormField. }) : assert( - items == null || - items.isEmpty || - value == null || - items.where((DropdownItem item) { - return item.value == value; - }).length == - 1, - "There should be exactly one item with [DropdownButton]'s value: " - '$value. \n' - 'Either zero or 2 or more [DropdownItem]s were detected ' - 'with the same value', + valueListenable == null || multiValueListenable == null, + 'Only one of valueListenable or multiValueListenable can be used.', ), _inputDecoration = null, _isEmpty = false, _isFocused = false; - DropdownButton2._formField({ + const DropdownButton2._formField({ super.key, required this.items, this.selectedItemBuilder, - this.value, + required this.valueListenable, + required this.multiValueListenable, this.hint, this.disabledHint, required this.onChanged, @@ -167,20 +161,7 @@ class DropdownButton2 extends StatefulWidget { required InputDecoration inputDecoration, required bool isEmpty, required bool isFocused, - }) : assert( - items == null || - items.isEmpty || - value == null || - items.where((DropdownItem item) { - return item.value == value; - }).length == - 1, - "There should be exactly one item with [DropdownButtonFormField]'s value: " - '$value. \n' - 'Either zero or 2 or more [DropdownItem]s were detected ' - 'with the same value', - ), - _inputDecoration = inputDecoration, + }) : _inputDecoration = inputDecoration, _isEmpty = isEmpty, _isFocused = isFocused; @@ -206,31 +187,43 @@ class DropdownButton2 extends StatefulWidget { /// {@end-tool} /// /// If this callback is null, the [DropdownItem] from [items] - /// that matches [value] will be displayed. + /// that matches the selected [DropdownItem]'s value will be displayed. final DropdownButtonBuilder? selectedItemBuilder; - /// The value of the currently selected [DropdownItem]. + /// A [ValueListenable] that represents the value of the currently selected [DropdownItem]. + /// It holds a value of type `T?`, where `T` represents the type of [DropdownItem]'s value. + /// + /// If the value is null and the button is enabled, [hint] will be displayed + /// if it is non-null. + /// + /// If the value is null and the button is disabled, [disabledHint] will be displayed + /// if it is non-null. If [disabledHint] is null, then [hint] will be displayed + /// if it is non-null. + final ValueListenable? valueListenable; + + /// A [ValueListenable] that represents a list of the currently selected [DropdownItem]s. + /// It holds a list of type `List`, where `T` represents the type of [DropdownItem]'s value. /// - /// If [value] is null and the button is enabled, [hint] will be displayed + /// If the list is empty and the button is enabled, [hint] will be displayed /// if it is non-null. /// - /// If [value] is null and the button is disabled, [disabledHint] will be displayed + /// If the list is empty and the button is disabled, [disabledHint] will be displayed /// if it is non-null. If [disabledHint] is null, then [hint] will be displayed /// if it is non-null. - final T? value; + final ValueListenable>? multiValueListenable; /// A placeholder widget that is displayed by the dropdown button. /// - /// If [value] is null and the dropdown is enabled ([items] and [onChanged] are non-null), + /// If no [DropdownItem] is selected and the dropdown is enabled ([items] and [onChanged] are non-null), /// this widget is displayed as a placeholder for the dropdown button's value. /// - /// If [value] is null and the dropdown is disabled and [disabledHint] is null, + /// If no [DropdownItem] is selected and the dropdown is disabled and [disabledHint] is null, /// this widget is used as the placeholder. final Widget? hint; /// A preferred placeholder widget that is displayed when the dropdown is disabled. /// - /// If [value] is null, the dropdown is disabled ([items] or [onChanged] is null), + /// If no [DropdownItem] is selected and the dropdown is disabled ([items] or [onChanged] is null), /// this widget is displayed as a placeholder for the dropdown button's value. final Widget? disabledHint; @@ -409,6 +402,8 @@ class DropdownButton2State extends State> super.initState(); WidgetsBinding.instance.addObserver(this); _updateSelectedIndex(); + widget.valueListenable?.addListener(_updateSelectedIndex); + widget.multiValueListenable?.addListener(_updateSelectedIndex); if (widget.focusNode == null) { _internalNode ??= _createFocusNode(); } @@ -425,6 +420,8 @@ class DropdownButton2State extends State> @override void dispose() { WidgetsBinding.instance.removeObserver(this); + widget.valueListenable?.removeListener(_updateSelectedIndex); + widget.multiValueListenable?.removeListener(_updateSelectedIndex); _removeDropdownRoute(); _internalNode?.dispose(); _isMenuOpen.dispose(); @@ -432,6 +429,17 @@ class DropdownButton2State extends State> super.dispose(); } + T? get _currentValue { + if (widget.valueListenable != null) { + return widget.valueListenable!.value; + } + if (widget.multiValueListenable != null) { + //Use last selected item as the current value so if we've limited menu height, it scroll to last item. + return widget.multiValueListenable!.value.lastOrNull; + } + return null; + } + void _removeDropdownRoute() { _dropdownRoute?._dismiss(); _dropdownRoute = null; @@ -444,27 +452,34 @@ class DropdownButton2State extends State> if (widget.focusNode == null) { _internalNode ??= _createFocusNode(); } - _updateSelectedIndex(); + if (widget.valueListenable != oldWidget.valueListenable || + widget.multiValueListenable != oldWidget.multiValueListenable) { + _updateSelectedIndex(); + oldWidget.valueListenable?.removeListener(_updateSelectedIndex); + oldWidget.multiValueListenable?.removeListener(_updateSelectedIndex); + widget.valueListenable?.addListener(_updateSelectedIndex); + widget.multiValueListenable?.addListener(_updateSelectedIndex); + } } void _updateSelectedIndex() { if (widget.items == null || widget.items!.isEmpty || - (widget.value == null && + (_currentValue == null && widget.items! .where((DropdownItem item) => - item.enabled && item.value == widget.value) + item.enabled && item.value == _currentValue) .isEmpty)) { _selectedIndex = null; return; } assert(widget.items! - .where((DropdownItem item) => item.value == widget.value) + .where((DropdownItem item) => item.value == _currentValue) .length == 1); for (int itemIndex = 0; itemIndex < widget.items!.length; itemIndex++) { - if (widget.items![itemIndex].value == widget.value) { + if (widget.items![itemIndex].value == _currentValue) { _selectedIndex = itemIndex; return; } @@ -684,40 +699,49 @@ class DropdownButton2State extends State> final buttonHeight = _buttonStyle?.height ?? (widget.isDense ? _denseButtonHeight : null); - Widget item = buttonItems[_selectedIndex ?? hintIndex ?? 0]; - if (item is DropdownItem) { - item = item.copyWith(alignment: widget.alignment); - } + final Widget innerItemsWidget = buttonItems.isEmpty + ? const SizedBox.shrink() + : ValueListenableBuilder( + valueListenable: widget.valueListenable ?? + widget.multiValueListenable ?? + ValueNotifier(null), + builder: (context, multiValue, _) { + _uniqueValueAssert( + widget.items, + widget.valueListenable, + widget.multiValueListenable, + ); + Widget item = buttonItems[_selectedIndex ?? hintIndex ?? 0]; + if (item is DropdownItem) { + item = item.copyWith(alignment: widget.alignment); + } - // If value is null (then _selectedIndex is null) then we - // display the hint or nothing at all. - final Widget innerItemsWidget; - if (buttonItems.isEmpty) { - innerItemsWidget = const SizedBox.shrink(); - } else { - // When both buttonHeight & buttonWidth are specified, we don't have to use IndexedStack, - // which enhances the performance when dealing with big items list. - // Note: Both buttonHeight & buttonWidth must be specified to avoid changing - // button's size when selecting different items, which is a bad UX. - innerItemsWidget = buttonHeight != null && _buttonStyle?.width != null - ? Align( - alignment: widget.alignment, - child: item, - ) - : IndexedStack( - index: _selectedIndex ?? hintIndex, - alignment: widget.alignment, - children: buttonHeight != null - ? buttonItems.mapIndexed((item, index) => item).toList() - // TODO(Ahmed): use indexed from Flutter [Dart>=v3.0.0]. - : buttonItems.mapIndexed((item, index) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [item], - ); - }).toList(), - ); - } + // When both buttonHeight & buttonWidth are specified, we don't have to use IndexedStack, + // which enhances the performance when dealing with big items list. + // Note: Both buttonHeight & buttonWidth must be specified to avoid changing + // button's size when selecting different items, which is a bad UX. + return buttonHeight != null && _buttonStyle?.width != null + ? Align( + alignment: widget.alignment, + child: item, + ) + : IndexedStack( + index: _selectedIndex ?? hintIndex, + alignment: widget.alignment, + children: buttonHeight != null + ? buttonItems + .mapIndexed((item, index) => item) + .toList() + // TODO(Ahmed): use indexed from Flutter [Dart>=v3.0.0]. + : buttonItems.mapIndexed((item, index) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [item], + ); + }).toList(), + ); + }, + ); Widget result = DefaultTextStyle( style: _enabled @@ -869,7 +893,8 @@ class DropdownButtonFormField2 extends FormField { this.dropdownButtonKey, required List>? items, DropdownButtonBuilder? selectedItemBuilder, - T? value, + ValueListenable? valueListenable, + ValueListenable>? multiValueListenable, Widget? hint, Widget? disabledHint, this.onChanged, @@ -897,21 +922,14 @@ class DropdownButtonFormField2 extends FormField { Color? barrierColor, String? barrierLabel, }) : assert( - items == null || - items.isEmpty || - value == null || - items.where((DropdownItem item) { - return item.value == value; - }).length == - 1, - "There should be exactly one item with [DropdownButton]'s value: " - '$value. \n' - 'Either zero or 2 or more [DropdownItem]s were detected ' - 'with the same value', + valueListenable == null || multiValueListenable == null, + 'Only one of valueListenable or multiValueListenable can be used.', ), decoration = _getInputDecoration(decoration, buttonStyleData), super( - initialValue: value, + initialValue: valueListenable != null + ? valueListenable.value + : multiValueListenable?.value.lastOrNull, autovalidateMode: autovalidateMode ?? AutovalidateMode.disabled, builder: (FormFieldState field) { final _DropdownButtonFormFieldState state = @@ -955,7 +973,8 @@ class DropdownButtonFormField2 extends FormField { key: dropdownButtonKey, items: items, selectedItemBuilder: selectedItemBuilder, - value: state.value, + valueListenable: valueListenable, + multiValueListenable: multiValueListenable, hint: hint, disabledHint: disabledHint, onChanged: onChanged == null ? null : state.didChange, diff --git a/packages/dropdown_button2/lib/src/utils.dart b/packages/dropdown_button2/lib/src/utils.dart index 7d0a547..6c43d5a 100644 --- a/packages/dropdown_button2/lib/src/utils.dart +++ b/packages/dropdown_button2/lib/src/utils.dart @@ -61,4 +61,50 @@ extension ExtendedIterable on Iterable { var i = 0; return map((e) => f(e, i++)); } + + /// The last element of this iterable, or `null` if the iterable is empty. + /// + // TODO(Ahmed): use lastOrNull from Flutter [Dart>=v3.0.0]. + E? get lastOrNull { + if (isEmpty) { + return null; + } + return last; + } +} + +void _uniqueValueAssert( + List>? items, + ValueListenable? valueListenable, + ValueListenable>? multiValueListenable, +) { + if (items == null || items.isEmpty) { + return; + } + + String assertMessage(T value) { + return "There should be exactly one item with [DropdownButton]'s value: " + '$value. \n' + 'Either zero or 2 or more [DropdownItem]s were detected ' + 'with the same value'; + } + + assert( + valueListenable?.value == null || + items.where((DropdownItem item) { + return item.value == valueListenable!.value; + }).length == + 1, + assertMessage(valueListenable!.value as T), + ); + + final currentMultiValue = multiValueListenable?.value.lastOrNull; + assert( + currentMultiValue == null || + items.where((DropdownItem item) { + return item.value == currentMultiValue; + }).length == + 1, + assertMessage(currentMultiValue), + ); } diff --git a/packages/dropdown_button2/test/dropdown_button2_test.dart b/packages/dropdown_button2/test/dropdown_button2_test.dart index bf79c3e..072d053 100644 --- a/packages/dropdown_button2/test/dropdown_button2_test.dart +++ b/packages/dropdown_button2/test/dropdown_button2_test.dart @@ -7,7 +7,8 @@ void main() { 'Button and Menu Focus', () { final List menuItems = List.generate(10, (int index) => index); - final value = menuItems.first; + final valueListenable = ValueNotifier(menuItems.first); + final value = valueListenable.value; final findDropdownButton = find.byType(DropdownButton2); final findDropdownButtonFormField = @@ -25,7 +26,7 @@ void main() { home: Scaffold( body: Center( child: DropdownButton2( - value: value, + valueListenable: valueListenable, items: menuItems.map>((int item) { return DropdownItem( value: item, @@ -62,7 +63,7 @@ void main() { home: Scaffold( body: Center( child: DropdownButton2( - value: value, + valueListenable: valueListenable, items: menuItems.map>((int item) { return DropdownItem( value: item, @@ -107,7 +108,7 @@ void main() { child: Form( key: formKey, child: DropdownButtonFormField2( - value: value, + valueListenable: valueListenable, items: menuItems.map>((int item) { return DropdownItem( value: item, diff --git a/packages/dropdown_button2_test/lib/src/few_styling_example.dart b/packages/dropdown_button2_test/lib/src/few_styling_example.dart index 2c63c6c..488c185 100644 --- a/packages/dropdown_button2_test/lib/src/few_styling_example.dart +++ b/packages/dropdown_button2_test/lib/src/few_styling_example.dart @@ -19,7 +19,7 @@ class _FewStylingExampleState extends State { 'Item7', 'Item8', ]; - String? selectedValue; + final valueListenable = ValueNotifier(null); @override Widget build(BuildContext context) { @@ -66,11 +66,9 @@ class _FewStylingExampleState extends State { ), )) .toList(), - value: selectedValue, + valueListenable: valueListenable, onChanged: (value) { - setState(() { - selectedValue = value; - }); + valueListenable.value = value; }, buttonStyleData: ButtonStyleData( height: 50, diff --git a/packages/dropdown_button2_test/lib/src/form_field_example.dart b/packages/dropdown_button2_test/lib/src/form_field_example.dart index 09b00ba..0c4e2c2 100644 --- a/packages/dropdown_button2_test/lib/src/form_field_example.dart +++ b/packages/dropdown_button2_test/lib/src/form_field_example.dart @@ -13,8 +13,7 @@ class _FormFieldExampleState extends State { 'Male', 'Female', ]; - - String? selectedValue; + final valueListenable = ValueNotifier(null); final _formKey = GlobalKey(); @@ -65,6 +64,7 @@ class _FormFieldExampleState extends State { ), )) .toList(), + valueListenable: valueListenable, validator: (value) { if (value == null) { return 'Please select gender.'; @@ -72,10 +72,7 @@ class _FormFieldExampleState extends State { return null; }, onChanged: (value) { - //Do something when selected item is changed. - }, - onSaved: (value) { - selectedValue = value.toString(); + valueListenable.value = value; }, iconStyleData: const IconStyleData( icon: Icon( @@ -96,7 +93,7 @@ class _FormFieldExampleState extends State { TextButton( onPressed: () { if (_formKey.currentState!.validate()) { - _formKey.currentState!.save(); + // Do something. } }, child: const Text('Submit Button'), diff --git a/packages/dropdown_button2_test/lib/src/multi_select_example.dart b/packages/dropdown_button2_test/lib/src/multi_select_example.dart index 512ac5a..d510cb5 100644 --- a/packages/dropdown_button2_test/lib/src/multi_select_example.dart +++ b/packages/dropdown_button2_test/lib/src/multi_select_example.dart @@ -10,12 +10,13 @@ class MultiSelectExample extends StatefulWidget { class _MultiSelectExampleState extends State { final List items = [ + 'All', 'Item1', 'Item2', 'Item3', 'Item4', ]; - List selectedItems = []; + final multiValueListenable = ValueNotifier>([]); @override Widget build(BuildContext context) { @@ -37,18 +38,21 @@ class _MultiSelectExampleState extends State { height: 40, //disable default onTap to avoid closing menu when selecting an item enabled: false, - child: StatefulBuilder( - builder: (context, menuSetState) { - final isSelected = selectedItems.contains(item); + child: ValueListenableBuilder>( + valueListenable: multiValueListenable, + builder: (context, multiValue, _) { + final isSelected = multiValue.contains(item); return InkWell( onTap: () { - isSelected - ? selectedItems.remove(item) - : selectedItems.add(item); - //This rebuilds the StatefulWidget to update the button's text - setState(() {}); - //This rebuilds the dropdownMenu Widget to update the check mark - menuSetState(() {}); + if (item == 'All') { + isSelected + ? multiValueListenable.value = [] + : multiValueListenable.value = List.from(items); + } else { + multiValueListenable.value = isSelected + ? ([...multiValue]..remove(item)) + : [...multiValue, item]; + } }, child: Container( height: double.infinity, @@ -76,23 +80,28 @@ class _MultiSelectExampleState extends State { ), ); }).toList(), - //Use last selected item as the current value so if we've limited menu height, it scroll to last item. - value: selectedItems.isEmpty ? null : selectedItems.last, + multiValueListenable: multiValueListenable, onChanged: (value) {}, selectedItemBuilder: (context) { return items.map( (item) { - return Container( - alignment: AlignmentDirectional.center, - child: Text( - selectedItems.join(', '), - style: const TextStyle( - fontSize: 14, - overflow: TextOverflow.ellipsis, - ), - maxLines: 1, - ), - ); + return ValueListenableBuilder>( + valueListenable: multiValueListenable, + builder: (context, multiValue, _) { + return Container( + alignment: AlignmentDirectional.center, + child: Text( + multiValue + .where((item) => item != 'All') + .join(', '), + style: const TextStyle( + fontSize: 14, + overflow: TextOverflow.ellipsis, + ), + maxLines: 1, + ), + ); + }); }, ).toList(); }, diff --git a/packages/dropdown_button2_test/lib/src/search_example.dart b/packages/dropdown_button2_test/lib/src/search_example.dart index 88b0db6..1d34c8c 100644 --- a/packages/dropdown_button2_test/lib/src/search_example.dart +++ b/packages/dropdown_button2_test/lib/src/search_example.dart @@ -20,7 +20,7 @@ class _SearchExampleState extends State { 'B_Item4', ]; - String? selectedValue; + final valueListenable = ValueNotifier(null); final TextEditingController textEditingController = TextEditingController(); @override @@ -55,11 +55,9 @@ class _SearchExampleState extends State { ), )) .toList(), - value: selectedValue, + valueListenable: valueListenable, onChanged: (value) { - setState(() { - selectedValue = value; - }); + valueListenable.value = value; }, buttonStyleData: const ButtonStyleData( padding: EdgeInsets.symmetric(horizontal: 16), diff --git a/packages/dropdown_button2_test/lib/src/simple_example.dart b/packages/dropdown_button2_test/lib/src/simple_example.dart index fbee714..de69c71 100644 --- a/packages/dropdown_button2_test/lib/src/simple_example.dart +++ b/packages/dropdown_button2_test/lib/src/simple_example.dart @@ -15,7 +15,7 @@ class _SimpleExampleState extends State { 'Item3', 'Item4', ]; - String? selectedValue; + final valueListenable = ValueNotifier(null); @override Widget build(BuildContext context) { @@ -43,11 +43,9 @@ class _SimpleExampleState extends State { ), )) .toList(), - value: selectedValue, + valueListenable: valueListenable, onChanged: (String? value) { - setState(() { - selectedValue = value; - }); + valueListenable.value = value; }, buttonStyleData: const ButtonStyleData( padding: EdgeInsets.symmetric(horizontal: 16), diff --git a/packages/dropdown_button2_test/lib/src/with_separators_example.dart b/packages/dropdown_button2_test/lib/src/with_separators_example.dart index 24593cc..8bc1055 100644 --- a/packages/dropdown_button2_test/lib/src/with_separators_example.dart +++ b/packages/dropdown_button2_test/lib/src/with_separators_example.dart @@ -15,7 +15,7 @@ class _WithSeparatorsExampleState extends State { 'Item3', 'Item4', ]; - String? selectedValue; + final valueListenable = ValueNotifier(null); @override Widget build(BuildContext context) { @@ -53,11 +53,9 @@ class _WithSeparatorsExampleState extends State { child: Divider(), ), ), - value: selectedValue, - onChanged: (String? value) { - setState(() { - selectedValue = value; - }); + valueListenable: valueListenable, + onChanged: (value) { + valueListenable.value = value; }, buttonStyleData: const ButtonStyleData( padding: EdgeInsets.symmetric(horizontal: 16), diff --git a/packages/dropdown_button2_test/test/examples/goldens/multi_select_example/desktop_open_menu.png b/packages/dropdown_button2_test/test/examples/goldens/multi_select_example/desktop_open_menu.png index 1372d86..4f4701d 100644 Binary files a/packages/dropdown_button2_test/test/examples/goldens/multi_select_example/desktop_open_menu.png and b/packages/dropdown_button2_test/test/examples/goldens/multi_select_example/desktop_open_menu.png differ diff --git a/packages/dropdown_button2_test/test/examples/goldens/multi_select_example/ipad_pro_open_menu.png b/packages/dropdown_button2_test/test/examples/goldens/multi_select_example/ipad_pro_open_menu.png index 253970d..2ce0fba 100644 Binary files a/packages/dropdown_button2_test/test/examples/goldens/multi_select_example/ipad_pro_open_menu.png and b/packages/dropdown_button2_test/test/examples/goldens/multi_select_example/ipad_pro_open_menu.png differ diff --git a/packages/dropdown_button2_test/test/examples/goldens/multi_select_example/iphone_14_open_menu.png b/packages/dropdown_button2_test/test/examples/goldens/multi_select_example/iphone_14_open_menu.png index 6a979d9..efbbb87 100644 Binary files a/packages/dropdown_button2_test/test/examples/goldens/multi_select_example/iphone_14_open_menu.png and b/packages/dropdown_button2_test/test/examples/goldens/multi_select_example/iphone_14_open_menu.png differ diff --git a/packages/dropdown_button2_test/test/examples/goldens/multi_select_example/iphone_8_open_menu.png b/packages/dropdown_button2_test/test/examples/goldens/multi_select_example/iphone_8_open_menu.png index 404565e..b22dfc6 100644 Binary files a/packages/dropdown_button2_test/test/examples/goldens/multi_select_example/iphone_8_open_menu.png and b/packages/dropdown_button2_test/test/examples/goldens/multi_select_example/iphone_8_open_menu.png differ diff --git a/packages/dropdown_button2_test/test/examples/goldens/multi_select_example/pixel_5_open_menu.png b/packages/dropdown_button2_test/test/examples/goldens/multi_select_example/pixel_5_open_menu.png index 0b83de5..625706d 100644 Binary files a/packages/dropdown_button2_test/test/examples/goldens/multi_select_example/pixel_5_open_menu.png and b/packages/dropdown_button2_test/test/examples/goldens/multi_select_example/pixel_5_open_menu.png differ