Flutter 封装:最佳实践 —— IM 聊天信息长按菜单实现

一、需求来源

工作需求需要实现仿微信的信息长按菜单,用了 WPopupMenu 实现,但是发现在 flutter 3.7 之后显示位置不准确,有时候在水平方向会超出屏幕显示范围。一直在寻找一种新的实现方式,直到最近学习到 CompositedTransformFollower/CompositedTransformTarget 组件。灵光一闪,这不最佳实现不是来了嘛。随后封装组件 NTargetFollower 实现重构;

效果如下:

二、使用示例

scss 复制代码
class _IMChatPageState extends State<IMChatPage> with
    SingleTickerProviderStateMixin,
    RouteAware,
    WidgetsBindingObserver,
    KeyboardChangeMixin,
    BottomSheetPhrasesMixin {

  //...
  

  /// 长按菜单专用(保证全页面唯一)
  final List<OverlayEntry> longPressEntries = [];
  /// 长按菜单专用
  VoidCallback? longPressOnHide;


  @override
  void didPush() {
    debugPrint("$this didPush");
  }

  @override
  void didPop() {
    debugPrint("$this didPop");
    longPressMenuHide();
  }

  @override
  void didPushNext() {
    debugPrint("$this didPushNext");
    longPressMenuHide();
  }

  @override
  void didPopNext() {
    debugPrint("$this didPopNext");
  }  

  //...

  buildContentChild({
    required int modelIndex,
    required bool isOwner,
    required String text,
  }) {
    //...

    late final menueItems = <Tuple2<String, String>>[      Tuple2("复制", "icon_copy.png",),      Tuple2("引用", "icon_quote.png",),      Tuple2("撤回", "icon_revoke.png",),    ];

    if (menueItems.isEmpty) {
      return child;
    }

    child = NTargetFollower(
        targetAnchor: isOwner ? Alignment.topRight : Alignment.topLeft,
        followerAnchor: isOwner ? Alignment.bottomRight : Alignment.bottomLeft,
        // targetAnchor: isOwner ? Alignment.topCenter : Alignment.topCenter,
        // followerAnchor: isOwner ? Alignment.bottomCenter : Alignment.bottomCenter,
        entries: longPressEntries,
        offset: Offset(0, -8),
        onLongPressEnd: (e) {
          // 勿删
        },
        target: child,
        followerBuilder: (context, onHide) {
          longPressOnHide = onHide;

          // debugPrint("${DateTime.now()} followerBuilder:");
          return NLongPressMenu(
            items: menueItems.map((e) => Tuple2(e.item1, e.item2.toAssetImage())).toList(),
            onItem: (Tuple2<String, AssetImage> t) {
              onHide();
              debugPrint("onChanged_$t");
              EasyToast.showToast(t.item1);
            }
          );
        }
    );
    return child;
  }
 
  /// 长按菜单隐藏
  longPressMenuHide() {
    longPressOnHide?.call();
  }

//...

三、源码

1、NTargetFollower 源码

dart 复制代码
//
//  n_target_follower.dart
//  flutter_templet_project
//
//  Created by shang on 2023/10/18 14:05.
//  Copyright © 2023/10/18 shang. All rights reserved.
//


import 'package:flutter/material.dart';
import 'package:flutter_templet_project/extension/custom_type_util.dart';

/// 关联组件
class NTargetFollower extends StatefulWidget {

  NTargetFollower({
    Key? key,
    this.targetAnchor = Alignment.topCenter,
    this.followerAnchor = Alignment.bottomCenter,
    this.showWhenUnlinked = true,
    this.offset = Offset.zero,
    this.onTap,
    this.onLongPressEnd,
    required this.target,
    required this.followerBuilder,
    this.entries,
  }) : super(key: key);

  final Alignment targetAnchor;
  final Alignment followerAnchor;

  final bool showWhenUnlinked;

  final Offset offset;

  final GestureTapCallback? onTap;

  /// 实现此方法则弹窗不会自动关闭,需手动关闭
  final GestureLongPressEndCallback? onLongPressEnd;

  /// 传入此参数则页面仅显示一个
  final List<OverlayEntry>? entries;

  final Widget target;

  VoidCallbackWidgetBuilder? followerBuilder;


  @override
  _NTargetFollowerState createState() => _NTargetFollowerState();
}

class _NTargetFollowerState extends State<NTargetFollower> {

  final LayerLink layerLink = LayerLink();

  late final _entries = widget.entries ?? <OverlayEntry>[];

  late OverlayEntry _overlayEntry;
  bool show = false;
  Offset indicatorOffset = const Offset(0, 0);

  @override
  Widget build(BuildContext context) {
    if (widget.followerBuilder == null) {
      return widget.target;
    }

    return GestureDetector(
      onTap: widget.onTap,
      // onTap: _toggleOverlay,
      // onPanStart: (e) => _showOverlay(),
      // onPanEnd: (e) => _hideOverlay(),
      // onPanUpdate: updateIndicator,
      onLongPressStart: (e) => _showOverlay(),
      onLongPressEnd: widget.onLongPressEnd ?? (e) => _hideOverlay(),
      onLongPressMoveUpdate: updateIndicatorLongPress,
      child: CompositedTransformTarget(
        link: layerLink,
        child: widget.target,
      ),
    );
  }


  void _toggleOverlay() {
    if (!show) {
      _showOverlay();
    } else {
      _hideOverlay();
    }
    show = !show;
  }

  void _showOverlay() {
    // if (_entries.isNotEmpty) {
    //   return;
    // }
    _hideOverlay();

    _overlayEntry = _createOverlayEntry(indicatorOffset);
    _entries.add(_overlayEntry);
    Overlay.of(context).insert(_overlayEntry);
  }

  void _hideOverlay() {
    for (final e in _entries) {
      e.remove();
    }
    _entries.clear();
  }

  void updateIndicator(DragUpdateDetails details) {
    indicatorOffset = details.localPosition;
    _overlayEntry.markNeedsBuild();
  }


  void updateIndicatorLongPress(LongPressMoveUpdateDetails details) {
    indicatorOffset = details.localPosition;
    _overlayEntry?.markNeedsBuild();
  }

  OverlayEntry _createOverlayEntry(Offset localPosition) {
    indicatorOffset = localPosition;
    return OverlayEntry(
      builder: (BuildContext context) => UnconstrainedBox(
        child: CompositedTransformFollower(
          link: layerLink,
          targetAnchor: widget.targetAnchor,
          followerAnchor: widget.followerAnchor,
          offset: widget.offset,
          showWhenUnlinked: widget.showWhenUnlinked,
          child: widget.followerBuilder?.call(context, _hideOverlay),
        ),
      ),
    );
  }
}

2、NLongPressMenu 源码

less 复制代码
import 'package:flutter/material.dart';
import 'package:flutter_templet_project/basicWidget/n_label_and_icon.dart';
import 'package:flutter_templet_project/basicWidget/n_text.dart';
import 'package:flutter_templet_project/extension/string_ext.dart';
import 'package:tuple/tuple.dart';

/// 长按黑色菜单
class NLongPressMenu extends StatelessWidget {

  const NLongPressMenu({
    Key? key,
    required this.items,
    required this.onItem,
  }) : super(key: key);

  /// 标题和本地图片
  final List<Tuple2<String, AssetImage>> items;
  /// 点击菜单回调
  final ValueChanged<Tuple2<String, AssetImage>> onItem;


  @override
  Widget build(BuildContext context) {
    return Container(
      // width: 200,
      // height: 100,
      padding: const EdgeInsets.only(top: 14, right: 20, bottom: 12, left: 20),
      decoration: const BoxDecoration(
        color: Color(0xff4d4d4d),
        // border: Border.all(color: Colors.blue),
        borderRadius: BorderRadius.all(Radius.circular(4)),
      ),
      constraints: BoxConstraints(
        maxWidth: MediaQuery.of(context).size.width * 0.7,
        minWidth: 0,
        maxHeight: 400,
      ),
      child: Wrap(
        spacing: 34,
        runSpacing: 16,
        children: items.map((e) {

          final child = NLabelAndIcon(
            direction: Axis.vertical,
            label: NText(e.item1,
              color: Colors.white,
              fontSize: 13,
              fontWeight: FontWeight.w400,
            ),
            icon: Image(
              image: e.item2,
              width: 18,
              height: 18,
              fit: BoxFit.fill,
            ),
          );

          return Material(
            color: Colors.transparent,
            child: InkWell(
              onTap: (){
                onItem(e);
              },
              child: child,
            ),
          );
        }).toList(),
      ),
    );
  }
}

四、总结

1、NTargetFollower 组件核心是CompositedTransformFollower + Overlay 实现;
2、通过自定义类型暴露出隐藏菜单方法 onHide
ini 复制代码
typedef VoidCallbackWidgetBuilder = Widget Function(BuildContext context, VoidCallback cb);
3、目前仅识别长按手势,其他手势大家可以随意扩展,源码非常简单,可以无限扩展;
4、聊天信息长按只是 CompositedTransformFollower/CompositedTransformTarget 的一种可能性;目前看来任何两个有位置关联的功能均可以用该组件封装实现;

IMChatPage

n_target_follower

相关推荐
m0_7381858226 分钟前
Flutter 鸿蒙化实战:flutter_scankit 适配 OpenHarmony,华为 ScanKit 扫码
flutter·华为·harmonyos·鸿蒙
Seraphina3631 分钟前
DVWA(SQL注入-low,medium,XSS反射-low)
前端·数据库·笔记·sql·网络安全·web·xss
Access开发易登软件36 分钟前
Access 导入 Markdown 表格:把文档里的数据写进表
前端·html·vba·markdown·access开发
扶风ff1 小时前
求职笔试资料太散?用练题簿在线刷题,按岗位整理练习清单
前端·学习·小程序
浪浪山_大橙子1 小时前
公司里的 AI,终于不只会聊天:我用 GPT‑6 把企业工作伙伴开源了
前端·后端·面试
琹箐1 小时前
命令行调用接口
前端
可乐鸡翅yeah_2 小时前
AES‑128 加密 M3U8,IV 初始化向量新手容易踩坑
前端·网络·数据库·ffmpeg·音视频·m3u8在线
MetWeave2 小时前
机场天气的"广播系统":三种报文一次讲明白
前端
xieter2 小时前
【技术精选】深入浅出现代前端渲染与性能优化实践指南 (2026-10-04)
前端·javascript
计算机魔术师2 小时前
Anthropic 密会宗教领袖谈 Claude 意识,OpenAI 为什么急眼
前端