web analytics

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

ความเดิมตอนที่แล้ว เราได้ลองเล่นการใช้งาน Popup Menu แบบพื้นฐานกันไปแล้ว ได้รู้จักกับ PopupMenuItem และ Item แบบพื้นฐาน ในตอนที่ 2 เราจะลองทำ Popup Menu แบบ custom เองกันครับ

ใครยังไม่ได้อ่านตอนที่ 1 อ่านได้ที่ลิงค์ข้างล่างนี้เลย

โดยตัวอย่าง Popup Menu ที่ผมจะใช้เป็นแบบ คือ Popup Menu แบบ Dark Theme จากใน Android Studio ครับ
จะเป็นแบบในรูปด้านล่างนี้ (อาจจะทำไม่ได้แบบเป๊ะๆนะ)

Item Data

ก่อนที่เราจะทำ Popup Menu Item ของเราเอง เราจะต้องมี class สำหรับเก็บข้อมูล item นั้นๆของเราก่อน ซึ่งจากในแบบที่เตรียมไว้ เราจะต้องทำ item 2 แบบ คือ แบบที่มีไอคอนและชื่อ กับแบบที่แสดงเส้นคั้น

โดยเริ่มจากสร้าง class สำหรับเป็น base class ที่ทุก class จะใช้เหมือนกัน ผมใช้ชื่อว่า BasePopupMenuItemData
ซึ่งทุก item จะมีเหมือนกันคือ id

abstract class BasePopupMenuItemData {
  int id;

  BasePopupMenuItemData(this.id);
}

ต่อมา item หลัก ผมตั้งชื่อว่า OptionPopupMenuItemData
โดยจะเก็บค่า icon, ชื่อ , shortcut

class OptionPopupMenuItemData extends BasePopupMenuItemData {
  IconData? icon;
  String title;
  String? shortcut;

  OptionPopupMenuItemData(
      {required int id, this.icon, required this.title, this.shortcut})
      : super(id);
}

ส่วน item ที่แสดงเส้นคั้น ตั้งชื่อว่า DividerPopupMenuItemData จะเก็บสีกับขนาดความหนาของเส้น

class DividerPopupMenuItemData extends BasePopupMenuItemData {
  Color? color;
  double? height;

  DividerPopupMenuItemData({this.color, this.height}) : super(0);
}

จากนั้นทำข้อมูล Popup menu ที่ต้องการ ไว้ใน List

 List<MyPopupMenuItemData> listPopupMenuData = [
    OptionPopupMenuItemData(
        icon: Icons.lightbulb,
        title: "Show Context Action",
        shortcut: "Alt+Enter"),
    DividerPopupMenuItemData(),
    OptionPopupMenuItemData(title: "Copy Reference", shortcut: "Ctrl+Alt+Shift+C"),
    OptionPopupMenuItemData(icon: Icons.paste, title: "Paste", shortcut: "Ctrl+V"),
    OptionPopupMenuItemData(title: "Paste from History...", shortcut: "Ctrl+Shift+V"),
   OptionPopupMenuItemData(title: "Paste without Formatting", shortcut: "Ctrl+Alt+Shift+V"),
   OptionPopupMenuItemData(title: "Column Selection Mode", shortcut: "Ctrl+Shift+Insert"),
  ];

เพิ่ม Gesture

ในตัวอย่างนี้ ผมจะแสดง popup menu โดยคลิกขวาตรงไหนก็ได้ในหน้าจอ
ดังนั้นเราจะใช้เทคนิคเดียวกับในตอนที่แล้วคือใช้ การเก็บค่า Pointer จาก onHover ของ MouseRegion

class _MyAppState extends State<MyApp> {
  PointerHoverEvent? pointerEvent;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: GestureDetector(
        onSecondaryTap: () async{
         //
        },
        child: MouseRegion(
          onHover: (event) {
            pointerEvent = event;
          },
          child: Container(),
        ),
      ),
    );
  }
}

จากนั้นที่ onSecondaryTap เราก็เรียกคำสั่ง showMenu<T> โดยกำหนด Generic Type เป็น BasePopupMenuItemData นั่นเอง ซึ่งจะเหลือ parameter items ที่เรายังไม่ได้ทำ

   ...
   GestureDetector(
        onSecondaryTap: () async{
          var value = await showMenu<BasePopupMenuItemData>(
              context: context,
              position: RelativeRect.fromLTRB(
                  pointerEvent?.position.dx ?? 0,
                  pointerEvent?.position.dy ?? 0,
                  pointerEvent?.position.dx ?? 0,
                  pointerEvent?.position.dy ?? 0),
              items: [
                //
              ]);
        },

Custom Popup Menu Item

มาถึงขั้นตอนสำคัญ คือการทำ Popup Menu Item ของเราเอง วิธีการคือ extends PopupMenuEntry<T> และกำหนด Generic type เป็น BasePopupMenuItemData โดยเราจะรับ Item data และ item height เข้ามา

ซึ่งจะมี method ที่ต้อง implement 3 ตัว คือ
createState จะ return State ของ Widget ตาม Item Data ที่ส่งมา
height คือ ความสูงของ item
represents คือ จะ return boolean ว่า item นี้คืออันที่ถูกเลือกใช่ไหม จะใช้ใน mode ที่ popup menu ของเราเป็นแบบ selectable

import 'package:flutter/material.dart';
import 'my_popup_menu_item_data.dart';

class MyPopupMenuItem extends PopupMenuEntry<BasePopupMenuItemData> {
  final BasePopupMenuItemData item;
  final double? itemHeight;

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

  @override
  State<StatefulWidget> createState() {
    //
  }

  @override
  double get height {
    //
  }

  @override
  bool represents(BasePopupMenuItemData? valueSelected) {
    //
  }
}

แต่ว่าในตอนนี้เรายังไม่มี class สำหรับ State ของ Item เลย ดังนั้นจะว่างไว้ก่อน
กำหนด height เป็นค่าความสูงเป็นค่าที่รับมา โดยมีค่า 32 เป็น default ส่วน represent จะ return false เพราะไม่ได้ใช้ popup menu แบบ selectable

class MyPopupMenuItem extends PopupMenuEntry<BasePopupMenuItemData> {
  ...

  @override
  State<StatefulWidget> createState() {
    //
  }

  @override
  double get height {
    return itemHeight ?? 32;
  }

  @override
  bool represents(BasePopupMenuItemData? valueSelected) {
    return false;
  }
}

มาทำ class สำหรับ State ของเรากัน เริ่มจาก item หลักของเรา ผมตั้งชื่อว่า OptionPopupMenuItemWidgetState จะเป็น State สำหรับ build Item แบบปกติ ดังนั้นที่ build() จะ มี widget ที่มีไอคอนอยู่ด้านซ้าย มีชื่อ แล้วก็จะแสดง shortcut

class OptionPopupMenuItemWidgetState extends State<MyPopupMenuItem> {
  OptionPopupMenuItemData item;

  OptionPopupMenuItemWidgetState({required this.item});

  @override
  Widget build(BuildContext context) {
    return Container(
      height: widget.height,
      padding: EdgeInsets.all(4),
      child: Row(
        children: [
          if (item.icon != null)
            Icon(
              item.icon,
              size: 24,
            )
          else
            SizedBox(
              width: 24,
            ),
          SizedBox(width: 4),
          Expanded(
              child: Text(
            item.title,
            style: TextStyle(fontSize: 12),
          )),
          Text(
            item.shortcut ?? "",
            style: TextStyle(fontSize: 12),
          ),
          SizedBox(width: 8),
        ],
      ),
    );
  }
}

ส่วน State สำหรับ Item ที่จะแสดงเส้นคั้น ก็ง่ายมาก แค่แสดง Container แสดงเป็นเส้น

class DividerPopupMenuItemWidgetState extends State<MyPopupMenuItem> {

  DividerPopupMenuItemData item;

  DividerPopupMenuItemWidgetState({required this.item});

  @override
  Widget build(BuildContext context) {
    return Container(
      height: item.height ?? 1,
      color: item.color ?? Color(0xff515151),
    );
  }
}

พอได้ class สำหรับ State แล้ว ก็กลับไป implement method createState() ที่ค้างไว้ โดยเช็คจาก Type ของ item แล้ว return State ที่ถูกต้อง

  @override
  State<StatefulWidget> createState() {
    if (item is OptionPopupMenuItemData) {
      return OptionPopupMenuItemWidgetState(
          item: item as OptionPopupMenuItemData);
    }
    if (item is DividerPopupMenuItemData) {
      return DividerPopupMenuItemWidgetState(
          item: item as DividerPopupMenuItemData);
    }

    throw Exception();
  }

จากนั้นเรากลับไปที่ items ของ showMenu() ให้กำหนด Custom Popup Menu Item ของเราลงไป

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

ลองรันดูผลลัพธ์ ตอนนี้เราได้ Custom Popup Menu ของเราแล้ว แต่ว่าเมื่อนำ cursor ไปชี้ ยังไม่มี hover

เพิ่ม Hover

ต่อมาคือ การเพิ่ม hover ให้กับ popup menu ของเราครับ วิธีการคือใช้เทคนิคเดิมจากตอนที่แล้วเลย คือ ใช้ MouseRegion เพื่อดัก event ที่ onEnter, onExit

class OptionPopupMenuItemWidgetStateextends State<MyPopupMenuItem> {
  OptionPopupMenuItemData item;
  bool isHover = false;

  OptionPopupMenuItemWidgetState({required this.item});

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

จะได้ hover ง่ายๆแบบนี้เลย

ตกแต่ง

ไหนๆเราก็ใช้ตัวอย่าง จาก popup menu จากใน Android Studio แล้ว ผมเลยอยากจะตกแต่งให้สีมันคล้ายที่สุด
วิธีการใส่สีพื้นหลังให้กับ popup menu ง่ายมากๆ เพราะมี parameter ที่ชื่อว่า color ใน showMenu

        MyPopupMenuItemData? value = await showMenu<MyPopupMenuItemData>(
              context: context,
              color: Color(0xff3c3f41),
              ...

ที่เหลือก็ไปตกแต่งใน State ตอน build Widget ของเรานั่นเอง ในตัวอย่างนี้ผมใช้สีดังนี้

#3C3F41 พื้นหลัง Popup Menu
#4B6EAF พื้นหลัง item ขณะ Hover
#3C3F41 พื้นหลัง item
#515151 เส้นคั้น

ลองดูผลลัพธ์

Return ค่ากลับ

เมื่อเราลองเลือกเมนูที่ต้องการ จะพบว่ากดเลือกอะไรไม่ได้เลย นั่นก็เพราะเรายังไม่ได้ return ค่าอะไรกลับไปนั่นเอง

วิธีการคือ เพิ่ม Navigator.pop(context, item ) ให้กับ onTap ของตัว item

class OptionPopupMenuItemWidgetState extends State<MyPopupMenuItem> {
  ....

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: (){
        Navigator.pop<BasePopupMenuItemData>(context, item);
      },
      ...

จากนั้น item จะ return กลับไปที่ showMenu() นั่นเอง

          var value = await showMenu<BasePopupMenuItemData>(
              context: context,
              ...

ตัวอย่างการแสดงค่าที่ได้จากการ return ของ showMenu

ตอนถัดไป

ยังไม่จบเท่านี้นะ ในตอนถัดไป ผมจะพาไปลองทำ Nested Popup Menu หรือ sub-popup menu แบบซ้อนย่อยๆลงไป ติดตามอ่านได้ในตอนถัดไปครับ

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