Skip to content

Instantly share code, notes, and snippets.

@rydmike
Last active June 2, 2021 13:07
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save rydmike/871121bd83f8c649069cb24f5e23720d to your computer and use it in GitHub Desktop.
Save rydmike/871121bd83f8c649069cb24f5e23720d to your computer and use it in GitHub Desktop.
A Flutter long press context menu. Wrap a child with LongPressPopupMenu and it pops up at press location with its PopupMenuItem:s
// BSD 3-Clause License
//
// Copyright (c) 2021, Mike Rydstrom (Rydmike)
// All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//
// 1. Redistributions of source code must retain the above copyright notice,
// this list of conditions and the following disclaimer.
//
// 2. Redistributions in binary form must reproduce the above copyright notice,
// this list of conditions and the following disclaimer in the documentation
// and/or other materials provided with the distribution.
//
// 3. Neither the name of the copyright holder nor the names of its
// contributors may be used to endorse or promote products derived from
// this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
// POSSIBILITY OF SUCH DAMAGE.
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
/// This is the main application widget.
class MyApp extends StatelessWidget {
/// Default const constructor.
const MyApp({Key? key}) : super(key: key);
/// title for the application, also used in home screen's app bar
static const String title = 'Long Press Menu';
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: title,
home: const HomeScreen(title: title),
theme: ThemeData.from(colorScheme: const ColorScheme.light()),
darkTheme: ThemeData.from(colorScheme: const ColorScheme.dark()),
themeMode: ThemeMode.light,
);
}
}
/// The home screen of this simple demo app.
class HomeScreen extends StatelessWidget {
/// Default const constructor.
const HomeScreen({Key? key, required this.title}) : super(key: key);
/// AppBar title of the home screen.
final String title;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: Text(title)),
body: Theme(
// Just an example of how to theme the popup menu and its icons a bit
// to make the long press context menu a bit prettier.
data: theme.copyWith(
popupMenuTheme: PopupMenuThemeData(
color: theme.cardColor.withOpacity(0.9),
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
iconTheme: const IconThemeData(size: 16),
),
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Spacer(),
Expanded(
flex: 4,
child: Container(
height: 200,
color: theme.colorScheme.primary,
child: Center(
child: Text(
'No action here',
style: theme.primaryTextTheme.headline6,
textAlign: TextAlign.center,
)),
),
),
const SizedBox(width: 8),
Expanded(
flex: 4,
child: CutCopyPasteMenu(
child: Container(
color: theme.colorScheme.secondary,
height: 200,
child: Center(
child: Text(
'Long press me',
style: theme.accentTextTheme.headline6,
textAlign: TextAlign.center,
),
),
),
),
),
const Spacer(),
],
),
),
),
);
}
}
/// A cut, copy paste menu example.
@immutable
class CutCopyPasteMenu extends StatelessWidget {
/// Default const constructor.
const CutCopyPasteMenu({Key? key, required this.child}) : super(key: key);
/// The child that gets the CutCopyPaste long press menu.
final Widget child;
@override
Widget build(BuildContext context) {
return LongPressPopupMenu<String>(
items: <PopupMenuEntry<String>>[
PopupMenuItem<String>(
value: 'cut',
height: 30,
child: SizedBox(
width: 80,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: const <Widget>[
Text('Cut'),
Icon(Icons.cut),
// Text('⌘ X'), // The ⌘ char did not work in DartPad.
],
),
),
),
PopupMenuItem<String>(
value: 'copy',
height: 30,
child: SizedBox(
width: 80,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: const <Widget>[
Text('Copy'),
Icon(Icons.copy),
// Text('⌘ C'), // The ⌘ char did not work in DartPad.
],
),
),
),
PopupMenuItem<String>(
value: 'paste',
height: 30,
child: SizedBox(
width: 80,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: const <Widget>[
Text('Paste'),
Icon(Icons.paste),
// Text('⌘ V'), // The ⌘ char did not work in DartPad.
],
),
),
),
],
onSelected: (String? value) async {
if (value != null) {
// Just wait 400ms so we can see the menu animate close after
// selection before we show a snack bar.
await Future<void>.delayed(const Duration(milliseconds: 400));
// Show a snack bar with the selected item.
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Long press menu selection ${value.toUpperCase()}'),
duration: const Duration(milliseconds: 1800),
),
);
}
},
child: child,
);
}
}
/// A simple long press popup menu.
@immutable
class LongPressPopupMenu<T> extends StatefulWidget {
/// The popup menu entries for the long press menu.
final List<PopupMenuEntry<T>> items;
/// ValueChanged callback with selected item in the long press menu.
/// Is null if menu closed without selection by clicking outside the menu.
final ValueChanged<T?> onSelected;
/// The child that can be long pressed to activate the long press menu.
final Widget child;
/// Default constructor
const LongPressPopupMenu({
Key? key,
required this.child,
required this.items,
required this.onSelected,
}) : super(key: key);
@override
State<StatefulWidget> createState() => _LongPressPopupMenuState<T>();
}
class _LongPressPopupMenuState<T> extends State<LongPressPopupMenu<T>> {
Offset _downPosition = const Offset(0, 0);
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTapDown: (TapDownDetails details) {
_downPosition = details.globalPosition;
},
onLongPress: () async {
final RenderBox? overlay =
Overlay.of(context)?.context.findRenderObject() as RenderBox?;
if (overlay != null) {
final T? value = await showMenu<T>(
context: context,
items: widget.items,
position: RelativeRect.fromLTRB(
_downPosition.dx,
_downPosition.dy,
overlay.size.width - _downPosition.dx,
overlay.size.height - _downPosition.dy,
),
);
widget.onSelected(value);
}
},
child: widget.child);
}
}
@rydmike
Copy link
Author

rydmike commented Feb 22, 2021

NOTE: This Gist requires Dart SDK >=2.12.0-0 with sound null safety!

Set Dart SDK environment to:

environment:
  sdk: '>=2.12.0-0 <3.0.0'

in your pubspec.yaml.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment