在 flutter 中实现循环上下文菜单

Implementing circular context menu in flutter

我已经搜索了 flutter 的圆形菜单包,它们都有径向排列的晶圆厂。我想要 UI 这样,无论用户在哪里点击(按住),都会弹出一个圆形菜单,其中的按钮按扇区划分。

对于如今的应用程序,径向弹出更多按钮 是用户可以执行的额外操作的常见 UX 做法。

然而,这对于向用户呈现多个操作很有用,但在您希望用户执行主要操作的情况下没用 .

不过,如果您的情况符合需要,请务必查看来自 Jeff @firehsip.io

this tutorial

这是此类径向上下文菜单的快速游乐场。

它使用状态管理、提供者、覆盖的概念...

我试图将其控制在 300 行代码以下。

项目结构:

领域层

出于本演示的目的,我使用了由它们的 idnamecolor 定义的 Boxes

径向菜单由 MenuConfigMenuActions

定义

状态管理层

对于盒子,我使用 boxesProvider 提供盒子列表和更新它们的可能性 color

对于菜单,我将 menuProvider 定义为 StateNotifierProvider.family 以及应用我的径向菜单的当前项目的 ScopedProvider。

表示层

Widget Tree的结构如下:

Providercope
> MaterialApp
  > HomePage
    > Scaffold
      > GridView
        > BoxWidget
          > ProviderScope [contextIdProvider.overrideWithValue(box.id)]
            > ContextMenuDetector

ContextMenuDetector 是一个 GestureDetector,它在 OverlayEntry 中创建 RadialMenu 并管理菜单用户体验,这要归功于 olLongPressStartolLongPressMoveUpdate,以及 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;

包依赖项