Search code examples
flutterdrop-down-menuflutter-layout

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


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(() {
  
                    });
               },
            ))

Solution

  • 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.