阅读 2041

【 Flutter 绘制 】点集的贝塞尔曲线拟合

本文作为对掘金小册 《Flutter 绘制指南 - 妙笔生花》 的一个知识补充点,后面会更新到小册中。在此也希望记录和分享一下 Flutter 中如何通过贝塞尔曲线使折线形成曲线。源码在这

1. 问题描述

现在有一批如下的点,很容易通过 canvas.drawPoints 绘制出如下的折线。

image-20201210091839474

---->[ 点集 ]----
List<Offset> points1 = [
  Offset(0, 20),
  Offset(40, 40) ,
  Offset(80, -20),
  Offset(120, -40),
  Offset(160, -80),
  Offset(200, -20),
  Offset(240, -40),
];
复制代码

但很多时候,我们希望用一个曲线 来展示数据,而非生硬的折线。

image-20201210092206460

所以本文就来探讨一下 如何使用贝塞尔曲线对点集进行拟合

image-20201210092727772


2. 绘制点与折线

程序入口文件 main.dart , 此处横屏全屏显示。

---->[p14_bezier/s05_bezier_line/main.dart]----
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'paper.dart';

void main() {
  // 确定初始化
  WidgetsFlutterBinding.ensureInitialized();
  //横屏
  SystemChrome.setPreferredOrientations(
      [DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]);
  //全屏显示
  SystemChrome.setEnabledSystemUIOverlays([]);

  runApp(Paper());
}
复制代码

显示组件 Paper ,使用 PaperPainter 画板。

---->[p14_bezier/s05_bezier_line/paper.dart]----
class Paper extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.white,
      child: CustomPaint( painter: PaperPainter() ),
    );
  }
}
复制代码

通过简单的几步,如下的折线图便跃然纸上。其中 Coordinate 是我写的一个坐标系绘制辅助类,来方便查看点的位置,从而帮助理解。详见源码,不想用的话也不影响,删掉即可。

image-20201210091839474

---->[p14_bezier/s05_bezier_line/paper.dart]----
class PaperPainter extends CustomPainter {
  final Coordinate coordinate = Coordinate();
  List<Offset> points1 = [
    Offset(0, 20),
    Offset(40, 40) ,
    Offset(80, -20),
    Offset(120, -40),
    Offset(160, -80),
    Offset(200, -20),
    Offset(240, -40),
  ];

  Paint _helpPaint = Paint();
  Paint _mainPaint = Paint();
  Path _linePath = Path();
  
  @override
  void paint(Canvas canvas, Size size) {
    coordinate.paint(canvas, size);
    // 画布原点 移到 屏幕中心
    canvas.translate(size.width / 2, size.height / 2);
    // 绘制辅助点线 
    _drawHelp(canvas);
  }

  void _drawHelp(Canvas canvas) {
    _helpPaint..style = PaintingStyle.stroke;
    // 绘制点
    points1.forEach((element) {
      canvas.drawCircle(element, 2, 
                        _helpPaint..strokeWidth=1..color=Colors.orange);
    });
    // 绘制折线
    canvas.drawPoints(PointMode.polygon, points1, 
                        _helpPaint..strokeWidth=0.5..color=Colors.red);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}
复制代码

3. 贝塞尔曲线拟合

在下面方法中,传入一个 List<Offset> 类型的点集 points 。其中首尾两段线使用二阶贝塞尔曲线,中间的使用三阶贝塞尔曲线。起止点和控制点通过 current 当前点和 next 下一点来控制。

image-20201210092727772

void addBezierPathWithPoints(Path path, List<Offset> points) {
  for (int i = 0; i < points.length - 1; i++) {
    Offset current = points[i];
    Offset next = points[i+1];
    if (i == 0) {
      path.moveTo(current.dx, current.dy);
      // 控制点
      double ctrlX = current.dx + (next.dx - current.dx) / 2;
      double ctrlY = next.dy;
      path.quadraticBezierTo(ctrlX, ctrlY, next.dx, next.dy);
    } else if (i < points.length - 2) {
      // 控制点 1
      double ctrl1X = current.dx + (next.dx - current.dx) / 2;
      double ctrl1Y = current.dy;
      // 控制点 2
      double ctrl2X = ctrl1X;
      double ctrl2Y = next.dy;
      path.cubicTo(ctrl1X,ctrl1Y,ctrl2X,ctrl2Y,next.dx,next.dy);
    }else{
      path.moveTo(current.dx, current.dy);
      // 控制点
      double ctrlX = current.dx + (next.dx - current.dx) / 2;
      double ctrlY = current.dy;
      path.quadraticBezierTo(ctrlX, ctrlY, next.dx, next.dy);
    }
  }
}
复制代码

首先来看第一段曲线 (0, 20) 是起点 current (40, 40) 是下一点 next,对于二阶贝塞尔曲线来说,只要确定控制点就完事了。这里 控制点 x 取两点的中点横坐标,y 取 next 的纵坐标,即下面的 (10,40) 点。

image-20201210095541558

if (i == 0) {
  path.moveTo(current.dx, current.dy);
  // 控制点
  double ctrlX = current.dx + (next.dx - current.dx) / 2;
  double ctrlY = next.dy;
  path.quadraticBezierTo(ctrlX, ctrlY, next.dx, next.dy);
} 
复制代码

再看最后一段曲线 ,和第一段类似,三点的位置如下,注意这里使用的是相对于倒数第二个点的添加 relativeQuadraticBezierTo,来保证曲线的连贯性

image-20201210100219171

// 控制点
double ctrlX = (next.dx - current.dx) / 2;
double ctrlY = 0;
path.relativeQuadraticBezierTo(ctrlX, ctrlY, next.dx-current.dx, next.dy-current.dy);
复制代码

第二段曲线使用 三阶贝塞尔,控制点如下所示。

image-20201210100510624

  // 控制点 1
  double ctrl1X = current.dx + (next.dx - current.dx) / 2;
  double ctrl1Y = current.dy;
  // 控制点 2
  double ctrl2X = ctrl1X;
  double ctrl2Y = next.dy;
  path.cubicTo(ctrl1X,ctrl1Y,ctrl2X,ctrl2Y,next.dx,next.dy);
复制代码

同样后面的几条线段都是类似,控制点如下,这样就生成了连续的曲线。这里通过 addBezierPathWithPoints 方法就可以实现将一个点集编程一个曲线路径添加到指定 Path 中。

image-20201210100715497

这样使用多个点集也就会形成多个曲线。

image-20201210101239232


4. 在统计图中使用

这样在后面 16 章实现的折线统计图就可以使用曲线来替换折线,代码见 p16_chart.s03_line_plus

曲线

本篇到此结束,不止是 Flutter 中的贝塞尔曲线,其他平台、框架中的贝塞尔曲线也是类似的,所以这个知识点虽然比较很小,但很重要。很好地理解它,能提升你对贝塞尔曲线的认识,一把利器握在手里,你是要驾驭它,而不是畏惧它。