Flutter 自定义 Widget 之饼形图实战

本文主要讲述了 Flutter 如何实现自定义 Widget 以及自定义饼形图实战,如有不当之处敬请指正。
阅读本文大约需要6分钟。

背景

Flutter 官方目前已经提供很多的小部件,可以直接使用,有 Material 风格的小部件,也有 iOS 风格的小部件,还有一些布局相关的小部件。在正常开发中能满足绝大多的页面场景,但是仍有部分小部件是官方没有提供的。虽然官方没有提供完整的小部件,但是官方提供了让我们自定义小部件的功能。

介绍

在 Flutter 中自定义 Widget 常用的有二种方式:通过组合其他 Widget 、自绘。

  1. 组合其他 Widget

    这种方式是通过拼装其他基础的 Widget 来组合成一个新的 Widget ,比如使用 Icon 和 Text 放在 Row 来组合成一个带图标功能的 Text。

    在平时的 Flutter 中经常会使用这种方法来实现不同的布局。

  2. 自绘

    如果遇到无法通过组合完成的页面UI,或者一些独特的UI,比如圆形进度条,统计图表等。这个时候最好的办法就是通过自定义 Widget 来绘画出我们所需要的样子,在 Flutter 中提供了 CustomPainter 和 Canvas 来供我们绘制。

方法

对于复杂或者不规则的 UI ,我们可能无法使用组合的方式完成。比如:需要一个三角形,五边形,一个折线图,一个饼形图,数字进度条等。有时候我们可以直接让 UI 设计师直接提供图片去展示,但是有些数据是动态的或者 UI 是需要和用户交互的,这个时候使用图片可能就达不到我们所需要的效果了,就需要我们自己去实现绘制 UI 了。

几乎所有的 UI 系统都会提供一个自绘 UI 的接口,这个接口通常会提供一个 2D 的画布 Canvas,在 Canvas 内部封装了一些基础的绘制 API,我们只需要调用相关的绘制 API 就可以绘制各种自定的图形了。

在 Flitter 中,它为我们提供了一个 CustomPainter Widget,我们可以结合画笔 CustomPainter 来实现自定义 Widget。

继承 CustomPainter 需要实现这个类的两个关键方法:paintshouldRepaint 。在 paint 方法决定绘制什么,使用传递过来的 canvas 和 size 完成绘制,shouldRepaint 决定否需要重绘的,返回 false 代表这个 Widget 绘制完成后不需要重新绘制。

绘制

想要完成绘制仅靠 canvas 是无法完成绘制的,还需要一个画笔 paint 。

构建 paint

  Paint _paint = Paint()
    ..color = Colors.red
    ..isAntiAlias = true
    ..style = PaintingStyle.fill
    ..strokeWidth = 12.0;
  1. color: 设置画笔颜色;

  2. isAntiAlias:是否开启抗锯齿;

  3. style:设置填充模式;

  4. strokeWidth:设置画笔粗细

    Paint 的设置有很多,但是正常开发中不会使用那么多的属性,具体的可以参考一下官方文档;

Canvas用法

  1. 绘制点

    drawPoints(PointMode pointMode, List points, Paint paint)

    绘制点只需要传入PointMode枚举和 point 集合就可以了。

    pointMode枚举有三个:points(点),lines(线,隔点连接),polygon(线,相邻连接)

  1. 绘制圆

    canvas.drawCircle(offset, radius, paint)

    绘制圆需要传入圆心 offset ,半径 radius,设置paint的填充模式可以绘制填充和不填充的圆。

  2. 绘制椭圆

    drawOval(Rect rect, Paint paint)

    绘制椭圆需要传入一个矩形 Rect 来确定大小和位置。

  3. 绘制圆弧

    drawArc(Rect rect, double startAngle, double sweepAngle, bool useCenter, Paint paint)

    绘制圆弧需要传入的参数比较多一点,首先需要一个矩形 Rect 来确定大小和位置,接着传入开始弧度 startAngle,多少弧度 sweepAngle,是否使用中心点绘制 useCenter。

    这里需要注意的是,Android绘制 startAngle 和 sweepAngle 使用的是角度,这里使用的弧度,角度和弧度的换算为:

    弧度 = 角度 * PI/180;

    角度 = 弧度 * 180/PI;

  4. 绘制圆角矩形

    drawRRect(RRect rrect, Paint paint)

    绘制圆角矩形比较简单,只需要传入 RRect,RRect 可直接使用 fromRectAndRadius,传入矩形大小位置 Rect 和圆角大小的 Radius。

常用的绘制方法就这些,canvas提供了很多的绘制,可以去官方文档查看。

实战饼形图

效果图

效果图

从图中看大致可以分为三个步骤:

  1. 绘制扇形区域
  2. 绘制每个扇形区域的线
  3. 绘制文字

1、确定大小位置

if (size.width > size.height) {
  radius = size.height / 3;
} else {
  radius = size.width / 3;
}

line1 = radius / 3;
line2 = radius / 2;

canvas.translate(size.width / 2, size.height / 2);

Rect rect = Rect.fromLTRB(-radius, -radius, radius, radius);

首先根据size的大小确定我们所要绘制的圆形的半径,这里的半径设置为宽高中较小的一边的三分之一,为什么不是一半,是因为后面需要绘制线和文字,所有需要预留出来。

接着确定绘制圆的圆心,这里直接使用 Canvas 的 translate 方法把画布移动到圆心,接着设置圆的大小和位置为: Rect.fromLTRB(-radius, -radius, radius, radius)

2、绘制扇形

确定了圆形的圆心大小后,我们就需要绘制组成圆形的每一个扇形,这里我们绘制扇形需要知道扇形的大小,所有我们需要先定义一个数据类:

abstract class BasePieEntity{

  String getTitle();

  double getData();

  double angle;

  Color getColor();

}

定义了一个抽象类,只需要实现 getTitle、getData 和 getColor 这三个方法,具体的数据类可以根据业务需求定义,基础该基础类即可。

在接收数据的时候,需要统计出每一个数据需要多大的角度:

var total = 0.0;

this.entities.forEach((e) {
  total += e.getData();
});

this.entities.forEach((e) {
  e.angle = e.getData() / total * 360;
});

计算出每条数据的角度,接下来我们只需要循环这个数据,根据数据中的角度绘制每一个扇形区域即可:

for (var i = 0; i < entities.length; i++) {
  var entity = entities[i];
  _paint.color = entity.getColor();
  canvas.drawArc(rect, (currentAngle * pi / 180), (entity.angle * pi / 180),
      true, _paint);
  currentAngle += entity.angle;
}

3、绘制线

首先绘制线分为两个部分,一部分是斜的,一部分是横线,首先我们绘制斜线:

绘制斜线首先找到绘制线的两个坐标点,一个坐标点在扇形的中间,且点在扇形的边缘,另一个转折点是圆心到起始点的延长线上。

首先通过角度确定第一个点,角度为起始角度+绘制角度的二分之一,通过三角函数计算出绘制线的起始点:

// 1,计算开始坐标和转折点坐标
var startX = r * (cos((currentAngle + (angle / 2)) * (pi / 180)));
var startY = r * (sin((currentAngle + (angle / 2)) * (pi / 180)));

同理根据延长线的大小加上半径使用三角函数即可得出转折点的坐标:

var stopX = (r + line1) * (cos((currentAngle + (angle / 2)) * (pi / 180)));
var stopY = (r + line1) * (sin((currentAngle + (angle / 2)) * (pi / 180)));

计算完起始点和转折点需要计算终点的坐标,终点的坐标分为两种情况,一种是在圆心的左边,那横线就是向左绘制,另一种就是在右边,横线需要向右边绘制,根据判断左右得出终点的坐标:

// 2、计算坐标在左边还是在右边,并计算横线结束坐标
var endX;
if (stopX - startX > 0) {
  endX = stopX + line2;
} else {
  endX = stopX - line2;
}

得到了起始点,转折点,和结束点的坐标,接下来需要根据相应的坐标点绘制斜线和横线即可:

// 3、绘制斜线和横线
canvas.drawLine(Offset(startX, startY), Offset(stopX, stopY), _paint);
canvas.drawLine(Offset(stopX, stopY), Offset(endX, stopY), _paint);

4、绘制文字

绘制完线,接下来需要绘制横线上方和下方的文字,上方绘制扇形所占的百分比,下方绘制标题。

在 Flutter 中绘制文字不是使用 Canvas 绘制,而是使用画笔 TextPainter 绘制。

在 TextPainter 中可以设置文字画笔的风格和文字的属性:

// 文字画笔 风格定义
TextPainter _newVerticalAxisTextPainter(String text, Color color) {
  return _textPainter
    ..text = TextSpan(
      text: text,
      style: new TextStyle(
        color: color,
        fontSize: 12.0,
      ),
    );
}

首先我们绘制也需要计算文字开始的坐标:

    // 4、绘制文字
    // 绘制下方名称
    // 上下间距偏移量
    var offset = 4;
    // 1、测量文字
    var tp = _newVerticalAxisTextPainter(name, color);
    tp.layout();

    var w = tp.width;
    // 2、计算文字坐标
    var textStartX;
    if (stopX - startX > 0) {
      if (w > line2) {
        textStartX = (stopX + offset);
      } else {
        textStartX = (stopX + (line2 - w) / 2);
      }
    } else {
      if (w > line2) {
        textStartX = (stopX - offset - w);
      } else {
        textStartX = (stopX - (line2 - w) / 2 - w);
      }
    }

同理,计算出上方百分比文字的坐标:

// 绘制上方百分比,步骤同上
var per = (angle / 360.0 * 100).toStringAsFixed(2) + "%";
var tpPre = _newVerticalAxisTextPainter(per, color);
tpPre.layout();

w = tpPre.width;
var h = tpPre.height;

if (stopX - startX > 0) {
  if (w > line2) {
    textStartX = (stopX + offset);
  } else {
    textStartX = (stopX + (line2 - w) / 2);
  }
} else {
  if (w > line2) {
    textStartX = (stopX - offset - w);
  } else {
    textStartX = (stopX - (line2 - w) / 2 - w);
  }
}

计算得出起始坐标,接下来绘制下方文字:

tp.paint(canvas, Offset(textStartX, stopY + offset));

上方百分比文字:

tpPre.paint(canvas, Offset(textStartX, stopY - offset - h));

至此,绘制一个饼形图就完成了。

结尾

完整代码奉上GitHub地址:fluter_demo ,欢迎star和fork。

到此,本文就结束了,如有不当之处敬请指正,一起学习探讨,谢谢🙏。

推荐阅读更多精彩内容