Files
FlClash/lib/ui/context_menu.dart
T
chen08209 7c9627fffb feat(ui): add Liquid Glass and slide-to-pick popup menus
Changelog: Optional two-tier Liquid Glass and slide-to-pick menus
2026-10-17 17:21:43 +08:00

216 lines
5.8 KiB
Dart

import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:material_ui/material_ui.dart';
import 'focus.dart';
import 'popup.dart';
import 'sortable.dart';
import 'surface.dart';
const _touchDevices = {
PointerDeviceKind.touch,
PointerDeviceKind.stylus,
PointerDeviceKind.invertedStylus,
};
/// A touch long press, the menu key and, on a remote, a held select key keep
/// the menu clear of the row, which a finger or the focus highlight would
/// otherwise leave covered.
class ContextMenuRegion extends StatefulWidget {
final List<CommonPopupMenuItem>? menuItems;
final Widget child;
const ContextMenuRegion({super.key, this.menuItems, required this.child});
@override
State<ContextMenuRegion> createState() => _ContextMenuRegionState();
}
class _ContextMenuRegionState extends State<ContextMenuRegion> {
Timer? _holdTimer;
LogicalKeyboardKey? _heldKey;
CommonPopupRoute<void>? _menu;
bool get _enabled {
final items = widget.menuItems;
return items != null &&
items.isNotEmpty &&
!SortableItem.isSorting(context);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_closeUnlessEnabled();
}
@override
void didUpdateWidget(ContextMenuRegion oldWidget) {
super.didUpdateWidget(oldWidget);
_closeUnlessEnabled();
}
@override
void dispose() {
_holdTimer?.cancel();
_closeMenu();
super.dispose();
}
/// A row withdraws its menu while its item updates, which the open menu
/// would otherwise still act on.
void _closeUnlessEnabled() {
if (_enabled) {
return;
}
_holdTimer?.cancel();
_heldKey = null;
_closeMenu();
}
void _closeMenu() {
final menu = _menu;
if (menu == null) {
return;
}
_menu = null;
// This runs while the tree builds or unmounts, when nothing may pop.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (menu.isCurrent) {
menu.navigator!.pop();
}
});
}
void _open(Offset globalPosition, {bool avoid = false}) {
final items = widget.menuItems;
final navigator = Navigator.maybeOf(context);
final navigatorBox = navigator?.context.findRenderObject();
final box = context.findRenderObject();
if (items == null ||
items.isEmpty ||
navigator == null ||
navigatorBox is! RenderBox ||
box is! RenderBox ||
!box.hasSize) {
return;
}
final point = navigatorBox.globalToLocal(globalPosition);
final rowRect = MatrixUtils.transformRect(
box.getTransformTo(navigatorBox),
Offset.zero & box.size,
);
final menu = _menu = CommonPopupRoute<void>(
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
placement: PopupPlacement.belowPoint,
elevated: ElevatedSurfaceTheme.isIn(context),
anchorOf: () => point & Size.zero,
avoid: avoid ? rowRect : null,
builder: (_) => CommonPopupMenu(items: items),
);
unawaited(
navigator.push(menu).whenComplete(() {
if (identical(_menu, menu)) {
_menu = null;
}
}),
);
}
void _openAtRow() {
final box = context.findRenderObject();
if (box is! RenderBox || !box.hasSize) {
return;
}
_open(box.localToGlobal(box.size.bottomLeft(Offset.zero)), avoid: true);
}
// Swallows the rest of the hold, which would otherwise repeat into the menu
// as presses of its first item.
void _handleHold() {
final key = _heldKey;
_heldKey = null;
KeyEventResult swallow(KeyEvent event) {
if (event.logicalKey != key) {
return KeyEventResult.ignored;
}
if (event is KeyUpEvent) {
FocusManager.instance.removeEarlyKeyEventHandler(swallow);
}
return KeyEventResult.handled;
}
FocusManager.instance.addEarlyKeyEventHandler(swallow);
_openAtRow();
}
KeyEventResult _handleRemoteSelect(KeyEvent event) {
final key = event.logicalKey;
switch (event) {
case KeyDownEvent():
_heldKey = key;
_holdTimer?.cancel();
_holdTimer = Timer(kLongPressTimeout, _handleHold);
case KeyRepeatEvent():
if (key != _heldKey) {
return KeyEventResult.ignored;
}
case KeyUpEvent():
if (key != _heldKey) {
return KeyEventResult.ignored;
}
_heldKey = null;
_holdTimer?.cancel();
final focused = FocusManager.instance.primaryFocus?.context;
if (focused != null) {
Actions.maybeInvoke(focused, const ActivateIntent());
}
}
return KeyEventResult.handled;
}
KeyEventResult _handleKey(KeyEvent event) {
final key = event.logicalKey;
if (key == LogicalKeyboardKey.contextMenu ||
(key == LogicalKeyboardKey.f10 &&
HardwareKeyboard.instance.isShiftPressed)) {
if (event is KeyDownEvent) {
_openAtRow();
}
return KeyEventResult.handled;
}
if (remoteSelectKeys.contains(key) &&
RemoteFocusAdapter.isEnabledOf(context)) {
return _handleRemoteSelect(event);
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
final enabled = _enabled;
return Focus(
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: enabled ? (_, event) => _handleKey(event) : null,
child: GestureDetector(
onSecondaryTapUp: enabled
? (details) => _open(details.globalPosition)
: null,
child: GestureDetector(
supportedDevices: _touchDevices,
onLongPressStart: enabled
? (details) {
Feedback.forLongPress(context);
_open(details.globalPosition, avoid: true);
}
: null,
child: widget.child,
),
),
);
}
}