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

相关推荐
无敌最俊朗@4 分钟前
c#————委托Action使用例子
java·前端·c#
7643319 分钟前
JavaScript ES6 继承 class
前端·javascript
袁代码25 分钟前
SwiftUI开发教程系列 - 第十二章:本地化与多语言支持
开发语言·前端·ios·swiftui·swift·ios开发
软件聚导航1 小时前
在uniapp中使用canvas封装组件遇到的坑,数据被后面设备覆盖,导致数据和前面的设备一样
java·前端·uni-app
好开心331 小时前
javaScript交互补充2(动画函数封装)
开发语言·前端·javascript·html·ecmascript
将登太行雪满山_1 小时前
自存 关于RestController请求传参数 前端和后端相关
java·前端·spring boot
kali-Myon1 小时前
ctfshow-web入门-SSTI(web369-web372)下
前端·python·学习·web安全·flask·web·ssti
鸽鸽程序猿1 小时前
【前端】HTML
前端·html
️ 邪神1 小时前
【Android、IOS、Flutter、鸿蒙、ReactNative 】水平布局
flutter·ios·鸿蒙·reactnative·anroid
程楠楠&M1 小时前
mongoDB的安装及使用
前端·数据库·mongodb·node