一、需求来源
工作需求需要实现仿微信的信息长按菜单,用了 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);