背景
之前项目中使用的图例基本出自 flutter_echarts: ^2.4.0 (用的是自己升级webview版本后的插件),原理很简单就是通过webview渲染 echarts,不得不说库是真的666,但我这里用不了就有点尴尬了~~
言归正传,这个UI小姐姐给的树状图想使用 echarts 偷懒难度不小,具体有以下问题:
echarts毕竟是前端库,半路出家查询配置多少有点眼花缭乱的感觉;- UI出图为左右两支数据组,查询
echarts相关资料,并没办法比较友好的展示出双端数据流,并且展示效果修改也是是一个问题; - 图例中涉及到用户交互,如果使用
echarts埋点js方法,个人感觉可读性不高,代码过于杂糅,不方便别人查阅修改;
图例
简单明了,但也够呛 > _ <
实现思路
大致的图解分块
- 居中标题单独绘制,具体为:
- 中心确定为屏幕中心;
- 确定文本
Padding预留背景的大小; - 通过限制最大文本宽度,计算得到文本的
size; - 绘制文本,绘制有色背景;
- 绘制每一个叶子节点
- 和标题类似优先计算
textSize,但是这里考虑整体文本不会太大,所以是文本完整显示为1行; - 确定文本
Padding预留背景的大小; - 绘制文本、绘制背景、绘制边框、绘制连接线或其他小部件;
- 累计所有同级节点,确定同级节点的宽高
- 宽度:以文本最长的那一行为准;
- 高度:由于单文本显示为一行,如果没有子节点集影响则总体高度为每一个
textSize+textPadding; - 节点包含子集:使用子集节点的累计高度替换当前节点的高度,保证多级树渲染显示不会出现内容重叠,且父级节点会增大上下留白;
-
添加交互,这里做了两个方案,记录分析下优劣与取舍:
1、直接使用
GestureDetector处理:- 拖动、缩放: 屏幕添加
GestureDetector记录拖动、缩放,然后通过transform实现形态变换,好处是添加RepaintBoundary后对顶层视图进行形态变换可以避免canvas的重复绘制;坏处是canvas的大小是固定的,如果内容过多渲染图超出屏幕,滑动的时候始终会有不可见部分; - 点击:通过回调
onTapDown获取到屏幕接触点,然后硬算触摸点是否在内容的Rect里面,嘛耶,这么多文本框能算?! 所以做到这里就直接pass这个方案了;
2、使用
GestureDetector处理点击 + MatrixGestureDetector 处理拖动、缩放- 拖动、缩放:
MatrixGestureDetector是对GestureDetector的封装然后回调形态变换的矩阵值Matrix4,通过Matrix4我们可以在绘制canvas.drawPath(path, paint)的 参数path进行矩阵变化path.transform(matrix.storage), 就可以实现canvas的基本交互了。缺点就是重复绘制了; - 点击:记录每一个点击节点的区域路径(path),然后通过
path.contains(point)判断接触点是否处于节点区域路径,实现点击判断。通过path.transform(matrix.storage)拖拽、缩放后依旧有效,这就比较顶了,便捷到起飞。
3、关于方案(1)说的
GestureDetector方案为什么不采用路径是否包含点的方法来处理,是因为移动/缩放后需要重新给原始path进行矩阵变换,效果不好。而且未能显示的节点根本都没办法实现交互。所以最后还是确定到了方案(2)。 - 拖动、缩放: 屏幕添加
总的来说难度不高,主要还是对节点Rect的计算。
*** 上文给出的仅仅是能获取到每一个显示节点的 size,而具体的显示偏移offset还需要通过额外计算。 ***
开始上手
一、计算部分
一、优先分析我们在绘制的时候需要使用到哪些数据,具体如下所示:
class DataTree {
/// 下级列表
final List<DataTree> children;
/// 标题
final String title;
/// 当前item值
final String value;
/// 是否包含下级数据 显示 [⊕]
final bool isMore;
/// 位置信息
final List<int> indexs;
/// 携带原始数据结构,方便传值
final dynamic data;
/// 通过数据计算位置并记录
/// rect 为整个模块信息(line+text+box)组合后
///
/// `textBoxRect` + `linePadding`
///
/// 记录当前标题框整体的坐标
///
/// `````````````````
/// ` `
/// ``````` 文本内容 ``````
/// ` `
/// `````````````````
///
final Rect boxRect;
/// 记录列表标题框整体的坐标
///
/// `````````````````
/// ` `
/// ``````` 文本内容 ``````
/// ` `
/// `````````````````
/// `````````````````
/// ` `
/// ``````` 文本内容 ``````
/// ` `
/// `````````````````
///
final Rect boxListRect;
final Rect childrenRect;
/// 包含textPadding的数据
final Rect textBoxRect;
final Size textSize;
final EdgeInsets textPadding;
/// 同级列表最大宽度 (计算当前子集与本身间隔线的距离)
/// 实际上并没有使用,只是用于记录分析,`boxListRect`宽度等于该值
final double maxWidth;
/// 文本大小
final TextStyle? style;
/// 文本框`textBoxRect`距离左右两边父级标签的距离
/// 不包括`textSize.width`与`maxWidth`的间隔差值
/// 所以绘制的时候需要额外补足该差值
final double linePadding;
/// 绘制轨迹(文本框体),用于命中交互
/// 目前只保存文本框交互,如果需要额外交互则需要保存对应位置的信息
final Path? path;
/// 展开节点(显示图标过小,点击不太好响应,所以使用`path`替代)
// final Path? expandIconPath;
部分属性阐述:
indexs: 记录当前节点在整个数据树上的位置,方便交互回调后修改/拼接新数据;
textBoxRect: 文本框的坐标及大小;
childrenRect: 当前节点下的子集的坐标及大小,该参数用于计算当前节点的整个rect;
boxListRect: 当前节点的整个rect,包含子集列表和自身(子集列表大小大于自身,则使用子集列表的size作为自己的大小计算);
二、拿到服务器数据后我们应该先执行哪些操作,再执行哪些操作?
- 给
DataTree赋值我们的显示标题title、value(一些类似id的属性值)、isMore(是否显示展开标签),同时限定到交互时的展开拼接数据操作)、children(子集数据); - 通过操作(1)后我们拿到了可以直接渲染的
DataTree数据,但是我们前文所说的indexs并没有在该操作实现(如果数据本身带有下标就可以不用处理这一步了),所以这里我们优先执行节点下标的计算:
/// 获取渲染数的节点下标信息
List<DataTree> dataTreeIndexItems(List<DataTree> data,
{List<int> sidx = const []}) {
List<DataTree> tempList = [];
for (var i = 0; i < data.length; i++) {
var indexs = List.of(sidx);
var item = data[i];
indexs.addAll([i]);
List<DataTree>? children;
if (item.children.isNotEmpty) {
children = dataTreeIndexItems(item.children, sidx: indexs);
}
item = item.copyWith(indexs: indexs, children: children);
tempList.add(item);
}
return tempList;
}
通过逐级递归完成赋值操作,后续的操作基本都是采用递归的方式处理。
- 先计算我们每个节点的
size,然后通过每个节点的size计算我们节点在画布上的坐标,最后合并成rect方便后续绘制操作;
/// 计算文本大小
///
/// `text` 文本信息
/// `style` 字体规格
/// `maxLines` 行数
/// `maxWidth` 绘制最大宽度
static Size getTextSize(
String text,
TextStyle? style, {
int? maxLines,
double maxWidth = double.infinity,
}) {
TextPainter painter = TextPainter(
// AUTO:华为手机如果不指定locale的时候,该方法算出来的文字高度是比系统计算偏小的。
locale: WidgetsBinding.instance.window.locale,
maxLines: maxLines,
textDirection: TextDirection.ltr,
text: TextSpan(
text: text,
style: style,
),
);
painter.layout(maxWidth: maxWidth);
return painter.size;
}
/// 优先计算所有元素的大小信息
static Tuple2<Size, List<DataTree>> childrenSize(
List<DataTree> data,
) {
if (data.isEmpty) return const Tuple2(Size.zero, []);
// 设置基本位置
List<DataTree> tempList = [];
// 预留40px,用于绘制破折号横线
var boxSize = const Size(40, 0);
// 获取文本框大小,及带边线的大小
for (var item in data) {
// 获取子集列表的size
final res = childrenSize(item.children);
// 计算文本内容的大小
final textSize = getTextSize(item.title, item.style ?? textStyle);
// 文本框的大小,数值 12px 为上下文本列表的间隔距离 6px
final textBoxHeight = textSize.height + item.textPadding.vertical + 12;
// 保存赋值数据
item = item.copyWith(
textSize: textSize,
children: res.item2,
boxRect: Rect.fromLTWH(
0,
0,
textSize.width + item.textPadding.horizontal + item.linePadding,
textBoxHeight, // 12 为上下6px间隔
),
textBoxRect: Rect.fromLTWH(
0,
0,
textSize.width + item.textPadding.horizontal,
textSize.height + item.textPadding.vertical,
),
childrenRect: Rect.fromLTWH(
0,
0,
res.item1.width,
max(textBoxHeight, res.item1.height), // res.item1.height 子集高度
),
);
// 更新整个树形结构的大小
// 累加
boxSize = boxSize.addHeight(max(item.boxRect.height, res.item1.height));
if (item.boxRect.width > boxSize.width) {
// 更新
boxSize = boxSize.updateWidth(item.boxRect.width + 40);
}
// 记录更新后的数据
tempList.add(item);
}
// 当前节点列表总大小
for (var i = 0; i < tempList.length; i++) {
tempList[i] = tempList[i].copyWith(
boxListRect: Rect.fromLTWH(
0,
0,
boxSize.width,
boxSize.height,
),
);
}
return Tuple2(boxSize, tempList);
}
/// 继续计算所有元素的坐标信息
///
/// [superBoxRect] 父级模块列表的整体位置大小
/// [superRect] 父级节点的大小
static List<DataTree> childrenRect(
List<DataTree> data,
Rect superBoxRect, {
// 不传入默认和整个模块大小相同
// 取中心坐标
Rect? superRect,
bool isRight = true,
}) {
if (data.isEmpty) return const [];
// 对应 `childrenSize` 中 `var boxSize = const Size(40, 0);`中的值
const leftPadding = 40;
// 主要取用垂直中心坐标
final superRectTemp = superRect ?? superBoxRect;
// 设置基本位置
List<DataTree> tempList = List.of(data);
final boxSize = data.first.boxListRect;
/// 整个树形结构的位置信息
var boxListRect = Rect.fromCenter(
center: Offset(
isRight
? superBoxRect.right + boxSize.width / 2
: superBoxRect.left - boxSize.width / 2,
superRectTemp.center.dy),
width: boxSize.width,
height: boxSize.height,
);
// 更新每一个数据的位置信息
double height = 0;
for (var i = 0; i < tempList.length; i++) {
var item = tempList[i];
// 当前节点的rect
final itemRect = Rect.fromLTWH(
isRight
? boxListRect.left + leftPadding
: boxListRect.right - item.boxRect.width - leftPadding,
boxListRect.top + height,
item.boxRect.width,
item.childrenRect.height);
// 当前节点文本的rect,不包含破折号横线的40px
final itemTextRect = Rect.fromLTWH(
isRight
? boxListRect.left + leftPadding
: boxListRect.right - item.boxRect.width - leftPadding,
boxListRect.top + height,
item.textBoxRect.width,
item.textBoxRect.height);
// 子集节点的rect
final childRect = Rect.fromLTWH(
isRight
? boxListRect.left + leftPadding
: boxListRect.right - item.boxRect.width - leftPadding,
boxListRect.top + height,
item.childrenRect.width,
item.childrenRect.height);
tempList[i] = item.copyWith(
boxRect: itemRect,
textBoxRect: itemTextRect,
boxListRect: boxListRect,
childrenRect: childRect,
);
// 修改子集的rect
if (item.children.isNotEmpty) {
final res = childrenRect(
item.children,
boxListRect,
superRect: childRect,
isRight: isRight,
);
tempList[i] = tempList[i].copyWith(
children: res,
);
}
height += item.childrenRect.height;
}
return tempList;
}
其实 List<DataTree> childrenRect(...) 和 Tuple2<Size, List<DataTree>> childrenSize(...) 在处理数据的操作上感觉是有重叠的,开始也是放在单个方法里面处理,但是在处理的时候方法内比较乱(子集坐标受父级节点影响,父级节点的大小受子集节点的影响),在单个方法处理阅读起来比较费力,逻辑有点混乱(大概率是没写好( ̄▽ ̄)")。
这个三个方法调用后,树图绘制所需要的一些参数基本就完成了,后面说一下绘制方法。
一、绘制部分(树图渲染)
绘制总的来说比较简单,主要是子节点与父级节点之间的间隔线位置。这里有个细节是,我们节点模块已经是计算完成的,绘制也是按照整个模块的 rect 进行绘制,但是每一个自己点的长度不一致,导致我们次级节点的破折线需要额外加上对应父级节点 boxListRect 剩余的宽度。
示意图
/// 绘制文字
///
/// `canvas` 画布
/// `text` 文本内容
/// `offset` 文本偏移(位置)
/// `maxWith` 绘制最大宽度
/// `maxLines` 行数
/// `style` 字体规格
/// `textAlign` 对齐方式
/// `matrix` 形变值
static text(
Canvas canvas,
String text,
Offset offset, {
// 文本宽度
double maxWith = 120,
int? maxLines = 2,
TextStyle? style,
TextAlign textAlign = TextAlign.center,
Matrix4? matrix,
String? ellipsis,
}) {
// 添加矩阵变换
final newOffset = offset.transform(matrix);
// 绘制文字
var paragraphBuilder = ui.ParagraphBuilder(
ui.ParagraphStyle(
fontFamily: style?.fontFamily,
textAlign: textAlign,
// 矩阵变换字体缩放设置
fontSize: (style?.fontSize ?? 14) * (matrix?.sacle ?? 1),
fontWeight: style?.fontWeight,
height: style?.height,
maxLines: maxLines,
ellipsis: ellipsis,
),
);
paragraphBuilder.pushStyle(ui.TextStyle(
color: style?.color, textBaseline: ui.TextBaseline.alphabetic));
paragraphBuilder.addText(text);
var paragraph = paragraphBuilder.build();
// 缩放是宽度也进行修改
paragraph.layout(
ui.ParagraphConstraints(width: maxWith * (matrix?.sacle ?? 1) + 2));
canvas.drawParagraph(paragraph, Offset(newOffset.dx, newOffset.dy));
}
// 绘制中间标题,这个比较简单
_drawTitle(
Canvas canvas,
Matrix4 matrix,
) {
/// 屏幕中点
final center = _title.textBoxRect.center;
final titleStyle = _title.style;
final textSize = _title.textSize;
final rect = _title.textBoxRect;
final textPaint = Paint();
textPaint.style = PaintingStyle.fill;
textPaint.shader = ui.Gradient.linear(
Offset(rect.left, center.dy).transform(matrix),
Offset(rect.right, center.dy).transform(matrix),
[const Color(0xff53A2FF), const Color(0xff076EE4)]);
final path = Path()
..addRRect(
RRect.fromRectAndRadius(
rect,
const Radius.circular(4),
),
);
// 对绘制路劲进行矩阵变化
canvas.drawPath(path.transform(matrix.storage), textPaint);
// 文字
DrawUtils.text(
canvas,
title,
Offset(rect.left + _title.textPadding.left,
rect.top + _title.textPadding.top),
style: titleStyle,
maxWith: textSize.width,
matrix: matrix,
ellipsis: '...',
// maxLines: 10,
);
}
/// 绘制数据列表,针对每个同级节点的绘制
///
/// `canvas` 画布
/// `data` 数据列表
/// `superBoxRect` 上级树形结构的大小
/// `matrix` 手势变换矩阵
/// `isRight` 树形结构延伸方向
_drawCompanyList(
Canvas canvas, List<DataTree> data, Rect superBoxRect, Matrix4 matrix,
{bool isRight = true}) {
for (var i = 0; i < data.length; i++) {
final item = data[i];
// 单个节点的绘制
_drawItemBox(canvas, data, i, matrix, isRight: isRight);
if (item.children.isNotEmpty) {
// 绘制子集节点
_drawCompanyList(canvas, item.children, item.boxRect, factor.value,
isRight: isRight);
}
}
// 横线
_drawPointLine(canvas, data.first, superBoxRect, matrix, isRight);
}
/// 树图节点绘制
_drawItemBox(Canvas canvas, List<DataTree> data, int index, Matrix4 matrix,
{bool isRight = true}) {
final flag = isRight ? 1 : -1;
var item = data[index];
/// 文本绘制中点
final center = Offset(item.boxRect.center.dx + flag * item.linePadding / 2,
item.boxRect.center.dy);
final titleStyle = textStyle.copyWith(
color: item.title.contains('更多') ? const Color(0xff1074E7) : null,
);
final textSize = item.textSize;
// 背景
final padding = item.textPadding;
final rect = Rect.fromCenter(
center: center,
width: textSize.width + padding.horizontal,
height: textSize.height + padding.vertical);
final textPaint = Paint();
var path = Path()
..addRRect(
RRect.fromRectAndRadius(
rect,
const Radius.circular(4),
),
);
path = path.transform(matrix.storage);
textPaint.style = PaintingStyle.fill;
textPaint.color = const Color(0xffF5F8FE);
canvas.drawPath(path, textPaint);
textPaint.style = PaintingStyle.stroke;
textPaint.color = const Color(0xffE7ECF5);
canvas.drawPath(path, textPaint);
// 修改值,保存文本框大小的绘制区域,用于判定交互是否命中
data[index] = data[index].copyWith(path: path);
// 文字
DrawUtils.text(
canvas,
item.title,
Offset(center.dx - textSize.width / 2, center.dy - textSize.height / 2),
style: titleStyle,
maxWith: textSize.width,
matrix: matrix,
maxLines: 1,
);
// 绘制结构图线条
final linePaint = Paint();
linePaint.strokeWidth = 1;
linePaint.style = PaintingStyle.stroke;
linePaint.color = const Color(0xff2A86F1);
final linePath = Path();
// 文本框边,重叠的竖线
final dx = isRight ? rect.centerLeft.dx : rect.centerRight.dx;
linePath.moveTo(dx, rect.center.dy - 4);
linePath.lineTo(dx, rect.center.dy + 4);
// canvas.drawPath(path, linePaint);
// 横线间隔(不包含圆角=8的距离)
linePath.moveTo(dx, rect.center.dy);
linePath.lineTo(
isRight ? dx - item.linePadding + 8 : dx + item.linePadding - 8,
rect.center.dy);
// canvas.drawPath(path, linePaint);
/// 线条是否带圆角
final isRaduis =
(data.length >= 2 && (index == 0 || index == data.length - 1));
if (isRaduis) {
if (index == 0) {
linePath.arcTo(
Rect.fromLTWH(dx - flag * item.linePadding - (isRight ? 0 : 8),
rect.center.dy, 8, 8),
3 * pi / 2,
-flag * pi / 2,
false);
linePath.lineTo(dx - flag * item.linePadding, item.boxRect.bottom);
}
if (index == data.length - 1) {
linePath.arcTo(
Rect.fromLTWH(dx - flag * item.linePadding - (isRight ? 0 : 8),
rect.center.dy - 8, 8, 8),
pi / 2,
flag * pi / 2,
false);
linePath.lineTo(dx - flag * item.linePadding, item.boxRect.top);
}
} else {
linePath.lineTo(dx - flag * item.linePadding, item.boxRect.center.dy);
if (data.length > 1) {
linePath.moveTo(dx - flag * item.linePadding, item.boxRect.top);
linePath.lineTo(dx - flag * item.linePadding, item.boxRect.bottom);
}
}
canvas.drawPath(linePath.transform(matrix.storage), linePaint);
// 绘制展开图标
if (item.isMore && item.children.isEmpty) {
_drawExpandIcon(canvas, item.boxRect, matrix, isRight);
}
}
/// 绘制列表模块 距离父级模块的 圆点横线
/// |
/// O-----|------
/// |
///
///
/// |
/// ------|-----O
/// |
///
_drawPointLine(Canvas canvas, DataTree item, Rect superBoxRect,
Matrix4 matrix, bool isRight) {
final boxRect = item.boxListRect;
// 左右间隔线
final linePaint = Paint();
linePaint.strokeWidth = 1;
final path = Path();
path.moveTo(
isRight ? superBoxRect.centerRight.dx : superBoxRect.centerLeft.dx,
superBoxRect.center.dy);
path.addArc(
Rect.fromLTWH(
(isRight
? superBoxRect.centerRight.dx
: superBoxRect.centerLeft.dx) -
2,
superBoxRect.center.dy - 2,
4,
4),
isRight ? 0 : pi,
2 * pi);
linePaint.style = PaintingStyle.fill;
linePaint.color = Colors.white;
canvas.drawPath(path.transform(matrix.storage), linePaint);
path.lineTo(
isRight ? boxRect.centerLeft.dx + 40 : boxRect.centerRight.dx - 40,
boxRect.center.dy);
linePaint.style = PaintingStyle.stroke;
linePaint.color = const Color(0xff2A86F1);
canvas.drawPath(path.transform(matrix.storage), linePaint);
}
整个树图的绘制就是这些方法了,每一个节点 Rect 计算完成后,我们的绘制操作就变得简单起来,个人认为 canvas 主要还是对坐标、路径等的计算过程比较麻烦,我们逐步拆解后整个绘制过程也会简单许多。
最后贴一下 CustomPaint 的相关代码:
...
/// 矩阵变化参数
final Matrix4Animation _transform = Matrix4Animation(
Matrix4(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1),
);
...
@override
Widget build(BuildContext context) {
// LayoutBuilder 作用是获取当前父级组件的大小,然后设置画布大小
return LayoutBuilder(builder: (context, constraints) {
return GestureDetector(
onTapDown: (TapDownDetails details) {
final point = details.globalPosition;
if (_items.isNotEmpty) {
final res = _checkTapPoint(point, _items);
if (res != null) {
widget.onItemsTap?.call(res, _items, res.indexs);
}
}
if (_extraItems.isNotEmpty) {
final res = _checkTapPoint(point, _extraItems);
if (res != null) {
widget.onExtraTap?.call(res, _extraItems, res.indexs);
}
}
},
child: MatrixGestureDetector(
shouldRotate: false,
onMatrixUpdate: (
Matrix4 matrix,
Matrix4 translationDeltaMatrix,
Matrix4 scaleDeltaMatrix,
Matrix4 rotationDeltaMatrix,
) {
_transform.value = matrix;
},
child: RepaintBoundary(
child: CustomPaint(
painter: DataTreePainter(
_transform,
items: widget.items,
extraItems: widget.extraItems,
title: widget.title,
// *** 这里没有采用setStates刷新视图,而是使用 CustomPainter 的
// 可见听属性 `Listenable? repaint ` 进行刷新的,所以每次绘制完
// 成将计算的结果返回父级组件,判断交互命中 (这里可以优化整改下~)
drawComplete: (p0, p1) {
_items = p0;
_extraItems = p1;
},
),
size: Size(constraints.maxWidth, constraints.maxHeight),
),
),
),
);
});
}
...
问题还有很多,比如额外新增子节点时,绘制并没有单独绘制受影响部分,而是整个画布的重绘;比如矩阵变换后画布也会重绘(感觉这里可以优化下,但是从哪里入手还没头绪)。
但还是完结撒花,哈哈哈。🎉🎉🎉
github:data_tree_demo