使用 Flutter 繪製圖表(二)餅狀圖?

ColdStone發表於2020-06-10

前言

接上文,本文講解如何使用 Flutter 繪製餅狀圖,最終效果如圖

使用 Flutter 繪製圖表(二)餅狀圖?

線上檢視

定義 PieChart & PiePart

第一步定義 PieChartPiePart 類。PieChart 是整個餅狀圖控制元件,有 dataslegends 兩個屬性,表示餅圖的資料和每部分的標識。 PiePart 表示餅圖的一部分,有 color, startAngle, sweepAngle 三個屬性,分別表示顏色,起始弧度值,佔據圓形的弧度值。PeiChartPainter 類實現了具體的繪製方法。

class PiePart {
  double sweepAngle;
  final Color color;
  final double startAngle;

  PiePart(
    this.startAngle,
    this.sweepAngle,
    this.color,
  );
}

class PieChart extends StatefulWidget {
  final List<double> datas;
  final List<String> legends;

  const PieChart({
    @required this.datas,
    @required this.legends,
  });

  @override
  _PieChartState createState() => _PieChartState();
}

class _PieChartState extends State<PieChart> with TickerProviderStateMixin {
  double _total = 0.0;
  final List<PiePart> _parts = <PiePart>[];

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Container(
          width: 300,
          height: 300,
          child: CustomPaint(
            painter: PeiChartPainter(
              total: _total,
              parts: _parts,
              datas: widget.datas,
              legends: widget.legends
            ),
          ),
        ),
      ],
    );
  }
}

class PeiChartPainter extends CustomPainter {
  final double total;
  final List<double> datas;
  final List<PiePart> parts;
  final List<String> legends;

  PeiChartPainter({
    @required this.total,
    @required this.datas,
    @required this.parts,
    @required this.legends,
  });

  @override
  void paint(Canvas canvas, Size size) {
    // TODO
  }

  @override
  bool shouldRepaint(PeiChartPainter oldDelegate) => true;
}
複製程式碼

繪製圓框

先繪製圖表的圓框,在 PeiChartPainter 上新增 drawCircle 方法,以圓的中心點和圓的半徑繪製一個空心圓形。

void drawCircle(Canvas canvas, Size size) {
  final sw = size.width;
  final sh = size.height;
  // 確定圓的半徑
  final double radius = math.min(sw, sh) / 2;
  // 定義中心點
  final Offset center = Offset(sw / 2, sh / 2);

  // 定義圓形的繪製屬性
  final paint = Paint()
    ..style = PaintingStyle.stroke
    ..color = Colors.grey
    ..strokeWidth = 1.0;

  // 使用 Canvas 的 drawCircle 繪製
  canvas.drawCircle(center, radius, paint);
}

@override
void paint(Canvas canvas, Size size) {
  drawCircle(canvas, size);
}
複製程式碼
使用 Flutter 繪製圖表(二)餅狀圖?

繪製標識

這一步需要先在 _PieChartState 裡面進行資料的初始化,然後繪製每個資料對應的標識,分以下幾步進行

  1. 計算出每個資料佔總和的佔比
  2. 根據佔比計算資料佔據圓的弧度值
  3. 根據之前資料佔據圓形的弧度值計算出下一個資料的起始弧度值
  4. 根據計算出的起始弧度值和佔據弧度值建立 PiePart 物件
  5. 使用 PiePart 物件繪製標識
class _PieChartState extends State<PieChart> with TickerProviderStateMixin {
  double _total = 0.0;
  final List<PiePart> _parts = <PiePart>[];

  @override
  void initState() {
    super.initState();

    List<double> datas = widget.datas;
    // 計算出資料總和
    _total = datas.reduce((a, b) => a + b);
    // 定義一個起始變數
    double startAngle = 0.0;

    for (int i = 0; i < datas.length; i++) {
      final data = datas[i];
      // 計算出每個資料所佔的弧度值
      final angle = (data / _total) * -math.pi * 2;
      PiePart peiPart;

      if (i > 0) {
        // 下一個資料的起始弧度值等於之前的資料弧度值之和
        double lastSweepAngle = _parts[i - 1].sweepAngle;
        startAngle += lastSweepAngle;
        peiPart = PiePart(startAngle, angle, colors[i]);
      } else {
        // 第一個資料的起始弧度為 0.0
        peiPart = PiePart(0.0, angle, colors[i]);
      }
      // 新增到陣列中
      _parts.add(peiPart);
    }
  }

    @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Container(
          width: 300,
          height: 300,
          child: CustomPaint(
            // 將資料傳給 PeiChartPainter
            painter: PeiChartPainter(
              total: _total,
              parts: _parts,
              datas: widget.datas,
              legends: widget.legends,
            ),
          ),
        ),
      ],
    );
  }
}
複製程式碼

在 PeiChartPainter 上新增 drawLegends 方法,在圓框的外圍繪製每部分對應的標識。

void drawLegends(Canvas canvas, Size size) {
  final sw = size.width;
  final sh = size.height;
  final double radius = math.min(sw, sh) / 2;
  final double fontSize = 12.0;

  for (int i = 0; i < datas.length; i++) {
    final PiePart part = parts[i];
    final String legend = legends[i];
    // 根據每部分的起始弧度加上自身弧度值的一半得到每部分的中間弧度值
    final radians = part.startAngle + part.sweepAngle / 2;
    // 根據三角函式計算中出標識文字的 x 和 y 位置,需要加上寬和高的一半適配 Canvas 的座標
    double x = math.cos(radians) * (radius + 32) + sw / 2 - fontSize;
    double y = math.sin(radians) * (radius + 32) + sh / 2;
    final offset = Offset(x, y);

    // 使用 TextPainter 繪製文字標識
    TextPainter(
      textAlign: TextAlign.center,
      text: TextSpan(
        text: legend,
        style: TextStyle(
          fontSize: fontSize,
          color: Colors.black,
        ),
      ),
      textDirection: TextDirection.ltr,
    )
      ..layout(
        minWidth: 0,
        maxWidth: size.width,
      )
      ..paint(canvas, offset);
  }
}

@override
void paint(Canvas canvas, Size size) {
  drawCircle(canvas, size);
  drawLegends(canvas, size);
}

複製程式碼
使用 Flutter 繪製圖表(二)餅狀圖?

計算文字位置用到的的三角函式是

angle
angle

繪製資料對應的弧形

PeiChartPainter 上新增 drawParts 方法,繪製每個資料對應的弧形。

void drawParts(Canvas canvas, Size size) {
  final sw = size.width;
  final sh = size.height;
  final double fontSize = 10.0;
  final double radius = math.min(sw, sh) / 2;
  final Offset center = Offset(sw / 2, sh / 2);

  // 建立弧形依照的矩形
  final rect = Rect.fromCenter(
    center: center,
    width: radius * 2,
    height: radius * 2,
  );
  // 設定繪製屬性
  final paint = Paint()
    ..strokeWidth = 0.0
    ..isAntiAlias = true
    ..style = PaintingStyle.fill;

  for (int i = 0; i < parts.length; i++) {
    final PiePart part = parts[i];
    // 設定每部分的顏色
    paint.color = part.color;
    // 使用 drawArc 方法畫出弧形,引數依次是依照的矩形,起始弧度值,佔據的弧度值,是否從中心點繪製,繪製屬性
    canvas.drawArc(rect, part.startAngle, part.sweepAngle, true, paint);

    final double data = datas[i];
    // 計算每部分佔比
    final String percent = (data / total * 100).toStringAsFixed(1);
    final double radians = part.startAngle + part.sweepAngle / 2;
    // 使用三角函式計算文字位置
    double x = math.cos(radians) * radius / 2 + sw / 2 - fontSize * 3;
    double y = math.sin(radians) * radius / 2 + sh / 2;
    final Offset offset = Offset(x, y);

    // 使用 TextPainter 繪製文字標識
    TextPainter(
      textAlign: TextAlign.start,
      text: TextSpan(
        text: '$data $percent%',
        style: TextStyle(
          fontSize: fontSize,
          color: Colors.white,
          fontWeight: FontWeight.bold,
        ),
      ),
      textDirection: TextDirection.ltr,
    )
      ..layout(
        minWidth: 0,
        maxWidth: size.width,
      )
      ..paint(canvas, offset);
  }
}

@override
void paint(Canvas canvas, Size size) {
  drawCircle(canvas, size);
  drawLegends(canvas, size);
  drawParts(canvas, size);
}
複製程式碼
使用 Flutter 繪製圖表(二)餅狀圖?

新增動畫

最後給餅圖新增一個資料不斷增長的動畫效果,在 _PieChartState 新增動畫的控制器 _controller 和儲存動畫資料的 _animateDatas 陣列。在 initState 中初始化動畫控制器和填充 _animateDatas 陣列。然後建立兩個 double 型別的補間動畫,將動畫值傳給 PeiChartPainter 使用即可。

class _PieChartState extends State<PieChart> with TickerProviderStateMixin {
  double _total = 0.0;
  AnimationController _controller;
  List<double> _animateDatas = [];
  final List<PiePart> _parts = <PiePart>[];

  @override
  void initState() {
    super.initState();
    // 初始化動畫控制器
    _controller = AnimationController(
      duration: Duration(milliseconds: 3000),
      vsync: this,
    );

    List<double> datas = widget.datas;
    // 計算出資料總和
    _total = datas.reduce((a, b) => a + b);
    // 設定一個起始變數
    double startAngle = 0.0;

    for (int i = 0; i < datas.length; i++) {
      // 填充動畫陣列
      _animateDatas.add(0.0);
      final data = datas[i];
      // 計算出每個資料所佔的弧度值
      final angle = (data / _total) * -math.pi * 2;
      PiePart peiPart;

      if (i > 0) {
        // 下一個資料的起始弧度值等於之前的弧度值相加
        double lastSweepAngle = _parts[i - 1].sweepAngle;
        startAngle += lastSweepAngle;
        peiPart = PiePart(startAngle, angle, colors[i]);
      } else {
        // 第一個資料的起始弧度為 0.0
        peiPart = PiePart(0.0, angle, colors[i]);
      }
      // 新增到陣列中
      _parts.add(peiPart);

      CurvedAnimation curvedAnimation = CurvedAnimation(
        parent: _controller,
        curve: Curves.ease,
      );

      // 建立弧形的補間動畫
      final partTween = Tween<double>(begin: 0.0, end: peiPart.sweepAngle);
      Animation<double> animation = partTween.animate(curvedAnimation);

      // 建立文字的補間動畫
      final percentTween = Tween<double>(begin: 0.0, end: data);
      Animation<double> percentAnimation =
          percentTween.animate(curvedAnimation);

      // 在動畫啟動後不斷改變資料值
      _controller.addListener(() {
        _parts[i].sweepAngle = animation.value;
        _animateDatas[i] =
            double.parse(percentAnimation.value.toStringAsFixed(1));
        setState(() {});
      });
      // 開始動畫
      _controller.forward();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Container(
          width: 300,
          height: 300,
          child: CustomPaint(
            // 將資料傳給 PeiChartPainter
            painter: PeiChartPainter(
              total: _total,
              parts: _parts,
              datas: _animateDatas,
              legends: widget.legends,
            ),
          ),
        ),
        SizedBox(height: 80),
        Container(
          decoration: BoxDecoration(
            color: Colors.blue,
            shape: BoxShape.circle,
          ),
          child: IconButton(
            color: Colors.white,
            icon: Icon(Icons.refresh),
            onPressed: () {
              _controller.reset();
              _controller.forward();
            },
          ),
        ),
      ],
    );
  }
}
複製程式碼

至此整個餅狀圖的繪製就完成了,傳入資料即可使用 ???

PieChart(
  datas: [60.0, 50.0, 40.0, 30.0, 90.0],
  legends: ['一月', '二月', '三月', '四月', '五月'],
);
複製程式碼

完整程式碼地址:pie_chart.dart

總結

本文說明了如何使用 Flutter 繪製一個餅狀圖,使用了一點三角函式,關鍵點在於計算出每個資料佔據整個圓形的弧度值,以及資料的起始弧度值。 數值增長的動畫效果使用一個 AnimationController 在開始動畫後不斷的更新繪製使用的資料,在將資料傳遞給 PeiChartPainter 使用即可實現。

附言

準備寫一系列關於用 Flutter 畫圖表的文章,用來分享這方面的知識,這篇文章是這個系列的第二篇,預計 6 篇。

  1. 使用 Flutter 繪製圖表(一)柱狀圖?
  2. 使用 Flutter 繪製圖表(二)餅狀圖?(本文)
  3. 使用 Flutter 繪製圖表(三)折線圖?
  4. 使用 Flutter 繪製圖表(四)雷達圖?
  5. 使用 Flutter 繪製圖表(五)環狀圖?
  6. 使用 Flutter 繪製圖表(六)條形圖?

本文使用 mdnice 排版

相關文章