在 flutter 中实现循环上下文菜单
Implementing circular context menu in flutter
我已经搜索了 flutter 的圆形菜单包,它们都有径向排列的晶圆厂。我想要 UI 这样,无论用户在哪里点击(按住),都会弹出一个圆形菜单,其中的按钮按扇区划分。
对于如今的应用程序,径向弹出更多按钮 是用户可以执行的额外操作的常见 UX 做法。
然而,这对于向用户呈现多个操作很有用,但在您希望用户执行主要操作的情况下没用 .
不过,如果您的情况符合需要,请务必查看来自 Jeff @firehsip.io
的 this tutorial
这是此类径向上下文菜单的快速游乐场。
它使用状态管理、提供者、覆盖的概念...
我试图将其控制在 300 行代码以下。
项目结构:
领域层
出于本演示的目的,我使用了由它们的 id
、name
和 color
定义的 Boxes
。
径向菜单由 MenuConfig
和 MenuActions
定义
状态管理层
对于盒子,我使用 boxesProvider
提供盒子列表和更新它们的可能性 color
。
对于菜单,我将 menuProvider
定义为 StateNotifierProvider.family
以及应用我的径向菜单的当前项目的 ScopedProvider。
表示层
Widget Tree的结构如下:
Providercope
> MaterialApp
> HomePage
> Scaffold
> GridView
> BoxWidget
> ProviderScope [contextIdProvider.overrideWithValue(box.id)]
> ContextMenuDetector
ContextMenuDetector
是一个 GestureDetector
,它在 OverlayEntry
中创建 RadialMenu
并管理菜单用户体验,这要归功于 olLongPressStart
、olLongPressMoveUpdate
,以及 olLongPressUp
我让你发现完整的源代码:
完整源代码:
import 'dart:math' show pi, cos, sin;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
part '66483259.radial_menu.freezed.dart';
void main() {
runApp(ProviderScope(
child: MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Radial Menu Demo',
home: HomePage(),
)));
}
class HomePage extends HookWidget {
@override
Widget build(BuildContext context) {
final boxes = useProvider(boxesProvider.state);
return Scaffold(
appBar: AppBar(title: Text('Radial Menu Demo')),
body: GridView.count(
crossAxisCount: 3,
children: boxes.map((box) => BoxWidget(box: box)).toList(),
),
);
}
}
class BoxWidget extends StatelessWidget {
final Box box;
const BoxWidget({Key key, this.box}) : super(key: key);
@override
Widget build(BuildContext context) {
return ProviderScope(
overrides: [contextIdProvider.overrideWithValue(box.id)],
child: ContextMenuDetector(
child: Container(
decoration: BoxDecoration(
color: box.color,
border: Border.all(color: Colors.black87, width: 2.0),
),
child: Text(box.name),
),
),
);
}
}
class ContextMenuDetector extends HookWidget {
final Widget child;
const ContextMenuDetector({Key key, this.child}) : super(key: key);
@override
Widget build(BuildContext context) {
final contextId = useProvider(contextIdProvider);
final _menuConfig = useProvider(menuProvider(contextId).state);
final _offsetCorrection = useState(Offset.zero);
final _overlayEntry = useState<OverlayEntry>(null);
OverlayEntry _createMenu({Offset offset}) => OverlayEntry(
builder: (BuildContext overlayContext) {
return ProviderScope(
overrides: [contextIdProvider.overrideWithValue(contextId)],
child: Positioned(
left: offset.dx - kMenuRadius,
top: offset.dy - kMenuRadius,
child: RadialMenu(),
),
);
},
);
void _showMenu({Offset offset}) {
_overlayEntry.value = _createMenu(offset: offset);
Overlay.of(context).insert(_overlayEntry.value);
}
void _hideMenu() {
_overlayEntry.value?.remove();
}
return GestureDetector(
onLongPressStart: (details) {
final menuOffset = Offset(
details.globalPosition.dx.clamp(
kMenuRadius, MediaQuery.of(context).size.width - kMenuRadius),
details.globalPosition.dy.clamp(
kMenuRadius, MediaQuery.of(context).size.height - kMenuRadius),
);
_offsetCorrection.value = details.globalPosition - menuOffset;
_showMenu(offset: menuOffset);
},
onLongPressMoveUpdate: (details) {
final offset = details.localOffsetFromOrigin + _offsetCorrection.value;
if (offset.distance <= kMenuRadius) {
final sextant =
(((offset.direction / pi + 2 + 1 / 6) % 2) * 3).floor();
if (sextant < 4) {
context.read(menuProvider(contextId)).selectAction(sextant);
return;
}
}
context.read(menuProvider(contextId)).selectAction(-1);
},
onLongPressUp: () {
if (_menuConfig.selectedAction >= 0) {
_menuConfig.currentAction?.callback?.call(context, contextId);
}
_hideMenu();
},
child: child,
);
}
}
class RadialMenu extends HookWidget {
@override
Widget build(BuildContext context) {
final contextId = useProvider(contextIdProvider);
final config = useProvider(menuProvider(contextId).state);
return Material(
color: Colors.transparent,
child: Container(
width: 2 * kMenuRadius,
height: 2 * kMenuRadius,
decoration: BoxDecoration(
color: Colors.grey.shade200,
shape: BoxShape.circle,
),
child: CustomPaint(
painter: RadialMenuPainter(config: config),
size: Size(2 * kMenuRadius, 2 * kMenuRadius),
child: Stack(
children: [
Positioned(
top: .4 * kMenuRadius,
left: .5 * kMenuRadius,
right: .5 * kMenuRadius,
child: Text('Menu $contextId', textAlign: TextAlign.center),
),
...config.actions.asMap().entries.map(
(action) {
final angle = pi * action.key / 3;
return Positioned(
left: kMenuRadius * (.6 + .5 * cos(angle)),
top: kMenuRadius * (.6 + .5 * sin(angle)),
child: SizedBox(
width: .8 * kMenuRadius,
height: .8 * kMenuRadius,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(action.value.label,
style: TextStyle(fontSize: 12.0)),
Icon(action.value.iconData, size: 16.0),
],
),
),
);
},
),
],
),
),
),
);
}
}
class RadialMenuPainter extends CustomPainter {
final MenuConfig config;
RadialMenuPainter({this.config});
@override
void paint(Canvas canvas, Size size) {
Paint paint = Paint()
..color = config.color
..style = PaintingStyle.stroke
..strokeWidth = size.shortestSide * .1;
canvas.drawCircle(
Offset(size.width / 2, size.height / 2), size.shortestSide / 2, paint);
config.actions.asMap().entries.forEach((action) {
Paint paint = Paint()
..color = action.key == config.selectedAction
? action.value.color
: action.value.color.withOpacity(.4)
..style = PaintingStyle.fill;
Path path = Path()
..moveTo(size.width / 2, size.height / 2)
..arcTo(
Rect.fromLTWH(size.width * .1, size.height * .1, size.width * .8,
size.height * .8),
pi * (action.key / 3 - 1 / 6),
pi / 3,
false)
..close();
canvas.drawPath(path, paint);
});
}
@override
bool shouldRepaint(covariant RadialMenuPainter oldDelegate) =>
oldDelegate.config.selectedAction != config.selectedAction;
}
final boxesProvider =
StateNotifierProvider<BoxesNotifier>((ref) => BoxesNotifier(24));
class BoxesNotifier extends StateNotifier<List<Box>> {
BoxesNotifier(int nbBoxes)
: super(
List.generate(
nbBoxes,
(index) => Box(id: index, name: 'Box $index', color: Colors.white),
),
);
updateBoxColor(int id, Color color) {
state = [...state]..[id] = state[id].copyWith(color: color);
}
}
@freezed
abstract class Box with _$Box {
const factory Box({int id, String name, Color color}) = _Box;
}
final contextIdProvider =
ScopedProvider<int>((ref) => throw UnimplementedError());
final menuProvider = StateNotifierProvider.family<MenuNotifier, int>(
(ref, id) => MenuNotifier(menuConfig));
class MenuNotifier extends StateNotifier<MenuConfig> {
MenuNotifier(MenuConfig state) : super(state);
void selectAction(int index) {
state = state.copyWith(selectedAction: index);
}
}
@freezed
abstract class MenuConfig implements _$MenuConfig {
const factory MenuConfig({
@Default(Colors.white) Color color,
@Default([]) List<MenuAction> actions,
int selectedAction,
}) = _RadialMenuConfig;
const MenuConfig._();
MenuAction get currentAction => actions[selectedAction];
}
@freezed
abstract class MenuAction with _$MenuAction {
const factory MenuAction(
{String label,
IconData iconData,
void Function(BuildContext, int id) callback,
Color color}) = _MenuAction;
}
final menuConfig = MenuConfig(
color: Colors.lightBlue.shade200,
actions: [
MenuAction(
label: 'Cut',
iconData: Icons.cut,
color: Colors.red.shade200,
callback: (context, id) =>
context.read(boxesProvider).updateBoxColor(id, Colors.red.shade200),
),
MenuAction(
label: 'Copy',
iconData: Icons.copy,
color: Colors.green.shade200,
callback: (context, id) =>
context.read(boxesProvider).updateBoxColor(id, Colors.green.shade200),
),
MenuAction(
label: 'Paste',
iconData: Icons.paste,
color: Colors.blue.shade200,
callback: (context, id) =>
context.read(boxesProvider).updateBoxColor(id, Colors.blue.shade200),
),
MenuAction(
label: 'Undo',
iconData: Icons.undo,
color: Colors.indigo.shade200,
callback: (context, id) => context
.read(boxesProvider)
.updateBoxColor(id, Colors.indigo.shade200),
),
],
);
const double kMenuRadius = 100.0;
包依赖项
- Riverpod (Flutter Hooks 风格)用于状态管理
- Freezed 域 类 不变性
我已经搜索了 flutter 的圆形菜单包,它们都有径向排列的晶圆厂。我想要 UI 这样,无论用户在哪里点击(按住),都会弹出一个圆形菜单,其中的按钮按扇区划分。
对于如今的应用程序,径向弹出更多按钮 是用户可以执行的额外操作的常见 UX 做法。
然而,这对于向用户呈现多个操作很有用,但在您希望用户执行主要操作的情况下没用 .
不过,如果您的情况符合需要,请务必查看来自 Jeff @firehsip.io
的 this tutorial这是此类径向上下文菜单的快速游乐场。
它使用状态管理、提供者、覆盖的概念...
我试图将其控制在 300 行代码以下。
项目结构:
领域层
出于本演示的目的,我使用了由它们的 id
、name
和 color
定义的 Boxes
。
径向菜单由 MenuConfig
和 MenuActions
状态管理层
对于盒子,我使用 boxesProvider
提供盒子列表和更新它们的可能性 color
。
对于菜单,我将 menuProvider
定义为 StateNotifierProvider.family
以及应用我的径向菜单的当前项目的 ScopedProvider。
表示层
Widget Tree的结构如下:
Providercope
> MaterialApp
> HomePage
> Scaffold
> GridView
> BoxWidget
> ProviderScope [contextIdProvider.overrideWithValue(box.id)]
> ContextMenuDetector
ContextMenuDetector
是一个 GestureDetector
,它在 OverlayEntry
中创建 RadialMenu
并管理菜单用户体验,这要归功于 olLongPressStart
、olLongPressMoveUpdate
,以及 olLongPressUp
我让你发现完整的源代码:
完整源代码:
import 'dart:math' show pi, cos, sin;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
part '66483259.radial_menu.freezed.dart';
void main() {
runApp(ProviderScope(
child: MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Radial Menu Demo',
home: HomePage(),
)));
}
class HomePage extends HookWidget {
@override
Widget build(BuildContext context) {
final boxes = useProvider(boxesProvider.state);
return Scaffold(
appBar: AppBar(title: Text('Radial Menu Demo')),
body: GridView.count(
crossAxisCount: 3,
children: boxes.map((box) => BoxWidget(box: box)).toList(),
),
);
}
}
class BoxWidget extends StatelessWidget {
final Box box;
const BoxWidget({Key key, this.box}) : super(key: key);
@override
Widget build(BuildContext context) {
return ProviderScope(
overrides: [contextIdProvider.overrideWithValue(box.id)],
child: ContextMenuDetector(
child: Container(
decoration: BoxDecoration(
color: box.color,
border: Border.all(color: Colors.black87, width: 2.0),
),
child: Text(box.name),
),
),
);
}
}
class ContextMenuDetector extends HookWidget {
final Widget child;
const ContextMenuDetector({Key key, this.child}) : super(key: key);
@override
Widget build(BuildContext context) {
final contextId = useProvider(contextIdProvider);
final _menuConfig = useProvider(menuProvider(contextId).state);
final _offsetCorrection = useState(Offset.zero);
final _overlayEntry = useState<OverlayEntry>(null);
OverlayEntry _createMenu({Offset offset}) => OverlayEntry(
builder: (BuildContext overlayContext) {
return ProviderScope(
overrides: [contextIdProvider.overrideWithValue(contextId)],
child: Positioned(
left: offset.dx - kMenuRadius,
top: offset.dy - kMenuRadius,
child: RadialMenu(),
),
);
},
);
void _showMenu({Offset offset}) {
_overlayEntry.value = _createMenu(offset: offset);
Overlay.of(context).insert(_overlayEntry.value);
}
void _hideMenu() {
_overlayEntry.value?.remove();
}
return GestureDetector(
onLongPressStart: (details) {
final menuOffset = Offset(
details.globalPosition.dx.clamp(
kMenuRadius, MediaQuery.of(context).size.width - kMenuRadius),
details.globalPosition.dy.clamp(
kMenuRadius, MediaQuery.of(context).size.height - kMenuRadius),
);
_offsetCorrection.value = details.globalPosition - menuOffset;
_showMenu(offset: menuOffset);
},
onLongPressMoveUpdate: (details) {
final offset = details.localOffsetFromOrigin + _offsetCorrection.value;
if (offset.distance <= kMenuRadius) {
final sextant =
(((offset.direction / pi + 2 + 1 / 6) % 2) * 3).floor();
if (sextant < 4) {
context.read(menuProvider(contextId)).selectAction(sextant);
return;
}
}
context.read(menuProvider(contextId)).selectAction(-1);
},
onLongPressUp: () {
if (_menuConfig.selectedAction >= 0) {
_menuConfig.currentAction?.callback?.call(context, contextId);
}
_hideMenu();
},
child: child,
);
}
}
class RadialMenu extends HookWidget {
@override
Widget build(BuildContext context) {
final contextId = useProvider(contextIdProvider);
final config = useProvider(menuProvider(contextId).state);
return Material(
color: Colors.transparent,
child: Container(
width: 2 * kMenuRadius,
height: 2 * kMenuRadius,
decoration: BoxDecoration(
color: Colors.grey.shade200,
shape: BoxShape.circle,
),
child: CustomPaint(
painter: RadialMenuPainter(config: config),
size: Size(2 * kMenuRadius, 2 * kMenuRadius),
child: Stack(
children: [
Positioned(
top: .4 * kMenuRadius,
left: .5 * kMenuRadius,
right: .5 * kMenuRadius,
child: Text('Menu $contextId', textAlign: TextAlign.center),
),
...config.actions.asMap().entries.map(
(action) {
final angle = pi * action.key / 3;
return Positioned(
left: kMenuRadius * (.6 + .5 * cos(angle)),
top: kMenuRadius * (.6 + .5 * sin(angle)),
child: SizedBox(
width: .8 * kMenuRadius,
height: .8 * kMenuRadius,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(action.value.label,
style: TextStyle(fontSize: 12.0)),
Icon(action.value.iconData, size: 16.0),
],
),
),
);
},
),
],
),
),
),
);
}
}
class RadialMenuPainter extends CustomPainter {
final MenuConfig config;
RadialMenuPainter({this.config});
@override
void paint(Canvas canvas, Size size) {
Paint paint = Paint()
..color = config.color
..style = PaintingStyle.stroke
..strokeWidth = size.shortestSide * .1;
canvas.drawCircle(
Offset(size.width / 2, size.height / 2), size.shortestSide / 2, paint);
config.actions.asMap().entries.forEach((action) {
Paint paint = Paint()
..color = action.key == config.selectedAction
? action.value.color
: action.value.color.withOpacity(.4)
..style = PaintingStyle.fill;
Path path = Path()
..moveTo(size.width / 2, size.height / 2)
..arcTo(
Rect.fromLTWH(size.width * .1, size.height * .1, size.width * .8,
size.height * .8),
pi * (action.key / 3 - 1 / 6),
pi / 3,
false)
..close();
canvas.drawPath(path, paint);
});
}
@override
bool shouldRepaint(covariant RadialMenuPainter oldDelegate) =>
oldDelegate.config.selectedAction != config.selectedAction;
}
final boxesProvider =
StateNotifierProvider<BoxesNotifier>((ref) => BoxesNotifier(24));
class BoxesNotifier extends StateNotifier<List<Box>> {
BoxesNotifier(int nbBoxes)
: super(
List.generate(
nbBoxes,
(index) => Box(id: index, name: 'Box $index', color: Colors.white),
),
);
updateBoxColor(int id, Color color) {
state = [...state]..[id] = state[id].copyWith(color: color);
}
}
@freezed
abstract class Box with _$Box {
const factory Box({int id, String name, Color color}) = _Box;
}
final contextIdProvider =
ScopedProvider<int>((ref) => throw UnimplementedError());
final menuProvider = StateNotifierProvider.family<MenuNotifier, int>(
(ref, id) => MenuNotifier(menuConfig));
class MenuNotifier extends StateNotifier<MenuConfig> {
MenuNotifier(MenuConfig state) : super(state);
void selectAction(int index) {
state = state.copyWith(selectedAction: index);
}
}
@freezed
abstract class MenuConfig implements _$MenuConfig {
const factory MenuConfig({
@Default(Colors.white) Color color,
@Default([]) List<MenuAction> actions,
int selectedAction,
}) = _RadialMenuConfig;
const MenuConfig._();
MenuAction get currentAction => actions[selectedAction];
}
@freezed
abstract class MenuAction with _$MenuAction {
const factory MenuAction(
{String label,
IconData iconData,
void Function(BuildContext, int id) callback,
Color color}) = _MenuAction;
}
final menuConfig = MenuConfig(
color: Colors.lightBlue.shade200,
actions: [
MenuAction(
label: 'Cut',
iconData: Icons.cut,
color: Colors.red.shade200,
callback: (context, id) =>
context.read(boxesProvider).updateBoxColor(id, Colors.red.shade200),
),
MenuAction(
label: 'Copy',
iconData: Icons.copy,
color: Colors.green.shade200,
callback: (context, id) =>
context.read(boxesProvider).updateBoxColor(id, Colors.green.shade200),
),
MenuAction(
label: 'Paste',
iconData: Icons.paste,
color: Colors.blue.shade200,
callback: (context, id) =>
context.read(boxesProvider).updateBoxColor(id, Colors.blue.shade200),
),
MenuAction(
label: 'Undo',
iconData: Icons.undo,
color: Colors.indigo.shade200,
callback: (context, id) => context
.read(boxesProvider)
.updateBoxColor(id, Colors.indigo.shade200),
),
],
);
const double kMenuRadius = 100.0;
包依赖项
- Riverpod (Flutter Hooks 风格)用于状态管理
- Freezed 域 类 不变性