使用 Flutter 繪製圖表(一)柱狀圖?

ColdStone發表於2020-06-02

前言

本文講解如何使用 Flutter (Google 開源的 UI 工具包,幫助開發者通過一套程式碼庫高效構建多平臺精美應用,支援移動、Web、桌面和嵌入式平臺) 繪製一個帶有動畫效果的柱狀圖表,最終效果如下圖。

使用 Flutter 繪製圖表(一)柱狀圖?

要繪製這樣的圖表普通的 Widget 難以實現,這時就需要 CustomPaintCustomPainter 出場了,它們類似於 Web 裡面的 <canvas> 元素,CustomPaint 提供了一個繪製區域,而 CustomPainter 擁有具體的繪製方法。

CustomPaint 和 CustomPainter

CustomPaint 是用來提供畫布的控制元件,它使用傳入畫筆 painterchild 控制元件後面繪製圖形, ,foregroundPainter 畫筆繪製在 child 控制元件之前。size 屬性控制畫布的大小,假如定義了子控制元件 child,那麼畫布的大小將由子控制元件的大小決定,size 屬性被忽略。

class CustomPaint extends SingleChildRenderObjectWidget {
  const CustomPaint({
    Key key,
    this.painter,
    this.foregroundPainter,
    this.size = Size.zero,
    this.isComplex = false,
    this.willChange = false,
    Widget child,
  })
}
複製程式碼

CustomPainter 是一個抽象類,是實現繪製圖形的控制元件,要在畫布上繪製圖形需要實現它的 paint 方法。paint 方法有兩個引數,Canvas canvasSize sizeSize 物件表示畫布的尺寸,Canvas 物件上是具體的繪製圖形的方法。

abstract class CustomPainter extends Listenable {
  void paint(Canvas canvas, Size size);

  bool shouldRepaint(covariant CustomPainter oldDelegate);
}
複製程式碼

Canvas canvas 物件主要的繪製圖形方法有

方法名 引數 效果
drawColor Color color, BlendMode blendMode 繪製顏色到畫布上
drawLine Offset p1, Offset p2, Paint paint 兩點之間畫線
drawPaint Paint paint 使用 [Paint] 填充畫布
drawRect Rect rect, Paint paint 繪製矩形
drawRRect RRect rrect, Paint paint 繪製帶圓角的矩形
drawOval Rect rect, Paint paint 繪製橢圓
drawCircle Offset c, double radius, Paint paint 繪製圓形
drawArc Rect rect, double startAngle, double sweepAngle, bool useCenter, Paint paint 繪製弧形
drawPath Path path, Paint paint 繪製路徑
drawImage Image image, Offset p, Paint paint 繪製影像
drawPoints PointMode pointMode, List<Offset> points, Paint paint 繪製多個點

要將圖形繪製到畫布上需要先建立一個繼承至 CustomPainter 的自定義畫筆,例如繪製一個矩形需要實現一個繪製矩形的畫筆 RectanglePainter,然後在畫布 CustomPaint 上應用它。

class RectanglePainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    // 定義一個矩形
    final Rect rect = Rect.fromLTWH(50.0, 50.0, 100.0, 100.0);
    // 指定繪製的樣式
    final Paint paint = Paint()
      ..color = Colors.orange
      ..strokeWidth = 4.0
      ..style = PaintingStyle.stroke
      ..isAntiAlias = true;

    // 使用 drawRect 繪製矩形
    canvas.drawRect(rect, paint);
  }

  @override
  bool shouldRepaint(RectanglePainter oldDelegate) => false;
}

class Rectangle extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Center(
      child: CustomPaint(
        // 使用 RectanglePainter 在畫布上繪製
        painter: RectanglePainter(),
        child: Container(
          width: 300,
          height: 300,
          decoration: BoxDecoration(
            border: Border.all(
              width: 1.0,
              color: Colors.grey[300],
            ),
          ),
        ),
      ),
    );
  }
}
複製程式碼

效果如圖

使用 Flutter 繪製圖表(一)柱狀圖?

繪製柱狀圖表

介紹完畢,下面開始繪製柱狀圖表,第一步建立 BarChart 控制元件代表柱狀圖,它有兩個構造引數一個是 data 用來接收圖表資料,以及 xAxis 表示圖表橫軸標識。

class BarChart extends StatefulWidget {
  final List<double> data;
  final List<String> xAxis;

  const BarChart({
    @required this.data,
    @required this.xAxis,
  });

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

class _BarChartState extends State<BarChart> with TickerProviderStateMixin {
  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        CustomPaint(
          painter: BarChartPainter(
            datas: widget.data,
            xAxis: widget.xAxis,
          ),
          child: Container(width: 300, height: 300),
        ),
      ],
    );
  }
}
複製程式碼

然後建立一個用來繪製的自定義畫筆 BarChartPainter

class BarChartPainter extends CustomPainter {
  final List<double> datas;
  final List<String> xAxis;

  BarChartPainter({
    @required this.xAxis,
    @required this.datas,
  });

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

  @override
  bool shouldRepaint(BarChartPainter oldDelegate) => true;

  @override
  bool shouldRebuildSemantics(BarChartPainter oldDelegate) => false;
}

複製程式碼

繪製座標軸

BarChartPainter 上定義一個 _drawAxis 方法用於繪製橫座標軸,使用一個由左上,左下,右下三個點控制的 Path 路徑繪製。

void _drawAxis(Canvas canvas, Size size) {
  final double sw = size.width;
  final double sh = size.height;

  // 使用 Paint 定義路徑的樣式
  final Paint paint = Paint()
    ..color = Colors.black87
    ..style = PaintingStyle.stroke
    ..strokeWidth = 1.0;

  // 使用 Path 定義繪製的路徑,從畫布的左上角到左下角在到右下角
  final Path path = Path()
    ..moveTo(0, 0)
    ..lineTo(0, sh)
    ..lineTo(sw, sh);

  // 使用 drawPath 方法繪製路徑
  canvas.drawPath(path, paint);
}

@override
void paint(Canvas canvas, Size size) {
  _drawAxis(canvas, size);
}
複製程式碼

效果如下

chart-axis

繪製標識

BarChartPainter 上定義一個 _drawLabels 方法繪製縱軸標識。

void _drawLabels(Canvas canvas, Size size) {
  final double gap = 50.0;
  final double sh = size.height;
  final List<double> yAxisLabels = [];

  Paint paint = Paint()
    ..color = Colors.black87
    ..strokeWidth = 2.0;

  // 使用 50.0 為間隔繪製比傳入資料多一個的標識
  for (int i = 0; i <= datas.length; i++) {
    yAxisLabels.add(gap * i);
  }

  yAxisLabels.asMap().forEach(
    (index, label) {
      // 標識的高度為畫布高度減去標識的值
      final double top = sh - label;
      final rect = Rect.fromLTWH(0, top, 4, 1);
      final Offset textOffset = Offset(
        0 - labelFontSize * 3,
        top - labelFontSize / 2,
      );

      // 繪製 Y 軸右邊的線條
      canvas.drawRect(rect, paint);

      // 繪製文字需要用 `TextPainter`,最後呼叫 paint 方法繪製文字
      TextPainter(
        text: TextSpan(
          text: label.toStringAsFixed(0),
          style: TextStyle(fontSize: labelFontSize, color: Colors.black87),
        ),
        textAlign: TextAlign.right,
        textDirection: TextDirection.ltr,
        textWidthBasis: TextWidthBasis.longestLine,
      )
        ..layout(minWidth: 0, maxWidth: 24)
        ..paint(canvas, textOffset);
    },
  );
}

@override
void paint(Canvas canvas, Size size) {
  _drawAxis(canvas, size);
  _drawLabels(canvas, size);
}

複製程式碼

效果如下

chart-yaxis

繪製資料矩形

然後定義一個 _darwBars 方法將具體矩形和橫軸標識繪製出來。

List<Color> colors = [
  Color(0xff8e43e7),
  Color(0xffff4f81),
  Color(0xff1cc7d0),
  Color(0xff00aeff),
  Color(0xff3369e7),
  Color(0xffb84592),
  Color(0xff2dde98),
  Color(0xffff6c5f),
  Color(0xff003666),
  Color(0xffffc168),
  Color(0xff050f2c),
];

void _darwBars(Canvas canvas, Size size) {
  final sh = size.height;
  final paint = Paint()..style = PaintingStyle.fill;

  for (int i = 0; i < datas.length; i++) {
    // 每個矩形使用預設的 colors 陣列裡面的顏色
    paint.color = colors[i];
    final double textFontSize = 14.0;
    final double data = datas[i];
    // 矩形的上邊緣為畫布高度減去資料值
    final double top = sh - data;
    // 矩形的左邊緣為當前索引值乘以矩形寬度加上矩形之間的間距
    final double left = i * _barWidth + (i * _barGap) + _barGap;

    // 使用 Rect.fromLTWH 方法建立要繪製的矩形
    final rect = Rect.fromLTWH(left, top, _barWidth, data);
    // 使用 drawRect 方法繪製矩形
    canvas.drawRect(rect, paint);

    final offset = Offset(
      left + _barWidth / 2 - textFontSize * 1.2,
      top - textFontSize * 2,
    );
    // 使用 TextPainter 繪製矩形上放的數值
    TextPainter(
      text: TextSpan(
        text: data.toStringAsFixed(1),
        style: TextStyle(fontSize: textFontSize, color: paint.color),
      ),
      textAlign: TextAlign.center,
      textDirection: TextDirection.ltr,
    )
      ..layout(
        minWidth: 0,
        maxWidth: textFontSize * data.toString().length,
      )
      ..paint(canvas, offset);

    final xData = xAxis[i];
    final xOffset = Offset(left + _barWidth / 2 - textFontSize, sh + 12);
    // 繪製橫軸標識
    TextPainter(
      textAlign: TextAlign.center,
      text: TextSpan(
        text: '$xData',
        style: TextStyle(fontSize: 12, color: Colors.black87),
      ),
      textDirection: TextDirection.ltr,
    )
      ..layout(
        minWidth: 0,
        maxWidth: size.width,
      )
      ..paint(canvas, xOffset);
  }
}

@override
void paint(Canvas canvas, Size size) {
  _drawAxis(canvas, size);
  _drawLabels(canvas, size);
  _darwBars(canvas, size);
}
複製程式碼

效果如下

chart-data

新增運動動畫

最後在 _BarChartState 裡使用一個 AnimationController 建立柱狀圖運動的動畫,關於動畫方面的知識可以查閱 從零開始的 Flutter 動畫 這篇文章。

class _BarChartState extends State<BarChart> with TickerProviderStateMixin {
  AnimationController _controller;
  final _animations = <double>[];

  @override
  void initState() {
    super.initState();
    double begin = 0.0;
    List<double> datas = widget.data;
    // 初始化動畫控制器,並呼叫 forward 方法啟動動畫
    _controller = AnimationController(
      vsync: this,
      duration: Duration(milliseconds: 3000),
    )..forward();

    for (int i = 0; i < datas.length; i++) {
      final double end = datas[i];
      // 使用一個補間值 Tween 建立每個矩形的動畫值
      final Tween<double> tween = Tween(begin: begin, end: end);
      // 初始化陣列裡面的值
      _animations.add(begin);

      // 建立補間動畫
      Animation<double> animation = tween.animate(
        CurvedAnimation(
          parent: _controller,
          curve: Curves.ease,
        ),
      );
      _controller.addListener(() {
        // 使用 setState 更新 _animations 陣列裡面的動畫值
        setState(() {
          _animations[i] = animation.value;
        });
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        CustomPaint(
          // 最後向 BarChartPainter 傳入 _animations 陣列,實現動畫
          painter: BarChartPainter(
            datas: _animations,
            xAxis: widget.xAxis,
            animation: _controller,
          ),
          child: Container(width: 300, height: 300),
        ),
      ],
    );
  }
}

複製程式碼

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

BarChart(
  data: [180.0, 98.0, 126.0, 64.0, 118.0],
  xAxis: ['一月', '二月', '三月', '四月', '五月'],
);
複製程式碼

完整程式碼地址:bar_chart.dart

總結

本文說明了什麼是 CustomPaintCustomPainter。以及如何使用它們繪製一個帶有動畫的柱狀圖表。

附言

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

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

本文使用 mdnice 排版

相關文章