Flutter - PopupMenu on long press

The OP and the First Answerer bypassed the original problem using PopupMenuButton, which worked fine in their case. But I think the more general question of how to position one's own menu and how to receive the user's response without using PopupMenuButton is worth answering, because sometimes we want a popup menu on a custom widget, and we want it to appear on some gestures other than a simple tap (e.g. the OP's original intention was to long-press).

I set out to make a simple app demonstrating the following:

  1. Use a GestureDetector to capture long-press
  2. Use the function showMenu() to display a popup menu, and position it near the finger's touch
  3. How to receive the user's selection
  4. (Bonus) How to make a PopupMenuEntry that represents multiple values (the oft-used PopupMenuItem can only represent a single value)

The result is, when you long-press on a big yellow area, a popup menu appears on which you can select +1 or -1, and the big number would increment or decrement accordingly:

Popup Menu Usage App

Skip to the end for the entire body of code. Comments are sprinkled in there to explain what I am doing. Here are a few things to note:

  1. showMenu()'s position parameter takes some effort to understand. It's a RelativeRect, which represents how a smaller rect is positioned inside a bigger rect. In our case, the bigger rect is the entire screen, the smaller rect is the area of touch. Flutter positions the popup menu according to these rules (in plain English):

    • if the smaller rect leans toward the left half of the bigger rect, the popup menu would align with the smaller rect's left edge

    • if the smaller rect leans toward the right half of the bigger rect, the popup menu would align with the smaller rect's right edge

    • if the smaller rect is in the middle, which edge wins depends on the language's text direction. Left edge wins if using English and other left-to-right languages, right edge wins otherwise.

It's always useful to reference PopupMenuButton's official implementation to see how it uses showMenu() to display the menu.

  1. showMenu() returns a Future. Use Future.then() to register a callback to handle user selection. Another option is to use await.

  2. Remember that PopupMenuEntry is a (subclass of) StatefulWidget. You can layout any number of sub-widgets inside it. This is how you represent multiple values in a PopupMenuEntry. If you want it to represent two values, just make it contain two buttons, however you want to lay them out.

  3. To close the popup menu, use Navigator.pop(). Flutter treats popup menus like a smaller "page". When we display a popup menu, we are actually pushing a "page" to the navigator's stack. To close a popup menu, we pop it from the stack, thus completing the aforementioned Future.

Here is the full code:

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Popup Menu Usage',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Popup Menu Usage'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

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

class _MyHomePageState extends State<MyHomePage> {
  var _count = 0;
  var _tapPosition;

  void _showCustomMenu() {
    final RenderBox overlay = Overlay.of(context).context.findRenderObject();

    showMenu(
      context: context,
      items: <PopupMenuEntry<int>>[PlusMinusEntry()],
      position: RelativeRect.fromRect(
          _tapPosition & const Size(40, 40), // smaller rect, the touch area
          Offset.zero & overlay.size   // Bigger rect, the entire screen
      )
    )
    // This is how you handle user selection
    .then<void>((int delta) {
      // delta would be null if user taps on outside the popup menu
      // (causing it to close without making selection)
      if (delta == null) return;

      setState(() {
        _count = _count + delta;
      });
    });

    // Another option:
    //
    // final delta = await showMenu(...);
    //
    // Then process `delta` however you want.
    // Remember to make the surrounding function `async`, that is:
    //
    // void _showCustomMenu() async { ... }
  }

  void _storePosition(TapDownDetails details) {
    _tapPosition = details.globalPosition;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            GestureDetector(
              // This does not give the tap position ...
              onLongPress: _showCustomMenu,

              // Have to remember it on tap-down.
              onTapDown: _storePosition,

              child: Container(
                color: Colors.amberAccent,
                padding: const EdgeInsets.all(100.0),
                child: Text(
                  '$_count',
                  style: const TextStyle(
                      fontSize: 100, fontWeight: FontWeight.bold),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class PlusMinusEntry extends PopupMenuEntry<int> {
  @override
  double height = 100;
  // height doesn't matter, as long as we are not giving
  // initialValue to showMenu().

  @override
  bool represents(int n) => n == 1 || n == -1;

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

class PlusMinusEntryState extends State<PlusMinusEntry> {
  void _plus1() {
    // This is how you close the popup menu and return user selection.
    Navigator.pop<int>(context, 1);
  }

  void _minus1() {
    Navigator.pop<int>(context, -1);
  }

  @override
  Widget build(BuildContext context) {
    return Row(
      children: <Widget>[
        Expanded(child: FlatButton(onPressed: _plus1, child: Text('+1'))),
        Expanded(child: FlatButton(onPressed: _minus1, child: Text('-1'))),
      ],
    );
  }
}

If you are going to use a gridView or listview for laying out the images on the screen, you can wrap each item with a gesture detector then you should keep your images in a list somewhere, then simply remove the image from the list and call setState().

Something like the following. (This code will probably won't compile but it should give you the idea)

    ListView.builder(
        itemCount: imageList.length,
        itemBuilder: (BuildContext context, int index) {
          return GestureDetector(
                onLongPress: () {
                  showMenu(
                    onSelected: () => setState(() => imageList.remove(index))}
                    items: <PopupMenuEntry>[
                      PopupMenuItem(
                        value: this._index,
                        child: Row(
                          children: <Widget>[
                            Icon(Icons.delete),
                            Text("Delete"),
                          ],
                        ),
                      )
                    ],
                    context: context,
                  );
                },
                child: imageList[index],
            );
          }
       )

Edit: You can use a popup menu too, like following

Container(
  margin: EdgeInsets.symmetric(vertical: 10),
  height: 100,
  width: 100,
  child: PopupMenuButton(
    child: FlutterLogo(),
    itemBuilder: (context) {
      return <PopupMenuItem>[new PopupMenuItem(child: Text('Delete'))];
    },
  ),
),

Tags:

Dart

Flutter