Flutter 的自定义 UI 系列(四)-示例:贝塞尔曲线和点击事件

1,170 阅读3分钟


自定义UI系列专栏



前言

图表是前端UI中前端的效果,通常我们可以用第三方的插件实现。但 APP 端的UI设计要求比较高,用第三方插件很可能与UI设计稿的风格不符,而且第三方插件扩展性也比较差。
Flutter 的 Canvas 提供了很多几何图形的绘制,天生的对数据图表有很好的支持。这篇文章将展示一个贝塞尔曲线图绘制和图表的点击事件,此UI效果来源于群里某个朋友的实际开发需求。

具体效果如下:

  • 初始化10个数据点,绘制一条平滑的曲线并穿过每个数据点
  • 移动数据点,可以改变数据点的位置和曲线
  • 点击在曲线上,可以增加一个数据点
  • 长按数据点,可以删除这个数据点

yb7b5-ft2bf.gif

实现思路

通过后台数据或其他方式获取数据后,转换成 Canvas 的坐标,并通过 Path.cubicTo 绘制出贝塞尔曲线。绘制过程中要实现:

  • 多个数据点用平滑的曲线连接
  • 点击事件的判断和分发
  • 数据点的增加、移动、删除

关于贝塞尔曲线的数学原理,可以搜索其他文章

数据点的计算

通常数据是后台返回的,我们在前端在转换成坐标数据。我这里用的是模拟数据,直接模拟了10个数据

    var list = List.generate(10, (index) {
      return Offset(15 + index * 40, Random().nextDouble() * 100);
    }).toList();
    _dataList.addAll(list);

曲线的绘制

  • 控制点的计算 多点贝塞尔曲线的控制点计算有很多方式,我这里用的是两个数据的前后数据点作为控制点,可以保证整体曲线的平滑.
void _processPoints() {
  var points = <Offset>[]..addAll(dataList);
  path.reset();
  points.insert(0, Offset(points.first.dx, points.first.dy));
  points.add(Offset(points.last.dx, points.last.dy));
  points.add(Offset(points.last.dx, points.last.dy));
  path.moveTo(points.first.dx, points.first.dy);
  for (int i = 1; i < points.length - 3; i++) {
    var ax = points[i].dx + (points[i + 1].dx - points[i - 1].dx) * ctrlValueT;
    var ay = points[i].dy + (points[i + 1].dy - points[i - 1].dy) * ctrlValueT;
    var bx = points[i + 1].dx - (points[i + 2].dx - points[i].dx) * ctrlValueT;
    var by = points[i + 1].dy - (points[i + 2].dy - points[i].dy) * ctrlValueT;
    path.cubicTo(ax, ay, bx, by, points[i + 1].dx, points[i + 1].dy);
  }
}

用 path.cubicTo 连接每个数据点,用 canvas.drawPath 绘制这条曲线 DemoBezier1.png


  • 数据点绘制
canvas.drawPoints(PointMode.points, dataList, paintPoints)

用 canvas.drawPoints 绘制每个数据点

DemoBezier2.png

通过数据转换、控制点计算和曲线绘制的步骤,已经实现了一条平滑的曲线穿过数据点的效果。


点击事件

在 CustomPaint 外用 GestureDetector 包裹,监听屏幕的触摸事件并分发给 CustomPaint。
GestureDetector 中这里需要处理的事件:

  • onPanDown: 手指按下时的回调,添加数据点
  • onPanUpdate: 手指移动时的回调,修改数据点的位置
  • onLongPressStart:手指长按时的回调,删除数据点

当手指触控到 CustomPaint 时,如何判断触控位置是不是数据点或者数据线呢。

点击事件中回调的位置,与数据点位置对比,坐标 x 和 y 轴方向上的差值都小于10,即可认为是点击事件触发在指定的数据点。

void _onTap(DragDownDetails detail) {
  indexOfPoint = _dataList.indexWhere((element) {
    return (element.dx - detail.localPosition.dx).abs() < 10 
            && (element.dy - detail.localPosition.dy).abs() < 10;
  });
  if (indexOfPoint > 0) {
    // 点击了数据点
  }
}

而对于数据曲线,则需要先计算曲线上每个点的位置。计算方法可以利用 CustomPaint 绘制时的 Path 提供的 PathMetric 。相关概念可以参考上一篇的 PathMetrics 部分。
具体实现的代码如下: pointInBezierLine 就是曲线上数据点的集合

var metric = path.computeMetrics().first;
var pointInBezierLine = <Offset>[];
for (int i1 = 0; i1 < metric.length; i1++) {
  var tangent = metric.getTangentForOffset(i1.toDouble());
  if (tangent?.position != null) {
    pointInBezierLine.add(tangent!.position);
  }
}

然后用判断点击是否数据点位置的同样的方法,来判断是否点击到了数据曲线上。如果点击在曲线上,则对增加一个数据点到 dataList 中,并重新绘制曲线。

  void _onTap(DragDownDetails detail) {
    var indexOfLine = _pointInLine.indexWhere((element) {
      return (element.dx - detail.localPosition.dx).abs() < 10 
            && (element.dy - detail.localPosition.dy).abs() < 10;
    });
    if (indexOfLine > 0) {
      //点击了曲线
      _dataList.add(detail.localPosition);
      _dataList.sort((e1, e2) => e1.dx.compareTo(e2.dx));
      setState(() {});
    }
  }

同理,如果 GestureDetector 中 onPanUpdate 回调移动事件时,修改对应的数据点信息并重新绘制曲线。 onLongPressStart 回调长按事件时,删除对应的数据并重新绘制曲线。

// 移动数据点
void _onUpdate(DragUpdateDetails details) {
  if (indexOfPoint < 0) return;
  if (details.localPosition.dy < 0 || details.localPosition.dy > 200) return;
  _dataList[indexOfPoint] = details.localPosition;
  setState(() {});
}
// 删除数据点
  void _onDelPoint(LongPressStartDetails detail) {
    var indexOfPoint = _dataList.indexWhere((element) => (element.dx - detail.localPosition.dx).abs() < 10 && (element.dy - detail.localPosition.dy).abs() < 10);
    if (indexOfPoint > 0) {
      _dataList.removeAt(indexOfPoint);
    }
    setState(() {});
  }

以上步骤就实现了一个简单的三阶贝塞尔曲线的绘制和数据点控制的功能,整体需求比较简单。