Custom dropdown button flutter bằng Overlay

Flutter đã hỗ trợ chúng ta component DropdownButton tuy nhiên việc custom khá hạn chế.

Trong bài viết này mình sẽ hướng dẫn các bạn sử dụng Overlay để custom dropdown như hình bên dưới.

1. Tạo file dropdown_country_box.dart

Class này sẽ nhận tham số country đầu vào và callBack nhận giá trị trả về.

class DropdownCountryBox extends StatefulWidget {
  final String country;
  ValueSetter<String> callBack;

  DropdownCountryBox(this.country, {Key? key, required this.callBack}) : super(key: key);

  @override
  _DropdownCountryBoxState createState() => _DropdownCountryBoxState();
}

class _DropdownCountryBoxState extends State<DropdownCountryBox> {
  @override
  Widget build(BuildContext context) {
    return _createHeader();
  }

  Widget _createHeader() {
    return Container(
      decoration: BoxDecoration(
          color: Colors.white,
          boxShadow: [
            BoxShadow(
                color: Colors.grey.withOpacity(0.1),
                blurRadius: 0.1,
                offset: const Offset(0, 0.1)),
          ],
          borderRadius: BorderRadius.circular(8)),
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          const Spacer(),
          Text(
            widget.country,
            style: const TextStyle(
                color: Colors.black,
                fontSize: 14),
          ),
          const Spacer(),
          const Icon(Icons.arrow_drop_down),
        ],
      ),
    );
  }
}

2. Tiếp theo ở main chúng ra sẽ hiển thị dropbox vừa tạo ra như sau:

class _MyHomePageState extends State<MyHomePage> {
  String dropdownValue = "VietNam";
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black12,
      appBar: AppBar(),
      body: Container(
        margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
        child: Row(
          children: [
            const Text("Select Country", style: TextStyle(fontSize: 14, color: Colors.white)),
            const SizedBox(width: 12),
            Expanded(child: DropdownCountryBox(dropdownValue, callBack: (value) =>{
              setState(() {
                dropdownValue = value;
              })
            })),
          ],
        ),
      )
    );
  }
}
Sau khi chạy chúng ta được kết quả như sau.

3. Tạo các item khi dropbox hiển thị

Item này sẽ hiển thị tên country, trạng thái được chọn, và bo tròn ở đầu/ cuối danh sách. Đồng thời callback return value khi người dùng click vào.

class DropDownItem extends StatelessWidget {
  final String text;
  final bool isSelected;
  final bool isFirstItem;
  final bool isLastItem;
  ValueSetter<String> callBack;

  DropDownItem(
      {required this.text,
        this.isSelected = false,
        this.isFirstItem = false,
        this.isLastItem = false,
        required this.callBack});

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        //return value
        callBack(text);
      },
      child: Container(
        decoration: BoxDecoration(
          borderRadius: const BorderRadius.all(Radius.circular(8)),
          gradient: isSelected
              ? AppColors.bgGradient
              : const LinearGradient(colors: [Colors.white, Colors.white]),
        ),
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
        child: Align(
          alignment: Alignment.center,
          child: Text(
            text,
            style: TextStyle(
                color: isSelected ? Colors.white : AppColors.textColor,
                fontSize: 14),
          ),
        ),
      ),
    );
  }
}

4. Setup các item thành 1 danh sách các nước.

itemHeight ở đây là chiều cao của item Header từ đó tính toán ra kích thước của danh sách.

class DropDown extends StatelessWidget {
  final double itemHeight;
  final String selectedItem;
  ValueSetter<String> callBack;
  List<String> dropCountryData = <String>['VietNam', 'ThaiLan', 'Campuchia', 'Indo', 'Sing'];

  DropDown(
      {Key? key,
        required this.itemHeight,
        required this.selectedItem,
        required this.callBack})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        const SizedBox(
          height: 5,
        ),
        Material(
          color: Colors.transparent,
          child: Container(
            height: dropCountryData.length * itemHeight + 5,
            decoration: BoxDecoration(
              borderRadius: BorderRadius.circular(8),
              color: Colors.white,
              boxShadow: [
                BoxShadow(
                    color: Colors.grey.withOpacity(0.1),
                    blurRadius: 0.1,
                    offset: const Offset(0, 0.1)),
              ],
            ),
            child: Column(
              children: <Widget>[
                DropDownItem(
                  text: dropCountryData[0],
                  isSelected: selectedItem == dropCountryData[0],
                  callBack: callBack,
                  isFirstItem: true
                ),
                DropDownItem(
                  text: dropCountryData[1],
                  isSelected: selectedItem == dropCountryData[1],
                  callBack: callBack,
                ),
                DropDownItem(
                  text: dropCountryData[2],
                  isSelected: selectedItem == dropCountryData[2],
                  callBack: callBack,
                ),
                DropDownItem(
                  text: dropCountryData[3],
                  isSelected: selectedItem == dropCountryData[3],
                  callBack: callBack,
                ),
                DropDownItem(
                  text: dropCountryData[4],
                  isSelected: selectedItem == dropCountryData[4],
                  callBack: callBack,
                  isLastItem: true
                ),
              ],
            ),
          ),
        ),
      ],
    );
  }
}

5. Tính toán vị trí hiển thị của lớp Overlay

Đầu tiên cập nhật _DropdownCountryBoxState như sau:

class _DropdownCountryBoxState extends State<DropdownCountryBox> {
  GlobalKey? actionKey;
  double height = 0, width = 0, xPosition = 0, yPosition = 0;
  bool isDropdownOpened = false;
  OverlayEntry? floatingDropdown;

  @override
  void initState() {
    actionKey = LabeledGlobalKey(widget.text);
    super.initState();
  }

  void hideDropdown(){
    floatingDropdown?.remove();
    isDropdownOpened = !isDropdownOpened;
  }

Tiếp theo bọc _createHeader() để xử lý action click ẩn hiện overlay và lưu vào GlobalKey để tính toán chiều cao.

return GestureDetector(
        key: actionKey,
        onTap: () {
          setState(() {
            if (isDropdownOpened) {
              floatingDropdown?.remove();
            } else {
              findDropdownPosition();
              floatingDropdown = _createFloatingDropdown();
              Overlay.of(context)?.insert(floatingDropdown!);
            }
            isDropdownOpened = !isDropdownOpened;
          });
        },
        child: _createHeader());

6. Tính toán vị trí sẽ hiện thị dropdown

Dùng actionKey đã lưu để lấy được thông tin Header sau đó lấy vị trí hiện thị của lớp Overlay qua offset.

  • xPosition vị trí cạnh trái của Header
  • yPosition vị trí bên dưới của Header

  void findDropdownPosition() {
    RenderBox? renderBox =
    actionKey?.currentContext?.findRenderObject() as RenderBox?;
    height = renderBox?.size.height ?? 0;
    width = renderBox?.size.width ?? 0;
    Offset? offset = renderBox?.localToGlobal(Offset.zero);
    xPosition = offset?.dx ?? 0;
    yPosition = offset?.dy ?? 0;
    print(height);
    print(width);
    print(xPosition);
    print(yPosition);
  }

Tiến hành tạo lớp Overlay để hiển thị.

 OverlayEntry _createFloatingDropdown() {
    return OverlayEntry(builder: (context) {
      return   Positioned(
        width: MediaQuery.of(context).size.width,
        top: yPosition + height,
        child: Container(
          margin: const EdgeInsets.symmetric(horizontal: 16),
          child: DropDown(
            itemHeight: height,
            selectedItem: widget.country,
            callBack: (value) => {
              hideDropdown(),
              widget.callBack(value)
            },
          ),
        ),
      );
    });
  }

Chạy lại ứng dụng để kiểm tra kết quả.

7. Bonus

Để code hoạt động tốt hơn chúng ta sẽ cần sử lý thêm các logic như

  • Touch outside overlay để tắt lớp phủ
  • Xử lý action back phím cứng của android.

Phần bonus này mình sẽ share luôn trong file code bên dưới.

SourceCode: Github

Nguyễn Linh

Chia sẻ để cùng tiến bộ...