點燈坊

失くすものさえない今が強くなるチャンスよ

GestureDetector 取代 ListTile 顯示兩層選單

Sam Xiao's Avatar 2024-10-30

DropdownBottomSheet 典型都是使用 ListView 搭配 ListTile 實現,但 ListTile 自帶 Padding,可能會無法達成 Designer 的設計要求,可使用沒有自帶 Padding 的 GestureDector 取代 ListTile

Version

Flutter 3.24

Flutter

gesture01

  • Android 與 iOS 都成功使用 GestureDetector 實現兩層下拉選單,注意每個 item 之間都沒有 padding

GestureDetector

import 'package:flutter/material.dart';

class Home extends StatefulWidget {
  const Home({super.key});

  
  State<Home> createState() => _Home();
}

class _Home extends State<Home> {
  String _selectedText = 'Select Option';

  void _showModalBottomSheet(BuildContext context) {
    showModalBottomSheet(
      context: context,
      isScrollControlled: true,
      shape: const RoundedRectangleBorder(
        borderRadius: BorderRadius.vertical(
          top: Radius.circular(16),
        ),
      ),
      builder: (BuildContext context) {
        var deviceHeight = MediaQuery.of(context).size.height;

        return Padding(
          padding: const EdgeInsets.symmetric(horizontal: 16.0),
          child: SizedBox(
            height: deviceHeight * 0.2,
            child: ListView(
              shrinkWrap: true,
              children: [
                Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    GestureDetector(
                      onTap: () {
                        setState(() => _selectedText = 'Option 1');
                        Navigator.pop(context);
                      },
                      child: const Padding(
                        padding: EdgeInsets.zero,
                        child: Text(
                          'Option 1',
                          style: TextStyle(fontWeight: FontWeight.bold),
                        ),
                      ),
                    ),
                    Padding(
                      padding: const EdgeInsets.only(left: 32.0),
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          GestureDetector(
                            onTap: () {
                              setState(() => _selectedText = 'Option 1.1');
                              Navigator.pop(context);
                            },
                            child: const Padding(
                              padding: EdgeInsets.zero,
                              child: Text('Option 1.1'),
                            ),
                          ),
                          GestureDetector(
                            onTap: () {
                              setState(() => _selectedText = 'Option 1.2');
                              Navigator.pop(context);
                            },
                            child: const Padding(
                              padding: EdgeInsets.zero,
                              child: Text('Option 1.2'),
                            ),
                          ),
                        ],
                      ),
                    ),
                  ],
                ),
                Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    GestureDetector(
                      onTap: () {
                        setState(() => _selectedText = 'Option 2');
                        Navigator.pop(context);
                      },
                      child: const Padding(
                        padding: EdgeInsets.zero,
                        child: Text(
                          'Option 2',
                          style: TextStyle(fontWeight: FontWeight.bold),
                        ),
                      ),
                    ),
                    Padding(
                      padding: const EdgeInsets.only(left: 32.0),
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          GestureDetector(
                            onTap: () {
                              setState(() => _selectedText = 'Option 2.1');
                              Navigator.pop(context);
                            },
                            child: const Padding(
                              padding: EdgeInsets.zero,
                              child: Text('Option 2.1'),
                            ),
                          ),
                          GestureDetector(
                            onTap: () {
                              setState(() => _selectedText = 'Option 2.2');
                              Navigator.pop(context);
                            },
                            child: const Padding(
                              padding: EdgeInsets.zero,
                              child: Text('Option 2.2'),
                            ),
                          ),
                          GestureDetector(
                            onTap: () {
                              setState(() => _selectedText = 'Option 2.3');
                              Navigator.pop(context);
                            },
                            child: const Padding(
                              padding: EdgeInsets.zero,
                              child: Text('Option 2.3'),
                            ),
                          ),
                        ],
                      ),
                    ),
                  ],
                ),
                Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    GestureDetector(
                      onTap: () {
                        setState(() => _selectedText = 'Option 3');
                        Navigator.pop(context);
                      },
                      child: const Padding(
                        padding: EdgeInsets.zero,
                        child: Text(
                          'Option 3',
                          style: TextStyle(fontWeight: FontWeight.bold),
                        ),
                      ),
                    ),
                    Padding(
                      padding: const EdgeInsets.only(left: 32.0),
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          GestureDetector(
                            onTap: () {
                              setState(() => _selectedText = 'Option 3.1');
                              Navigator.pop(context);
                            },
                            child: const Padding(
                              padding: EdgeInsets.zero,
                              child: Text('Option 3.1'),
                            ),
                          ),
                          GestureDetector(
                            onTap: () {
                              setState(() => _selectedText = 'Option 3.2');
                              Navigator.pop(context);
                            },
                            child: const Padding(
                              padding: EdgeInsets.zero,
                              child: Text('Option 3.2'),
                            ),
                          ),
                        ],
                      ),
                    ),
                  ],
                ),
              ],
            ),
          ),
        );
      },
    );
  }

  
  Widget build(BuildContext context) {
    var appBar = AppBar(
      title: const Text('DropdownBottomSheet'),
    );

    var dropdownBottomSheet = GestureDetector(
      onTap: () => _showModalBottomSheet(context),
      child: Container(
        margin: const EdgeInsets.all(16.0),
        padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
        decoration: BoxDecoration(
          border: Border.all(color: Colors.grey),
          borderRadius: BorderRadius.circular(8.0),
        ),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            Text(_selectedText),
            const Icon(Icons.arrow_drop_down),
          ],
        ),
      ),
    );

    return Scaffold(
      appBar: appBar,
      body: dropdownBottomSheet,
    );
  }
}

Line 194

var dropdownBottomSheet = GestureDetector(
  onTap: () => _showModalBottomSheet(context),
  child: Container(
    margin: const EdgeInsets.all(16.0),
    padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
    decoration: BoxDecoration(
      border: Border.all(color: Colors.grey),
      borderRadius: BorderRadius.circular(8.0),
    ),
    child: Row(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: [
        Text(_selectedText),
        const Icon(Icons.arrow_drop_down),
      ],
    ),
  ),
)
  • GestureDetector:包裹整個 Container,當
    Container 被點擊時會觸發 onTap()
    • onTap():馬上呼叫 _showModalBottomSheet()

Line 101

child: Row(
  mainAxisAlignment: MainAxisAlignment.spaceBetween,
  children: [
    Text(_selectedText),
    const Icon(Icons.arrow_drop_down),
  ],
),
  • 上方的下拉選單,其實是用 TextIcon 組合出來的

Line 13

void _showModalBottomSheet(BuildContext context) {
  showModalBottomSheet(
    context: context,
    isScrollControlled: true,
    shape: const RoundedRectangleBorder(
      borderRadius: BorderRadius.vertical(
        top: Radius.circular(16),
      ),
    ),
    builder: (BuildContext context) {
      var deviceHeight = MediaQuery.of(context).size.height;

      return Padding(
        padding: const EdgeInsets.symmetric(horizontal: 16.0),
        child: SizedBox(
          height: deviceHeight * 0.2,
          child: ListView(
            shrinkWrap: true,
            children: [
              Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  GestureDetector(
                    onTap: () {
                      setState(() => _selectedText = 'Option 1');
                        Navigator.pop(context);
                    },
                    child: const Padding(
                      padding: EdgeInsets.zero,
                      child: Text(
                        'Option 1',
                        style: TextStyle(fontWeight: FontWeight.bold),
                      ),
                    ),
                  ),
                  Padding(
                    padding: const EdgeInsets.only(left: 32.0),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        GestureDetector(
                          onTap: () {
                            setState(() => _selectedText = 'Option 1.1');
                            Navigator.pop(context);
                          },
                          child: const Padding(
                            padding: EdgeInsets.zero,
                            child: Text('Option 1.1'),
                          ),
                        ),
                        GestureDetector(
                          onTap: () {
                            setState(() => _selectedText = 'Option 1.2');
                            Navigator.pop(context);
                          },
                          child: const Padding(
                            padding: EdgeInsets.zero,
                            child: Text('Option 1.2'),
                          ),
                        ),
                      ],
                    ),
                  ),
                ],
              ),
            ],
          ),
        ),
      );
    },
  );
}
  • 呼叫 showModalBottomSheet() 顯示 ModalBottomSheet

Line 35

GestureDetector(
  onTap: () {
    setState(() => _selectedText = 'Option 1');
    Navigator.pop(context);
  },
  child: const Padding(
    padding: EdgeInsets.zero,
    child: Text(
      'Option 1',
      style: TextStyle(fontWeight: FontWeight.bold),
    ),
  ),
),
  • 第一層選單
  • GestureDetector:由於不使用 ListTile,只是透過 GestureDetector 偵測點擊
    • onTap():第一層選單被點擊時觸發
    • Padding:由於不像 ListTile 自帶 padding,可自行使用 prototype 所設計的 padding
      • Text:選單文字

Line 53

Padding(
  padding: const EdgeInsets.only(left: 32.0),
  child: Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
     GestureDetector(
       onTap: () {
         setState(() => _selectedText = 'Option 1.1');
         Navigator.pop(context);
       },
       child: const Padding(
         padding: EdgeInsets.zero,
         child: Text('Option 1.1'),
       ),
    ),
  ],
),
  • 第二層選單
  • Padding:設定第二層選單的縮排
    • Column:第二層選單可包含多個選項
      • GestureDetector:由於不使用 ListTile,只是透過 GestureDetector 偵測點擊
        • onTap():第二層選單被點擊時觸發
        • Padding:由於不像 ListTile 自帶 padding,可自行使用 prototype 所設計的 padding
        • Text:選單文字

Conclusion

  • 在一般使用上,由 ListView + ListTile 組合可完美實現 DropdownBottomSheet,但由於 ListTile 自帶 padding,可能無法達成 designer 的要求
  • 若要完美實現 designer 的 padding,可使用 ListView + GestureDetector 組合,可自行精準設定 item 間的 padding