直接先看需求图
image.png
这个选中有很多部件都可以实现,比如TextField、SelectableText、SelectableText.rich
image.png
image.png
很显然这不是我们想要的,我们需要自定义布局
后发现他们都提供了contextMenuBuilder方法,以及onSelectionChanged,对比发现contextMenuBuilder比较合适 ,因为返回了选中范围的起始坐标。
我这里使用SelectableText.rich,另外两个部件可自行测试
一开始,我发现提供了AdaptiveTextSelectionToolbar标准部件
AdaptiveTextSelectionToolbar(
anchors: editableTextState.contextMenuAnchors,
children: [
_buildMenuItem(Icons.copy, '复制', () {
_copyText(context, editableTextState);
}),
_buildMenuItem(Icons.border_color, '画线', () {
_underlineText(context, editableTextState);
}),
_buildMenuItem(Icons.auto_awesome, 'AI搜索', () {
_callAI(context, editableTextState);
}),
_buildMenuItem(Icons.book, '笔记', () {
_saveNote(context, editableTextState);
}),
],
);
Widget _buildMenuItem(IconData icon, String text, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 1),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 18, color: Colors.black87),
SizedBox(height: 2),
Text(text, style: TextStyle(fontSize: 14, color: Colors.black87)),
],
),
),
);
}
效果
image.png
这个位置是没问题,图标和文本也修改了,但是 背景色,圆角,和小三角呢?研究发现AdaptiveTextSelectionToolbar没有提供其他修改的方法,只要放弃了标准部件
我直接返回一个横向的布局呢?
结构:Stack>Container>row>,这样发现按钮直接出现在屏幕的左上角,那怎么和AdaptiveTextSelectionToolbar一样显示在对应的位置呢
我们来看看 contextMenuBuilder: (context, editableTextState) {},其中editableTextState就包含了位置相关的信息
var endX=editableTextState.contextMenuAnchors.primaryAnchor.dx;
var endY=editableTextState.contextMenuAnchors.primaryAnchor.dy;
var startX=editableTextState.contextMenuAnchors.secondaryAnchor!.dx;
var centerX=startX+(endX-startX)/2;
有这些坐标,我以为就大功告成了,后面发现位置不对,因为你的布局也是有宽高的,你需要知道你布局的宽高,减掉一半才可以完成居中定位
后面发现 ,菜单布局的宽高不好获取,因为你需要渲染完成才可以获取widget的宽高,但是我们需要定位,就需要再渲染之前计算位置,那咋整呢,后面想了下,在contextMenuBuilder方法中,延时一下获取
Timer(Duration(milliseconds: 1),(){
if(size.width==0)
_getWidgetSize();
});
果然可以了,
image.png
不过紧接着又发现问题了,如果选择范围比较窄,又刚好在上下左右边缘,会显示不全,
image.png
image.png
后面调整如下(具体算法放在offsetX方法中)
image.png
image.png
image.png
,最后就差倒三角了,写一个TrianglePainter类
import 'package:flutter/material.dart';
class TrianglePainter extends CustomPainter {
final bool isInverted;
TrianglePainter({this.isInverted = true});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.black87
..style = PaintingStyle.fill;
final path = Path();
if (isInverted) {
// 绘制倒三角形
path.moveTo(0, 0);
path.lineTo(size.width / 2, size.height);
path.lineTo(size.width, 0);
} else {
// 绘制正三角形
path.moveTo(size.width / 2, 0);
path.lineTo(0, size.height);
path.lineTo(size.width, size.height);
}
path.close();
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
最终效果:
image.png
image.png
完整代码就两三个类
class Selectable extends StatefulWidget {
const Selectable({super.key});
@override
State<Selectable> createState() => _SelectableState();
}
class _SelectableState extends State<Selectable> {
final GlobalKey _key = GlobalKey();
Size size=Size(0, 0);
TextSpan textSpan=TextSpan(text: '美乌代表团还讨论了人道主义努力作为和平进程一部分的重要性,特别是在停火期间,'
'包括交换战俘、释放被拘留的平民以及帮助被迫流离失所的乌克兰儿童返回等。'
'双方代表团同意确定谈判团队组成并立即开始谈判,'
'以实现持久和平并确保乌克兰的长期安全。'
'美国承诺与俄罗斯代表讨论这些具体建议。乌克兰代表团再次强调,欧洲伙伴应该参与和平进程。'
'两国总统同意尽快就乌克兰关键矿产资源开发达成全面协议,以增强乌克兰经济并确保乌克兰的长期繁荣与安全。'
'当天乌克兰总统办公室主任叶尔马克表示,'
'美国和乌克兰朝着恢复乌克兰可持续和平迈出了重要步伐。'
'两国代表一致认为现在是开始建立持久和平进程的时候了。');
@override
void initState() {
// TODO: implement initState
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_getWidgetSize();
});
}
void _getWidgetSize() {
final RenderBox? box = _key.currentContext?.findRenderObject() as RenderBox?;
if (box != null) {
setState(() {
size=box.size;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Container(
margin: EdgeInsets.all(10),
padding: EdgeInsets.all(10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.blue, width: 2),
),
child: SelectableText.rich(
textSpan,
style: TextStyle(fontSize: 16),
contextMenuBuilder: (context, editableTextState) {
return SelectMenuWidget(
maxSize: size,
editableTextState: editableTextState,
onSelect: (int index) {
if (index == 0) {
_copyText(context, editableTextState);
} else if (index == 1) {
_underlineText(context, editableTextState);
}else if (index == 2) {
_callAI(context, editableTextState);
}else if (index == 3) {
_saveNote(context, editableTextState);
}
},
);
}),
),
),
);
}
/// 复制选中的文本
void _copyText(BuildContext context, EditableTextState editableTextState) {
String selectedText =
editableTextState.textEditingValue.selection.textInside(editableTextState.textEditingValue.text);
Clipboard.setData(ClipboardData(text: selectedText));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('已复制')));
editableTextState.hideToolbar();
}
/// 已添加下划线
void _underlineText(BuildContext context, EditableTextState editableTextState) {
// 获取当前的选中范围
TextSelection selection = editableTextState.textEditingValue.selection;
if (editableTextState.textEditingValue.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('没有选中文本')));
return;
}
// 获取当前的文本
String currentText = editableTextState.textEditingValue.text;
// 确保选中部分的文本范围有效
String selectedText = currentText.substring(selection.start, selection.end);
// 更新 TextEditingController 或相应的文本显示方式
setState(() {
textSpan=getUnderlineTextSpan(selectedText,selection.start,selection.end);
});
// 这里通过 EditableTextState 来刷新
editableTextState.showToolbar();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('已添加下划线')));
}
TextSpan getUnderlineTextSpan(String text,int start,int end){
// 创建下划线样式的文本
TextSpan underlinedText = TextSpan(
text: text,
style: TextStyle(
decoration: TextDecoration.underline, // 添加下划线
),
);
// 重新构建新的 TextSpan
TextSpan updatedTextSpan = TextSpan(
children: [
TextSpan(text: text.substring(0, start)), // 选中前的文本
underlinedText, // 选中的部分添加下划线
TextSpan(text: text.substring(end)), // 选中后的文本
],
);
return updatedTextSpan;
}
/// 调用AI分析功能
void _callAI(BuildContext context, EditableTextState editableTextState) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('AI法师已启动')));
editableTextState.hideToolbar();
}
/// 保存笔记功能
void _saveNote(BuildContext context, EditableTextState editableTextState) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('禅师笔录已添加')));
editableTextState.hideToolbar();
}
}
class SelectMenuWidget extends StatefulWidget {
EditableTextState editableTextState;
Function(int) onSelect;
Size maxSize;
SelectMenuWidget({super.key,required this.editableTextState,required this.onSelect,required this.maxSize});
@override
State<SelectMenuWidget> createState() => _SelectMenuWidgetState(editableTextState: editableTextState,onSelect: onSelect);
}
class _SelectMenuWidgetState extends State<SelectMenuWidget> {
EditableTextState editableTextState;
Function(int) onSelect;
_SelectMenuWidgetState({required this.editableTextState,required this.onSelect});
double get endX=> editableTextState.contextMenuAnchors.primaryAnchor.dx;
double get endY=> editableTextState.contextMenuAnchors.primaryAnchor.dy;
double get startX=>editableTextState.contextMenuAnchors.secondaryAnchor!.dx;
double get centerX=> startX+(endX-startX)/2;
final GlobalKey _menuKey = GlobalKey();
Size size=Size(0, 0);
Size triangleSize=Size(20, 10);
Size _getMenuSize() {
final RenderBox? box = _menuKey.currentContext?.findRenderObject() as RenderBox?;
if (box != null) {
return box.size;
}
return Size.zero;
}
var menus= ["复制","画线","笔记","AI搜索"];
var icons= [Icons.copy,Icons.border_color,Icons.book,Icons.search];
@override
void initState() {
// TODO: implement initState
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if(size.width==0)
setState(() {
size=_getMenuSize();
});
});
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
Positioned(
top:endY<50? endY+30: endY-size.height-triangleSize.height,
// left: centerX-size.width/2, 不能一直取中间,否则中心点在两侧,菜单就显示不全
left: centerX-size.width/2-offsetX(),
child: Container(
key: _menuKey,
padding: EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children:List.generate(menus.length, (index){
return _buildMenuItem(icons[index], menus[index], (){
onSelect(index);
});
}),
),
),
),
Positioned(
top:endY<50? endY+20: endY-triangleSize.height,
left:centerX -triangleSize.width/2,
child: CustomPaint(
size: triangleSize,
painter: TrianglePainter(isInverted: endY<50?false: true),
)
)
],
);
}
double offsetX(){
if(centerX<size.width/2){
return centerX-size.width/2;
}else if(widget.maxSize.width-centerX<size.width/2){
return size.width/2-(widget.maxSize.width-centerX)-10;
}else return 0;
}
Widget _buildMenuItem(IconData icon, String text, VoidCallback onTap) {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 1),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 18, color: Colors.white),
SizedBox(height: 2),
Text(text, style: TextStyle(fontSize: 14, color: Colors.white)),
],
),
),
);
}
}