web analytics

รู้จัก Popup Menu ใน Flutter ตอนที่ 3

ความเดิมตอนที่แล้ว ในตอนที่ 1 เราได้ลองเล่นการใช้งาน Popup Menu แบบพื้นฐาน จากนั้นในตอนที่ 2 เราจะลองทำ Popup Menu แบบ custom เอง ในบล็อกนี้จะเป็นตอนต่อยอดจากการทำ Custom Popup Menu ครับ คือเราจะลองทำ sub-menu กัน

ใครยังไม่ได้อ่านตอนที่ 1-2 ให้อ่านก่อนนะครับ

Nested Popup Menu

ในตอนที่แล้วเราได้ทำ Popup menu ที่แสดง 1 level แต่ในโปรแกรมจริงๆ จะเห็นว่า Popup Menu มักจะมีมากกว่า 1 level แต่มักจะไม่เกิน 3 level

ตัวอย่าง Nested Popup Menu ใน Android Studio

Item Data

เริ่มจากเพิ่ม Item Data ของ Menu ผมตั้งชื่อว่า SubOptionPopupMenuItemData
โดยจะเก็บ List<BasePopupMenuItemData> ไว้

class SubOptionPopupMenuItemData extends BasePopupMenuItemData{
  int id;
  IconData? icon;
  String title;
  List<BasePopupMenuItemData> items;

  SubOptionPopupMenuItemData(
      {required this.id, this.icon, required this.title,required this.items});
}

จากนั้นเพิ่มข้อมูล ใน List<BasePopupMenuItemData> ของเรา

 List<BasePopupMenuItemData> listPopupMenuData = [
    ...
    DividerPopupMenuItemData(),
    SubOptionPopupMenuItemData(id: 7, title: "Refactor", items: [
      OptionPopupMenuItemData(id: 61, title: "Rename", shortcut: "Shift+F6"),
      OptionPopupMenuItemData(id: 62, title: "Move File", shortcut: "F6"),
    ])
  ];

Item Widget State

ต่อมา implement ส่วน class State ของเมนูย่อย ผมตั้งชื่อว่า SubOptionPopupMenuItemWidgetState
โดยจะ build Widget คล้ายๆ Item หลัก จะแตกต่างตรงไอคอนด้านขวาจะเป็นสามเหลี่ยม

class SubOptionPopupMenuItemWidgetState extends State<MyPopupMenuItem> {
  SubOptionPopupMenuItemData item;
  bool isHover = false;

  SubOptionPopupMenuItemWidgetState({required this.item});

  @override
  Widget build(BuildContext context) {
    return MouseRegion(
      onEnter: (_) {
        setState(() {
          isHover = true;
        });
      },
      onExit: (_) {
        setState(() {
          isHover = false;
        });
      },
      child: Container(
        color: isHover ? Color(0xff4b6eaf) : Color(0xff3c3f41),
        height: widget.height,
        padding: EdgeInsets.all(4),
        child: Row(
          children: [
            if (item.icon != null)
              Icon(
                item.icon,
                size: 24,
                color: Color(0xffafb1b3),
              )
            else
              SizedBox(
                width: 24,
              ),
            SizedBox(width: 4),
            Expanded(
                child: Text(
              item.title,
              style: TextStyle(fontSize: 12, color: Colors.white),
            )),
            Icon(
              Icons.play_arrow,
              size: 14,
              color: Colors.white,
            ),
            SizedBox(width: 8),
          ],
        ),
      ),
    );
  }
}

แล้วก็ ที่ method createState ของ Menu Item หาก Type เป็น SubOptionPopupMenuItemData ให้ return State ที่ถูกต้อง

class MyPopupMenuItem extends PopupMenuEntry<BasePopupMenuItemData> {
 ...

  @override
  State<StatefulWidget> createState() {
   ...
    if (item is SubOptionPopupMenuItemData) {
      return SubOptionPopupMenuItemWidgetState(
          item: item as SubOptionPopupMenuItemData);
    }
    throw Exception();
  }

ลองรัน จะเห็นว่า Item Menu แบบใหม่เพิ่มเข้ามาแล้ว แต่จะยังไม่ปรากฏ Popup Menu Level 2 เพราะเรายังไม่ได้ทำส่วนนั้น

showNestedPopupMenu

ต่อมาเรามาเขียน method สำหรับเรียก show Menu ย่อยกัน ขอเรียกว่า showNestedPopupMenu()
โดยเราจะใช้หลักการเดิมเหมือนกับตอนเรียก Popup Menu ปกติ โดยเบื้องต้นผมจะกำหนด position เป็น 0 ไปก่อน


class SubOptionPopupMenuItemWidgetState extends State<MyPopupMenuItem> {
  ...

  showNestedPopupMenu() async{
    MyPopupMenuItemData? value = await showMenu<MyPopupMenuItemData>(
        context: context,
        color: Color(0xff3c3f41),
        position: RelativeRect.fromLTRB( 0, 0, 0, 0),
        items: [
          for (var item in item.items)
            MyPopupMenuItem(
              item: item,
            )
        ]);
  }
}

จากนั้นเรียกใช้คำสั่ง showPopupMenu() ที่ onEnter เพื่อแสดง Popup Menu ย่อย เมื่อนำ cursor มาวางที่ Menu

class SubOptionPopupMenuItemWidgetState extends State<MyPopupMenuItem> {


 ...
  @override
  Widget build(BuildContext context) {
    return MouseRegion(
      onEnter: (_) {
        showPopupMenu();  // Add this line.
        setState(() {
          isHover = true;
        });
      },

ลองรัน จะพบว่า Popup Menu Level 2 แสดงแล้ว แต่ position ไม่ถูกต้อง เพราะเรากำหนด 0,0 ไว้

ดังนั้น ลองเปลี่ยนใหม่ กำหนด position จาก event ที่ได้จาก onEnter เลย

class SubOptionPopupMenuItemWidgetState extends State<BasePopupMenuItemData> {
  SubOptionPopupMenuItemData item;
  bool isHover = false;
  Offset? pointerOffset;

  SubOptionPopupMenuItemWidgetState({required this.item});

  @override
  Widget build(BuildContext context) {
    return MouseRegion(
      onEnter: (event) {
        pointerOffset = event.position;
        showNestedPopupMenu();
        setState(() {
          isHover = true;
        });
      },
      onExit: (event) {
        setState(() {
          isHover = false;
        });
      },
      ...

ลองรัน ดูผลลัพธ์ เมื่อเราเอา cursor เข้าไปตรงไหน ตัว Popup Menu ย่อยก็จะแสดงมาตรงนั้น

ตกแต่ง

มาทำต่อกัน ตอนนี้เราสามารถแสดง Popup Menu ย่อยได้แล้ว เรามาตกแต่งเพิ่มกัน โดยเทียบจากแบบของเรา
จะเห็นว่า Popup Menu ย่อยจะแสดงด้านขวา และจะแสดง High light ที่ Popup Menu หลักด้วย

ดังนั้น ลองปรับ logic ให้ เอา isHover = false ไว้หลัง showNestedPopupMenu แทน onExit

  @override
  Widget build(BuildContext context) {
    return MouseRegion(
      onEnter: (event) async {
        pointerOffset = event.position;
        setState(() {
          isHover = true;
        });
        await showNestedPopupMenu();
        setState(() {
          isHover = false;
        });
      },
      onExit: (event) {
        // 
      },

จะได้แบบนี้

จากนั้นมาปรับเรื่อง position กันต่อ ให้ Popup Menu แสดงด้านขวาของ Popup Menu หลัก ซึ่งตอนนี้เราใช้ position จาก cursor ดังนั้นเราต้องเปลี่ยน postion ให้ใช้เป็น position ด้านขวาของ Popup Menu หลักแทน ซึ่งเราจะต้องรู้ก่อนว่า position ของ Popup Menu หลัก อยู่ตรงไหนแล้วจึงค่อยบวกค่าเพิ่มเข้าไป

ดังนั้นเราต้องรับ position ของ Popup Menu หลักเข้ามาใน popup menu item

class MyPopupMenuItem extends PopupMenuEntry<BasePopupMenuItemData> {
  final BasePopupMenuItemData item;
  final double? itemHeight;
  final Offset? positionBase; // Add this line.

  MyPopupMenuItem({required this.item, this.itemHeight, this.positionBase});
  ...

แล้วก็ส่งค่า position เข้าไป

 onSecondaryTap: () async {
          var value = await showMenu<BasePopupMenuItemData>(
              ...
              items: [
                for (var item in listPopupMenuData)
                  MyPopupMenuItem(
                      item: item, positionBase: pointerEvent?.position)
              ]);
        },

จากนั้นใช้ position ของ Popup Menu หลัก เป็นตำแหน่ง left, right และบวกด้วย 280 โดยค่า 280 คือขนาด default ของ popup menu

  showNestedPopupMenu() async {
    var value = await showMenu<BasePopupMenuItemData>(
        context: context,
        color: Color(0xff3c3f41),
        position: RelativeRect.fromLTRB(
            (widget.positionBase?.dx ?? 0) + 280,
            pointerOffset?.dy ?? 0,
            (widget.positionBase?.dx ?? 0) + 280,
            pointerOffset?.dy ?? 0),
        items: [
          for (var item in item.items)
            MyPopupMenuItem(
              item: item,
            )
        ]);
  }

จะได้แบบนี้

ข้อจำกัด

จากที่ได้ลองเล่น Popup Menu พบว่ามีข้อจำกัดอยู่พอสมควร

  1. ไม่สามารถปรับขนาดของ popup menu ได้ จะกำหนดคงที่ไว้ที่ 280 (_kMenuMaxWidth ใน popup_menu.dart)
  2. ตัว popup menu ยังกำหนด padding ของ top-bottom ไว้ 8 อีกด้วย (_kMenuVerticalPadding ใน popup_menu.dart) ไม่สามารถปรับเองได้
  3. ไม่สามารถเพิ่ม onHover ให้กับตัว popup menu ได้ เช่น เมื่อ exit ออกจาก popup menu ให้ปิด
  4. เมื่อเปิด popup menu ซ้อน ไม่สามารถเลือก หรือ ้hover popup หลักได้

หวังว่าในอนาคต popup menu จะเพิ่ม parameter ให้สามารถปรับแต่งได้มากขึ้น ง่ายขึ้นมากกว่านี้

จบแล้ว

ลองเล่นเรื่อง Popup Menu เท่านี้ก่อนนะครับ น่าจะทำให้เห็นภาพการใช้งาน Popup menu ใน Flutter มากขึ้น หากมีอะไรเพิ่มเติมเดี๋ยวมาเขียนในตอนถัดไปนะครับ

ขอบคุณที่ติดตามอ่านจนจบครับ