chat_context_menu 3.3.0
chat_context_menu: ^3.3.0 copied to clipboard
A Flutter package that provides chat context menu with customizable appearance and animations, perfect for chat applications.
Chat Context Menu #
A Flutter package that provides an iOS-style chat context menu with customizable appearance and animations. This package handles the positioning, arrow indicator, and background blur/dimming, allowing you to provide any widget as the menu content.
Features #
- iOS-style Context Menu: Smooth animations and layout similar to native iOS context menus.
- Automatic Positioning: The menu is automatically positioned near the target widget, with smart overflow handling.
- Arrow Indicator: An optional arrow points to the target widget.
- Customizable Appearance: Configure background color, border radius, and barrier color.
- Flexible Content: You provide the widget for the menu content, giving you full control over the items and layout.
- Easy Integration: Wrap any widget with
ChatContextMenuWrapperto enable the context menu. - Selectable Text:
ChatSelectableTextprovides fully custom text selection with draggable handles, auto-scroll, and a context menu with smart positioning — ideal for chat bubbles. Activates via long-press, double-click (word), triple-click (paragraph), right-click, or mouse drag on desktop, with vertical or horizontal menu placement (axis). - Platform-Adaptive Triggers: Configurable trigger modes for mobile (tap / double-tap / long-press) and desktop (right-click / left-click) on
ChatContextMenuWrapper. - Built-in animation styles:
ChatContextMenuAnimationStyle.scaleFade(default, existing overlay fade+scale) orcupertinoSheet(iOS sheet bounce from the arrow). Override duration withtransitionDurations/transitionDuration, or replace both withtransitionsBuilder. - Barrier anchor cutout (opt-in): Set
excludeAnchorFromBarrier: truewith a non-transparentbarrierColorto punch a hole over the anchor, keep it bright and tappable, and dismiss on shaded taps (in-route dismiss scrim). UsebarrierAnchorPadding/barrierAnchorBorderRadiusto size and round the cutout. Whenfalse(default), the barrier is a classic full sheet over the previous route.
Screenshots #
| ScreenShot | ScreenShot | ScreenShot | |
|---|---|---|
![]() |
![]() |
![]() |
Getting started #
Add chat_context_menu to your pubspec.yaml:
dependencies:
chat_context_menu: ^last_version
Usage #
Wrap the widget you want to trigger the menu (usually a chat bubble) with ChatContextMenuWrapper.
import 'package:chat_context_menu/chat_context_menu.dart';
import 'package:example/app_theme.dart';
import 'package:example/context_menu_pane.dart';
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Chat Context Menu',
theme: AppTheme.light,
darkTheme: AppTheme.dark,
themeMode: .system,
debugShowCheckedModeBanner: false,
home: const ChatScreen(),
);
}
}
class ChatScreen extends StatefulWidget {
const ChatScreen({super.key});
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
final List<String> _messages = [
"Hello!",
"Hello!",
"How are you?",
"Im Fine",
"and you?",
"Im good too, thanks for asking.",
"This is a long press context menu demo.",
"Try long pressing on any message.",
"Try long pressing on any message.",
"You can see different options.",
"You can see different options.",
"Like Reply, Copy, Forward, Delete.",
"It mimics the iOS style context menu.",
"Hope",
"It mimics the iOS style context menu.",
"Hope",
"Like Reply, Copy, Forward, Delete.",
];
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final TextTheme textTheme = theme.textTheme;
final ColorScheme colorScheme = theme.colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('Chat Context Menu')),
body: Column(
children: [
Expanded(
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _messages.length,
itemBuilder: (context, index) {
final isMe = index % 2 == 0;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Align(
alignment: isMe
? Alignment.centerRight
: Alignment.centerLeft,
child: ChatContextMenuWrapper(
barrierColor: Colors.transparent,
backgroundColor: colorScheme.surface,
borderRadius: BorderRadius.circular(10),
shadows: [
BoxShadow(
color: colorScheme.onSurface.withValues(alpha: 0.15),
blurRadius: 32,
),
],
menuBuilder: (context, hideMenu) {
return ContextMenuPane(
textTheme: textTheme,
colorScheme: colorScheme,
onReplayTap: hideMenu,
onForwardTap: hideMenu,
onCopyTap: hideMenu,
onDeleteTap: hideMenu,
onMoreTap: hideMenu,
onQuoteTap: hideMenu,
onSelectTap: hideMenu,
);
},
widgetBuilder: (context, showMenu, hideMenu) {
return GestureDetector(
onLongPress: showMenu,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
margin: .symmetric(vertical: 4),
decoration: BoxDecoration(
color: isMe
? colorScheme.primary
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_messages[index],
style: TextStyle(
fontSize: 16,
color: isMe ? colorScheme.onPrimary : null,
),
),
),
);
},
),
),
);
},
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
decoration: const InputDecoration(
hintText: 'Type a message...',
border: OutlineInputBorder(),
),
),
),
],
),
bottomNavigationBar: BottomNavigationBar(
items: const [
BottomNavigationBarItem(icon: Icon(Icons.chat), label: 'Chat'),
BottomNavigationBarItem(
icon: Icon(Icons.settings),
label: 'Settings',
),
],
),
);
}
}
Customization #
You can customize the ChatContextMenuWrapper with the following properties:
widgetBuilder: A builder function for the anchor widget. It providesshowMenuandhideMenucallbacks (hideMenuis a no-op when the menu is not open).menuBuilder: A builder function that returns the widget to display in the menu. It provides ahideMenucallback.barrierColor: Color of the background overlay.excludeAnchorFromBarrier: Whentrue, the overlay leaves a cutout over the anchor (see Usage notes). Default isfalse(standard modal dimming over the anchor as well).barrierAnchorPadding:EdgeInsetsapplied around the measured anchor rect to expand or shrink the barrier cutout.barrierAnchorBorderRadius: Optional bubble corner radius for both the punched hole outline and dismiss hit-testing (typically match your bubbleBorderRadius.circular(...)).backgroundColor: Background color of the menu container.borderRadius: Border radius of the menu container.padding: Padding inside the menu container.animationStyle: Built-in menu animation. DefaultChatContextMenuAnimationStyle.scaleFade(150ms overlay fade+scale). UsecupertinoSheetfor the iOS action-sheet bounce (335ms, scale from the arrow). A non-nulltransitionsBuilderoverrides both.transitionDurations: Optional duration override. Whennull, uses the duration ofanimationStyle.barrierDismissible: Whentrue(default), tapping the dimmed area closes the menu; whenfalse, taps play the system alert sound and the menu stays open (aligned with Flutter’sModalBarrierbehavior).useRootNavigator: Whentrue, the menu route is pushed onto the rootNavigatorinstead of the nearest one. Default isfalse. See “Nested Navigators / desktop multi-pane layouts” below.
Usage notes #
Barrier with anchor cutout (recommended for chat bubbles)
- Set a non-transparent
barrierColorandexcludeAnchorFromBarrier: true. The overlay draws a hole over the wrapped anchor and uses an in-route dismiss layer so taps on the shaded area callNavigator.maybePopreliably (the hole geometry is shared by painting and hit-testing). - Set
barrierAnchorBorderRadiusto match your bubble’sBoxDecoration.borderRadiusso the cutout outline matches the bubble; if they differ, corners look misaligned. - Use
barrierAnchorPaddingto slightly inflate or shrink the cutout if you need extra breathing room around the widget.
Long or scrollable content
- This applies when
excludeAnchorFromBarrieris true: a tall message in aListViewstill lays out at full content height; the package clips the anchor rect to the nearest scroll viewport (RenderAbstractViewport) and theMediaQuerywindow before opening the route, so the hole does not “brighten” the input bar or other UI below the list. If you use unusual clipping (nested scrollables, custom viewports), verify the result in your layout.
Default without cutout
- With
excludeAnchorFromBarrier: false(default), you get Flutter’s usual modal barrier: the anchor is dimmed like the rest of the screen (no hole, no in-route dismiss scrim for that mode).
Menu wrapper scope
ChatContextMenuWrappermeasures the outerListeneraroundwidgetBuilder. The trigger widget should be the same subtree you want to treat as the anchor; if you use a cutout, avoid wrapping an unnecessarily large parent or the hole will be oversized.
Nested Navigators / desktop multi-pane layouts
- Anchor coordinates are always measured in the target Navigator's Overlay coordinate space, so the menu positions correctly even when the anchor sits inside a nested
Navigatorthat is offset byTransforms or padded shells. With a single full-window Navigator (typical mobile apps) this equals window coordinates — no behavior change. - If the nearest Navigator's overlay is clipped (e.g. a desktop split-view where each pane hosts its own Navigator inside
ClipRect/ a clippedMaterialcard), passuseRootNavigator: trueso the menu and barrier render in the root overlay, cover the whole window, and are not cut off at the pane border. ForChatSelectableText, useuseRootOverlay: truefor the same effect. - With
excludeAnchorFromBarrier: true, the cutout is additionally clamped to every clipping ancestor between the anchor and the overlay (scroll viewports,ClipRect/ClipRRect, clippedMaterial), so the hole never extends past the visible pane area. - Triggering the menu while a pane-slide animation is running snapshots the anchor position at that moment; the menu will not follow the animation but stays consistent and dismissable.
ChatSelectableText #
A fully custom selectable text widget built from the ground up. Users can long-press to activate selection, adjust the range with draggable handles, and perform operations on the selected text via a context menu. On desktop, selection also works with the mouse: drag with the left button to select, right-click to open the menu, double-click to select a word, and triple-click to select a paragraph.
Basic Usage #
ChatSelectableText(
'Long press to select text and see the context menu.',
style: TextStyle(fontSize: 16),
menuBackgroundColor: Colors.white,
menuShadows: [
BoxShadow(color: Colors.black12, blurRadius: 32),
],
menuBuilder: (context, selectedText, hideMenu, selectAll) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(Icons.copy),
onPressed: () {
Clipboard.setData(ClipboardData(text: selectedText));
hideMenu();
},
),
IconButton(
icon: Icon(Icons.select_all),
onPressed: selectAll,
),
],
);
},
)
Custom Selection Colors #
ChatSelectableText(
'Custom selection and handle colors.',
style: TextStyle(fontSize: 16),
selectionColor: Colors.orange.withValues(alpha: 0.35),
handleColor: Colors.deepOrange,
menuBackgroundColor: Colors.white,
menuBuilder: (context, selectedText, hideMenu, selectAll) {
return Text('Selected: $selectedText');
},
)
In Chat Bubbles #
ChatSelectableText(
message.text,
style: TextStyle(
fontSize: 16,
color: isMe ? Colors.white : Colors.black,
),
selectionColor: isMe
? Colors.white.withValues(alpha: 0.3)
: Colors.blue.withValues(alpha: 0.3),
handleColor: isMe ? Colors.white : Colors.blue,
menuBackgroundColor: Colors.white,
menuShadows: [
BoxShadow(color: Colors.black12, blurRadius: 32),
],
menuBuilder: (context, selectedText, hideMenu, selectAll) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(onPressed: () { hideMenu(); }, child: Text('Copy')),
TextButton(onPressed: selectAll, child: Text('Select All')),
],
);
},
onSelectionChanged: (text) {
debugPrint('Selected: $text');
},
)
Word Selection Mode #
// Select only the tapped word instead of all text
ChatSelectableText(
'Long press a word to select just that word.',
selectAllOnActivate: false,
menuBuilder: (context, selectedText, hideMenu, selectAll) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(onPressed: () { hideMenu(); }, child: Text('Copy')),
TextButton(onPressed: selectAll, child: Text('Select All')),
],
);
},
)
Desktop Interactions & Menu Axis #
On desktop platforms ChatSelectableText supports mouse-first workflows out of the box:
- Mouse drag — press the left button and drag to select; the menu appears on release. Touch drags are unaffected and still scroll the surrounding list.
- Right-click — activates selection (respects
selectAllOnActivate) and opens the menu at the pointer. - Double-click / triple-click — selects the word / the whole paragraph under the pointer.
Use axis to place the menu beside the selection instead of above/below it:
ChatSelectableText(
'Drag, right-click, double-click or triple-click me.',
axis: Axis.horizontal, // menu appears to the left/right of the selection
menuBuilder: (context, selectedText, hideMenu, selectAll) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(onPressed: () { hideMenu(); }, child: Text('Copy')),
TextButton(onPressed: selectAll, child: Text('Select All')),
],
);
},
)
ChatSelectableText Properties #
| Property | Type | Default | Description |
|---|---|---|---|
data |
String |
required | Text content |
style |
TextStyle? |
null |
Text style |
selectionColor |
Color? |
theme primary (30% alpha) | Selection highlight color |
handleColor |
Color? |
theme primary | Drag handle color |
handleSize |
double |
16.0 |
Handle widget size |
selectAllOnActivate |
bool |
true |
Select all text on activation, or just tapped word |
autoScrollEdgeExtent |
double |
48.0 |
Distance from edge to trigger auto-scroll |
autoScrollSpeed |
double |
10.0 |
Auto-scroll speed in pixels per frame |
enableHapticFeedback |
bool |
true |
Haptic feedback on selection activation |
menuBuilder |
Widget Function(BuildContext, String, VoidCallback, VoidCallback) |
required | Menu content builder (context, text, hide, selectAll) |
menuBackgroundColor |
Color? |
null |
Menu background color |
menuBorderRadius |
BorderRadius |
BorderRadius.circular(8) |
Menu corner radius |
menuPadding |
EdgeInsets |
EdgeInsets.all(8) |
Menu internal padding |
menuShadows |
List<BoxShadow>? |
null |
Menu shadow |
arrowHeight |
double |
8.0 |
Arrow indicator height |
arrowWidth |
double |
12.0 |
Arrow indicator width |
spacing |
double |
6.0 |
Space between menu and selection |
horizontalMargin |
double |
10.0 |
Min margin from screen edges |
axis |
Axis |
Axis.vertical |
Menu placement: above/below (vertical) or beside (horizontal) the selection |
useRootOverlay |
bool |
false |
Insert handles/menu/barrier into the root Overlay (for nested Navigators, see notes above) |
onSelectionChanged |
ValueChanged<String>? |
null |
Selection change callback |
onMenuClosed |
VoidCallback? |
null |
Menu closed callback |
animationStyle |
ChatContextMenuAnimationStyle |
scaleFade |
Built-in menu animation (scaleFade or cupertinoSheet) |
transitionsBuilder |
Function? |
null |
Custom menu animation (overrides animationStyle) |
transitionDuration |
Duration? |
style default | Menu animation duration (scaleFade 150ms, cupertinoSheet 335ms) |
Additional information #
For more details, check the example folder in the repository.



