Flutter自绘组件:扇形图

2,096 阅读5分钟

简介

在开发过程中通常会遇到一些不规则的UI,比如不规则的线条,多边形,统计图表等等,用那些通用组件通过组合的方式无法进行实现,这就需要我们自己进行绘制。可以通过使用CuntomPaint组件并结合画笔CustomPainter去进行手动绘制各种图形。

CustomPaint介绍

CustomPaint是一个继承SingleChildRenderObjectWidgetWidget,这里主要介绍几个重要参数:
childCustomPaint的子组件。 painter: 画笔,绘制的图形会显示在child后面。
foregroundPainter:前景画笔,绘制的图形会显示在child前面。
size:绘制区域大小。

CustomPainter介绍

CustomPainter是一个抽象类,通过自定义一个类继承自CustomPainter,重写paintshouldRepaint方法,具体绘制主要在paint方法里。

paint介绍

主要两个参数: Canvas:画布,可以用于绘制各种图形。 Size:绘制区域的大小。

void paint(Canvas canvas, Size size)

shouldRepaint介绍

在Widget重绘前会调用该方法确定时候需要重绘,shouldRepaint返回ture表示需要重绘,返回false表示不需要重绘。

bool shouldRepaint(CustomPainter oldDelegate)

示例

这里我们通过绘制一个饼状图来演示绘制的整体流程。

pie_chart_view.gif

使用CustomPaint

首先,使用CustomPaint,绘制大小为父组件最大值,传入自定义painter

@override
Widget build(BuildContext context) {
    return CustomPaint(
      size: Size.infinite,
      painter: PieChartPainter(),
    );
}

自定义Painter

自定义PieChartPainter继承CustomPainter

class PieChartPainters extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return oldDelegate != this;
  }
}

绘制

接着我们来实现paint方法进行绘制

@override
void paint(Canvas canvas, Size size) {
    //移动到中心点
    canvas.translate(size.width / 2, size.height / 2);
    //绘制饼状图
    _drawPie(canvas, size);
    //绘制扇形分割线
    _drawSpaceLine(canvas);
    //绘制中心圆
    _drawHole(canvas, size);
}
绘制饼状图

我们以整个画布的中点为圆点,然后计算出每个扇形的角度区域,通过canvas.drawArc绘制扇形。

pie_chart_view1.png

void _drawPie(Canvas canvas, Size size) {
    var startAngle = 0.0;
    var sumValue = models.fold<double>(0.0, (sum, model) => sum + model.value);
    for (var model in models) {
      Paint paint = Paint()
        ..style = PaintingStyle.fill
        ..color = model.color;
      var sweepAngle = model.value / sumValue * 360;
      canvas.drawArc(Rect.fromCircle(radius: model.radius, center: Offset.zero),
          startAngle * pi / 180, sweepAngle * pi / 180, true, paint);

      //为每一个区域绘制延长线和文字
      _drawLineAndText(
          canvas, size, model.radius, startAngle, sweepAngle, model);

      startAngle += sweepAngle;
    }
}
绘制延长线以及文本

延长线的起点为扇形区域边缘中点位置,长度为一个固定的长度,转折点坐标通过半径加这个固定长度和三角函数进行计算,然后通过转折点的位置决定横线终点的方向,而横线的长度则根据文字的宽度决定,然后通过canvas.drawLine进行绘制直线。
文本绘制使用TextPainter.paint进行绘制,paint方法里面最终是通过canvas.drawParagraph进行绘制的。
最后再在文字的前面通过canvas.drawCircle绘制一个小圆点。

pie_chart_view2.png

 void _drawLineAndText(Canvas canvas, Size size, double radius,
      double startAngle, double sweepAngle, PieChartModel model) {
    var ratio = (sweepAngle / 360.0 * 100).toStringAsFixed(2);

    var top = Text(model.name);
    var topTextPainter = getTextPainter(top);

    var bottom = Text("$ratio%");
    var bottomTextPainter = getTextPainter(bottom);

    // 绘制横线
    // 计算开始坐标以及转折点的坐标
    var startX = radius * (cos((startAngle + (sweepAngle / 2)) * (pi / 180)));
    var startY = radius * (sin((startAngle + (sweepAngle / 2)) * (pi / 180)));

    var firstLine = radius / 5;
    var secondLine =
        max(bottomTextPainter.width, topTextPainter.width) + radius / 4;
    var pointX = (radius + firstLine) *
        (cos((startAngle + (sweepAngle / 2)) * (pi / 180)));
    var pointY = (radius + firstLine) *
        (sin((startAngle + (sweepAngle / 2)) * (pi / 180)));

    // 计算坐标在左边还是在右边
    // 并计算横线结束坐标
    // 如果结束坐标超过了绘制区域,则改变结束坐标的值
    var marginOffset = 20.0; // 距离绘制边界的偏移量
    var endX = 0.0;
    if (pointX - startX > 0) {
      endX = min(pointX + secondLine, size.width / 2 - marginOffset);
      secondLine = endX - pointX;
    } else {
      endX = max(pointX - secondLine, -size.width / 2 + marginOffset);
      secondLine = pointX - endX;
    }

    Paint paint = Paint()
      ..style = PaintingStyle.fill
      ..strokeWidth = 1
      ..color = Colors.grey;

    // 绘制延长线
    canvas.drawLine(Offset(startX, startY), Offset(pointX, pointY), paint);
    canvas.drawLine(Offset(pointX, pointY), Offset(endX, pointY), paint);

    // 文字距离中间横线上下间距偏移量
    var offset = 4;
    var textWidth = bottomTextPainter.width;
    var textStartX = 0.0;
    textStartX =
        _calculateTextStartX(pointX, startX, textWidth, secondLine, textStartX, offset);
    bottomTextPainter.paint(canvas, Offset(textStartX, pointY + offset));

    textWidth = topTextPainter.width;
    var textHeight = topTextPainter.height;
    textStartX =
        _calculateTextStartX(pointX, startX, textWidth, secondLine, textStartX, offset);
    topTextPainter.paint(canvas, Offset(textStartX, pointY - offset - textHeight));

    // 绘制文字前面的小圆点
    paint.color = model.color;
    canvas.drawCircle(
        Offset(textStartX - 8, pointY - 4 - topTextPainter.height / 2),
        4,
        paint);
}
绘制扇形分割线

在绘制完扇形之后,然后在扇形的开始的那条边上绘制一条直线,起点为圆点,长度为扇形半径,终点的位置根据半径和扇形开始的那条边的角度用三角函数进行计算,然后通过canvas.drawLine进行绘制。

pie_chart_view3.png

void _drawSpaceLine(Canvas canvas) {
    var sumValue = models.fold<double>(0.0, (sum, model) => sum + model.value);
    var startAngle = 0.0;
    for (var model in models) {
      _drawLine(canvas, startAngle, model.radius);
      startAngle += model.value / sumValue * 360;
    }
}

void _drawLine(Canvas canvas, double angle, double radius) {
    var endX = cos(angle * pi / 180) * radius;
    var endY = sin(angle * pi / 180) * radius;
    Paint paint = Paint()
      ..style = PaintingStyle.fill
      ..color = Colors.white
      ..strokeWidth = spaceWidth;
    canvas.drawLine(Offset.zero, Offset(endX, endY), paint);
  }
绘制内部中心圆

这里可以通过传入的参数判断是否需要绘制这个圆,使用canvas.drawCircle进行绘制一个与背景色一致的圆。

pie_chart_view4.png

void _drawHole(Canvas canvas, Size size) {
    if (isShowHole) {
      holePath.reset();
      Paint paint = Paint()
        ..style = PaintingStyle.fill
        ..color = Colors.white;
      canvas.drawCircle(Offset.zero, holeRadius, paint);
    }
}

触摸事件处理

接下来我们来处理点击事件,当我们点击某一个扇形区域时,此扇形需要突出显示,如下图:

pie_chart_view5.png

重写hitTest方法
注意
这个方法的返回值决定是否响应事件。
默认情况下返回null,事件不会向下传递,也不会进行处理; 如果返回true则当前组件进行处理事件; 如果返回false则当前组件不会响应点击事件,会向下一层传递;

我直接在这里处理点击事件,通过该方法传入的offset确定点击的位置,如果点击位置是在圆形区域内并且不在中心圆内则处理事件同时判断所点击的具体是哪个扇形,反之则恢复默认状态。

@override
bool? hitTest(Offset offset) {
    if (oldTapOffset.dx==offset.dx && oldTapOffset.dy==offset.dy) {
      return false;
    }
    oldTapOffset = offset;
    for (int i = 0; i < paths.length; i++) {
      if (paths[i].contains(offset) &&
          !holePath.contains(offset)) {
        onTap?.call(i);
        oldTapOffset = offset;
        return true;
      }
    }
    onTap?.call(-1);
    return false;
}

至此,我们通过onTap向上传递出点击的是第几个扇形,然后进行处理,更新UI就可以了。

动画实现

pie_chart_view.gif

这里通过Widget继承ImplicitlyAnimatedWidget来实现,ImplicitlyAnimatedWidget是一个抽象类,继承自StatefulWidget,既然是StatefulWidget那肯定还有一个StateState继承AnimatedWidgetBaseState(此类继承自ImplicitlyAnimatedWidgetState),感兴趣的小伙伴可以直接去看源码

实现AnimatedWidgetBaseState里面的forEachTween方法,主要是用于来更新Tween的初始值。

@override
void forEachTween(TweenVisitor<dynamic>visitor) {
   customPieTween = visitor(customPieTween, end, (dynamic value) {
      return CustomPieTween(begin: value, end: end);
    }) as CustomPieTween;
}

自定义CustomPieTween继承自Tween,重写lerp方法,对需要做动画的参数进行处理

class CustomPieTween extends Tween<List<PieChartModel>> {
  CustomPieTween({List<PieChartModel>? begin, List<PieChartModel>? end})
      : super(begin: begin, end: end);

  @override
  List<PieChartModel> lerp(double t) {
    List<PieChartModel> list = [];
    begin?.asMap().forEach((index, model) {
      list.add(model
        ..radius = lerpDouble(model.radius, end?[index].radius ?? 100.0, t));
    });
    return list;
  }

  double lerpDouble(double radius, double radius2, double t) {
    if (radius == radius2) {
      return radius;
    }
    var d = (radius2 - radius) * t;
    var value = radius + d;
    return value;
  }
}

完整代码

感兴趣的小伙伴可以直接看源码 GitHub:chart_view