Create custom dropdown in flutter - or how to put custom dropdown options in a layer above everything else

15,815

Solution 1

Custom dropdown below button

I understand that the built-in dropdown works very well but for some use cases, I need something different. For example, if I only have a few items I want the drop-down to appear below the button or have full control of where the dropdown appears. I haven't found a good option yet so I have tried to make my own. I have built on what @M123 mentioned with the overlay and tried to implement it in a similar way to the built-in dropdown. I have found this medium post from the developer of flutter_typeahead very useful. https://medium.com/saugo360/https-medium-com-saugo360-flutter-using-overlay-to-display-floating-widgets-2e6d0e8decb9

animated gif of custom dropdown

The button creates a full-screen stack using overlay. This is so that we can add a full-screen gesture detector behind the dropdown so that it closes when the user taps anywhere on the screen.

The overlay is linked to the button using a LayerLink and the CompositedTransformFollower widget.

We also use RenderBox renderBox = context.findRenderObject(); to easily get the position and size of the button. Then position the dropdown accoridingly.

the Dropdown file

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';

class CustomDropdown<T> extends StatefulWidget {
  /// the child widget for the button, this will be ignored if text is supplied
  final Widget child;

  /// onChange is called when the selected option is changed.;
  /// It will pass back the value and the index of the option.
  final void Function(T, int) onChange;

  /// list of DropdownItems
  final List<DropdownItem<T>> items;
  final DropdownStyle dropdownStyle;

  /// dropdownButtonStyles passes styles to OutlineButton.styleFrom()
  final DropdownButtonStyle dropdownButtonStyle;

  /// dropdown button icon defaults to caret
  final Icon icon;
  final bool hideIcon;

  /// if true the dropdown icon will as a leading icon, default to false
  final bool leadingIcon;
  CustomDropdown({
    Key key,
    this.hideIcon = false,
    @required this.child,
    @required this.items,
    this.dropdownStyle = const DropdownStyle(),
    this.dropdownButtonStyle = const DropdownButtonStyle(),
    this.icon,
    this.leadingIcon = false,
    this.onChange,
  }) : super(key: key);

  @override
  _CustomDropdownState<T> createState() => _CustomDropdownState<T>();
}

class _CustomDropdownState<T> extends State<CustomDropdown<T>>
    with TickerProviderStateMixin {
  final LayerLink _layerLink = LayerLink();
  OverlayEntry _overlayEntry;
  bool _isOpen = false;
  int _currentIndex = -1;
  AnimationController _animationController;
  Animation<double> _expandAnimation;
  Animation<double> _rotateAnimation;

  @override
  void initState() {
    super.initState();

    _animationController =
        AnimationController(vsync: this, duration: Duration(milliseconds: 200));
    _expandAnimation = CurvedAnimation(
      parent: _animationController,
      curve: Curves.easeInOut,
    );
    _rotateAnimation = Tween(begin: 0.0, end: 0.5).animate(CurvedAnimation(
      parent: _animationController,
      curve: Curves.easeInOut,
    ));
  }

  @override
  Widget build(BuildContext context) {
    var style = widget.dropdownButtonStyle;
    // link the overlay to the button
    return CompositedTransformTarget(
      link: this._layerLink,
      child: Container(
        width: style.width,
        height: style.height,
        child: OutlinedButton(
          style: OutlinedButton.styleFrom(
            padding: style.padding,
            backgroundColor: style.backgroundColor,
            elevation: style.elevation,
            primary: style.primaryColor,
            shape: style.shape,
          ),
          onPressed: _toggleDropdown,
          child: Row(
            mainAxisAlignment:
                style.mainAxisAlignment ?? MainAxisAlignment.center,
            textDirection:
                widget.leadingIcon ? TextDirection.rtl : TextDirection.ltr,
            mainAxisSize: MainAxisSize.min,
            children: [
              if (_currentIndex == -1) ...[
                widget.child,
              ] else ...[
                widget.items[_currentIndex],
              ],
              if (!widget.hideIcon)
                RotationTransition(
                  turns: _rotateAnimation,
                  child: widget.icon ?? Icon(FontAwesomeIcons.caretDown),
                ),
            ],
          ),
        ),
      ),
    );
  }

  OverlayEntry _createOverlayEntry() {
    // find the size and position of the current widget
    RenderBox renderBox = context.findRenderObject();
    var size = renderBox.size;
    
    var offset = renderBox.localToGlobal(Offset.zero);
    var topOffset = offset.dy + size.height + 5;
    return OverlayEntry(
      // full screen GestureDetector to register when a
      // user has clicked away from the dropdown
      builder: (context) => GestureDetector(
        onTap: () => _toggleDropdown(close: true),
        behavior: HitTestBehavior.translucent,
        // full screen container to register taps anywhere and close drop down
        child: Container(
          height: MediaQuery.of(context).size.height,
          width: MediaQuery.of(context).size.width,
          child: Stack(
            children: [
              Positioned(
                left: offset.dx,
                top: topOffset,
                width: widget.dropdownStyle.width ?? size.width,
                child: CompositedTransformFollower(
                  offset:
                      widget.dropdownStyle.offset ?? Offset(0, size.height + 5),
                  link: this._layerLink,
                  showWhenUnlinked: false,
                  child: Material(
                    elevation: widget.dropdownStyle.elevation ?? 0,
                    borderRadius:
                        widget.dropdownStyle.borderRadius ?? BorderRadius.zero,
                    color: widget.dropdownStyle.color,
                    child: SizeTransition(
                      axisAlignment: 1,
                      sizeFactor: _expandAnimation,
                      child: ConstrainedBox(
                        constraints: widget.dropdownStyle.constraints ??
                            BoxConstraints(
                              maxHeight: MediaQuery.of(context).size.height -
                                  topOffset -
                                  15,
                            ),
                        child: ListView(
                          padding:
                              widget.dropdownStyle.padding ?? EdgeInsets.zero,
                          shrinkWrap: true,
                          children: widget.items.asMap().entries.map((item) {
                            return InkWell(
                              onTap: () {
                                setState(() => _currentIndex = item.key);
                                widget.onChange(item.value.value, item.key);
                                _toggleDropdown();
                              },
                              child: item.value,
                            );
                          }).toList(),
                        ),
                      ),
                    ),
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  void _toggleDropdown({bool close = false}) async {
    if (_isOpen || close) {
      await _animationController.reverse();
      this._overlayEntry.remove();
      setState(() {
        _isOpen = false;
      });
    } else {
      this._overlayEntry = this._createOverlayEntry();
      Overlay.of(context).insert(this._overlayEntry);
      setState(() => _isOpen = true);
      _animationController.forward();
    }
  }
}

/// DropdownItem is just a wrapper for each child in the dropdown list.\n
/// It holds the value of the item.
class DropdownItem<T> extends StatelessWidget {
  final T value;
  final Widget child;

  const DropdownItem({Key key, this.value, this.child}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return child;
  }
}

class DropdownButtonStyle {
  final MainAxisAlignment mainAxisAlignment;
  final ShapeBorder shape;
  final double elevation;
  final Color backgroundColor;
  final EdgeInsets padding;
  final BoxConstraints constraints;
  final double width;
  final double height;
  final Color primaryColor;
  const DropdownButtonStyle({
    this.mainAxisAlignment,
    this.backgroundColor,
    this.primaryColor,
    this.constraints,
    this.height,
    this.width,
    this.elevation,
    this.padding,
    this.shape,
  });
}

class DropdownStyle {
  final BorderRadius borderRadius;
  final double elevation;
  final Color color;
  final EdgeInsets padding;
  final BoxConstraints constraints;

  /// position of the top left of the dropdown relative to the top left of the button
  final Offset offset;

  ///button width must be set for this to take effect
  final double width;

  const DropdownStyle({
    this.constraints,
    this.offset,
    this.width,
    this.elevation,
    this.color,
    this.padding,
    this.borderRadius,
  });
}

using the dropdown

I have tried to make using the custom dropdown similar to the built-in one with the added bonus of being able to style the actual dropdown, as well as the button.

Widget build(BuildContext context) {
  return Scaffold(
    body: Center(
      child: CustomDropdown<int>(
        child: Text(
          'dropdown',
        ),
        onChange: (int value, int index) => print(value),
        dropdownButtonStyle: DropdownButtonStyle(
          width: 170,
          height: 40,
          elevation: 1,
          backgroundColor: Colors.white,
          primaryColor: Colors.black87,
        ),
        dropdownStyle: DropdownStyle(
          borderRadius: BorderRadius.circular(8),
          elevation: 6,
          padding: EdgeInsets.all(5),
        ),
        items: [
          'item 1',
          'item 2',
          'item 3',
          'item 4',
        ]
            .asMap()
            .entries
            .map(
              (item) => DropdownItem<int>(
            value: item.key + 1,
            child: Padding(
              padding: const EdgeInsets.all(8.0),
              child: Text(item.value),
            ),
          ),
        )
            .toList(),
      ),
    ),
  );
}

I am sure there will be some improvements needed in there somewhere. But it's working for me at the moment.

Solution 2

Dropdown decision

I would recommend using the standard Flutter drop down menu. Because it is very robust, easy to write and has been tried and tested. You said that you would like to style your drop down yourself, I suspect that this is the reason why you decided against the standard. But this doesn't have to be the case. The standard drop down menu can be designed pretty well. More on that below

Example Code

  String dropdownValue = 'One';

  Widget build(BuildContext context) {
    return DropdownButton<String>(
      value: dropdownValue,
      icon: Icon(Icons.arrow_downward),
      iconSize: 24,
      elevation: 16,
      style: TextStyle(color: Colors.deepPurple),
      underline: Container(
        height: 2,
        color: Colors.deepPurpleAccent,
      ),
      onChanged: (String newValue) {
        setState(() {
          dropdownValue = newValue;
        });
      },
      items: <String>['One', 'Two', 'Free', 'Four']
          .map<DropdownMenuItem<String>>((String value) {
        return DropdownMenuItem<String>(
          value: value,
          child: Text(value),
        );
      }).toList(),
    );

Style

Your DropdownMenuItem will follow your ThemeData class. Not only its backgroundColor will match the canvasColor in your ThemeData class, but also it will follow the same TextStyle.

The Theme data has to be initialized in the Material App:

return MaterialApp(
    //....
    theme: new ThemeData(
            fontFamily: "Encode Sans", //my custom font
            canvasColor: _turquoise, //my custom color
            //other theme data
            ),
     //.....
),

If you don't want to or can't work with theme data, this may be something for you. The DropdownButton class has an inbuilt variable called dropdownColor which can be assigned any color you need directly, without changing any ThemeData. Automatically changes the color of the dropdown menu items as well.

For example, if you want to change the With from the dropdown you can feed its child property a new Container and add the desired width. just make sure you use a suitable width so that you do not get overflow problems later on when you use the menu within a more complex layout. I would still recommend leaving the width on dynamic.

In addition, the DropDownButton has the ability to expand, which means that it takes up all the space it can get

DropdownButton<String>(
   isExpanded: true,
)

Solution 3

I found a new way to build a custom drop down, by using Overlay.

Docs:

Overlays let independent child widgets "float" visual elements on top of other widgets by inserting them into the overlay's Stack. The overlay lets each of these widgets manage their participation in the overlay using OverlayEntry objects.

This gives you all the design freedom, as every kind of child is allowed. How to move the DropDown I wrote as comments in the code.

Here is a small sample, how to use it.

OverlayEntry floatingDropdown;


AnyButton(
   //...
   onTap: () {
        setState(() {
          if (isDropdownOpened) {
            floatingDropdown.remove();
          } else {
            findDropdownData();
            floatingDropdown = _createFloatingDropdown();
            Overlay.of(context).insert(floatingDropdown);
          }

          isDropdownOpened = !isDropdownOpened;
        });
      },
);




  OverlayEntry _createFloatingDropdown() {
    return OverlayEntry(builder: (context) {
      return Positioned(
        // You can change the position here
        left: xPosition,
        width: width,
        top: yPosition + height,
        height: 4 * height + 40,
        // Any child
        child: Container(
          color: Colors.black,
          height: height,
          child: Text('Hallo'),
        ),
      );
    });
  }

A full fully designed example can be found here.

Share:
15,815
user3808307
Author by

user3808307

Updated on June 14, 2022

Comments

  • user3808307
    user3808307 almost 2 years

    I am looking for a way to create a custom dropdown so I can style it myself.

    I ran into this answer that seems pretty useful

    https://stackoverflow.com/a/63165793/3808307

    The problem is that if the container is smaller than the dropdown, flutter complains about pixel overflowing. How can I get this dropdown to be on top of the other elements in the page, so I don't get this warning? Or is there another way to recreate a custom dropdown without this issue?

    All answers I find are regarding the built in DropdownButton

    Below, the answer linked above, with editions


    First, create a dart file named drop_list_model.dart:

    import 'package:flutter/material.dart';
    
    class DropListModel {
      DropListModel(this.listOptionItems);
    
      final List<OptionItem> listOptionItems;
    }
    
    class OptionItem {
      final String id;
      final String title;
    
      OptionItem({@required this.id, @required this.title});
    }
    

    Next, create file file select_drop_list.dart:

    import 'package:flutter/material.dart';
    import 'package:time_keeping/model/drop_list_model.dart';
    import 'package:time_keeping/widgets/src/core_internal.dart';
    
    class SelectDropList extends StatefulWidget {
      final OptionItem itemSelected;
      final DropListModel dropListModel;
      final Function(OptionItem optionItem) onOptionSelected;
    
      SelectDropList(this.itemSelected, this.dropListModel, this.onOptionSelected);
    
      @override
      _SelectDropListState createState() => _SelectDropListState(itemSelected, dropListModel);
    }
    
    class _SelectDropListState extends State<SelectDropList> with SingleTickerProviderStateMixin {
    
      OptionItem optionItemSelected;
      final DropListModel dropListModel;
    
      AnimationController expandController;
      Animation<double> animation;
    
      bool isShow = false;
    
      _SelectDropListState(this.optionItemSelected, this.dropListModel);
    
      @override
      void initState() {
        super.initState();
        expandController = AnimationController(
            vsync: this,
            duration: Duration(milliseconds: 350)
        );
        animation = CurvedAnimation(
          parent: expandController,
          curve: Curves.fastOutSlowIn,
        );
        _runExpandCheck();
      }
    
      void _runExpandCheck() {
        if(isShow) {
          expandController.forward();
        } else {
          expandController.reverse();
        }
      }
    
      @override
      void dispose() {
        expandController.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return Container(
          child: Column(
            children: <Widget>[
              Container(
                padding: const EdgeInsets.symmetric(
                    horizontal: 15, vertical: 17),
                decoration: new BoxDecoration(
                  borderRadius: BorderRadius.circular(20.0),
                  color: Colors.white,
                  boxShadow: [
                    BoxShadow(
                        blurRadius: 10,
                        color: Colors.black26,
                        offset: Offset(0, 2))
                  ],
                ),
                child: new Row(
                  mainAxisSize: MainAxisSize.max,
                  crossAxisAlignment: CrossAxisAlignment.center,
                  children: <Widget>[
                    Icon(Icons.card_travel, color: Color(0xFF307DF1),),
                    SizedBox(width: 10,),
                  
                      child: GestureDetector(
                        onTap: () {
                          this.isShow = !this.isShow;
                          _runExpandCheck();
                          setState(() {
    
                          });
                        },
                        child: Text(optionItemSelected.title, style: TextStyle(
                            color: Color(0xFF307DF1),
                            fontSize: 16),),
                      ),
                   
                    Align(
                      alignment: Alignment(1, 0),
                      child: Icon(
                        isShow ? Icons.arrow_drop_down : Icons.arrow_right,
                        color: Color(0xFF307DF1),
                        size: 15,
                      ),
                    ),
                  ],
                ),
              ),
              SizeTransition(
                  axisAlignment: 1.0,
                  sizeFactor: animation,
                  child: Container(
                    margin: const EdgeInsets.only(bottom: 10),
                      padding: const EdgeInsets.only(bottom: 10),
                      decoration: new BoxDecoration(
                        borderRadius: BorderRadius.only(bottomLeft: Radius.circular(20), bottomRight: Radius.circular(20)),
                        color: Colors.white,
                        boxShadow: [
                          BoxShadow(
                              blurRadius: 4,
                              color: Colors.black26,
                              offset: Offset(0, 4))
                        ],
                      ),
                      child: _buildDropListOptions(dropListModel.listOptionItems, context)
                  )
              ),
    //          Divider(color: Colors.grey.shade300, height: 1,)
            ],
          ),
        );
      }
    
      Column _buildDropListOptions(List<OptionItem> items, BuildContext context) {
        return Column(
          children: items.map((item) => _buildSubMenu(item, context)).toList(),
        );
      }
    
      Widget _buildSubMenu(OptionItem item, BuildContext context) {
        return Padding(
          padding: const EdgeInsets.only(left: 26.0, top: 5, bottom: 5),
          child: GestureDetector(
            child: Row(
              children: <Widget>[
                
                  child: Container(
                    padding: const EdgeInsets.only(top: 20),
                    decoration: BoxDecoration(
                      border: Border(top: BorderSide(color: Colors.grey[200], width: 1)),
                    ),
                    child: Text(item.title,
                        style: TextStyle(
                            color: Color(0xFF307DF1),
                            fontWeight: FontWeight.w400,
                            fontSize: 14),
                        maxLines: 3,
                        textAlign: TextAlign.start,
                        overflow: TextOverflow.ellipsis),
                  ),
                
              ],
            ),
            onTap: () {
              this.optionItemSelected = item;
              isShow = false;
              expandController.reverse();
              widget.onOptionSelected(item);
            },
          ),
        );
      }
    
    }
    

    Initialize value:

    DropListModel dropListModel = DropListModel([OptionItem(id: "1", title: "Option 1"), OptionItem(id: "2", title: "Option 2")]);
    OptionItem optionItemSelected = OptionItem(id: null, title: "Chọn quyền truy cập");
    

    Finally use it:

    Container(height: 47, child: SelectDropList(
               this.optionItemSelected, 
               this.dropListModel, 
               (optionItem){
                     optionItemSelected = optionItem;
                        setState(() {
      
                        });
                   },
                ))
    
  • iDecode
    iDecode over 3 years
    You just finished writing the answer and got an upvote! Even reading your answer will take more than 5 mins.
  • m123
    m123 over 3 years
    I'm sorry that I wrote such a big text. But since I didn't follow your question exactly, I took a little more time to explain why I would suggest the other way
  • iDecode
    iDecode over 3 years
    Hey, I'm not OP, but I just saw after I wrote the comment, somebody took off their upvote.
  • m123
    m123 over 3 years
    Ohh, I didn't notice. I answered several questions today so I can already imagine that something like this happened. About a month ago I received a notification that someone I had helped upvoted many of my answers one after the other. Of course, this reputation was not attributed to me lol. Again regarding your question, what do you think of my proposal
  • user3808307
    user3808307 over 3 years
    Yes, @M123, the problem with the dropdown that comes with flutter is the styling. For example, where to place the dropdown in regards to the button. It has no flexibility in that sense. Regarding overflow, the one that comes with flutter has no overflow problem, it is in a layer above. What I want to achive is have my own dropdown that will behave as the built in with no overflow, but that can be fully customizable in appearance
  • m123
    m123 over 3 years
    So your goal is to move your dropdown further away from the button or what do you want to achieve. I'll see if I can find a way later
  • user3808307
    user3808307 over 3 years
    @M123 for example that it makes the selected element be in the middle (after you select, when you reopen, the selected item is in the vertical axis at the same coordinate as the dropdown button), or having a shadow instead of elevation. I ended up copying PopupMenuButton code into a separate file, renaming the classes, and worked from there. I don't know if it was the best option
  • user3808307
    user3808307 over 3 years
    @M123 Also it has this padding added by the widget that I did not want. Flutter assumes the styling. Most people, at least that I know of, work with designer, so the styling can't be defined by Flutter, I hate that side of it, it reminds me of WP.
  • m123
    m123 over 3 years
    @user3808307 It's great that you found a solution, I didn't understand that with the padding, have you tried both of my answers, maybe the second one with the OverlayEntry works better for you.
  • user3808307
    user3808307 over 3 years
    The problem with this one is that the dropdown button is in list items. So having to set the position for each individually is not really convenients. Specially when the ones at the bottom need to open up (so as to not go out of bounds of the page), and the ones on the top have to open down.
  • Osama Remlawi
    Osama Remlawi over 2 years
    The "CustomDropdown" has plenty of errors with Flutter 2.2.3 Dart 2.13.4, any idea?
  • Dan James
    Dan James over 2 years
    The principal should work still but the above code is not null safe. You will need to migrate the code to null safe. I will look at doing that for the answer soon.