flutter canvas绘制可交互的多级树图

708 阅读11分钟

背景

之前项目中使用的图例基本出自 flutter_echarts: ^2.4.0 (用的是自己升级webview版本后的插件),原理很简单就是通过webview渲染 echarts,不得不说库是真的666,但我这里用不了就有点尴尬了~~

言归正传,这个UI小姐姐给的树状图想使用 echarts 偷懒难度不小,具体有以下问题:

  1. echarts 毕竟是前端库,半路出家查询配置多少有点眼花缭乱的感觉;
  2. UI出图为左右两支数据组,查询 echarts 相关资料,并没办法比较友好的展示出双端数据流,并且展示效果修改也是是一个问题;
  3. 图例中涉及到用户交互,如果使用 echarts 埋点js方法,个人感觉可读性不高,代码过于杂糅,不方便别人查阅修改;

图例

GIF 2023-8-22 14-43-05.gif

简单明了,但也够呛 > _ <

实现思路

1692771932109.jpeg

大致的图解分块

  1. 居中标题单独绘制,具体为:
  • 中心确定为屏幕中心;
  • 确定文本 Padding 预留背景的大小;
  • 通过限制最大文本宽度,计算得到文本的 size
  • 绘制文本,绘制有色背景;
  1. 绘制每一个叶子节点
  • 和标题类似优先计算textSize,但是这里考虑整体文本不会太大,所以是文本完整显示为1行;
  • 确定文本 Padding 预留背景的大小;
  • 绘制文本、绘制背景、绘制边框、绘制连接线或其他小部件;
  1. 累计所有同级节点,确定同级节点的宽高
  • 宽度:以文本最长的那一行为准;
  • 高度:由于单文本显示为一行,如果没有子节点集影响则总体高度为每一个 textSize + textPadding ;
  • 节点包含子集:使用子集节点的累计高度替换当前节点的高度,保证多级树渲染显示不会出现内容重叠,且父级节点会增大上下留白;
  1. 添加交互,这里做了两个方案,记录分析下优劣与取舍:

    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: 当前节点下的子集的坐标及大小,该参数用于计算当前节点的整个rectboxListRect: 当前节点的整个rect,包含子集列表和自身(子集列表大小大于自身,则使用子集列表的size作为自己的大小计算);

二、拿到服务器数据后我们应该先执行哪些操作,再执行哪些操作?

  1. DataTree赋值我们的显示标题titlevalue(一些类似id的属性值)、isMore(是否显示展开标签),同时限定到交互时的展开拼接数据操作)、children(子集数据);
  2. 通过操作(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;
}

通过逐级递归完成赋值操作,后续的操作基本都是采用递归的方式处理。

  1. 先计算我们每个节点的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 剩余的宽度。

企业微信截图_16929430718336.png

示意图


  /// 绘制文字
  ///
  /// `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