Flutter

前言

不管是 web 端还是移动端,图表都是用来统计数据必不可少的工具,当前 flutter 在移动端的能力也日渐凸显,用 flutter 写一些统计内容就不需要在 iOS 和 Android 分别去实现图表统计,这也避免了一些 iOS 和 Android 在图表显示的差异,让两端的表现更加统一。google 官方也给出了 flutter 图表的解决方案 chart_flutter,虽然它的能力目前还有些不足,但我相信以它的更新速度,以后会具有更多的功能也会更加稳定。

能力

目前 charts_flutter 更新到了 0.6.0 版本,查看更新日志看这里

支持的图表类型

当前 charts 库支持三种图表类型,每种类型都有几个配置选项

  • 条形图,支持堆叠多个数据系列,水平展示,时间序列(以时间为横坐标展示,数据是根据时间的顺序变化)

  • 折线图,支持时间序列

  • 饼图

    条形图与三个分组系列 条形图与分组和堆积的条形图 折线图 饼图

简单使用

先定位一下抽象的文档

先打开pubspec.yaml文件,加入

flutter:
    sdk:flutter
  charts_flutter:^0.6.0
1
2
3

现在运行 IDE 的功能来获取包,或使用 flutter packages get 从终端运行。

用文档中最简单的例子做示例

/// Bar chart example
import 'package:charts_flutter/flutter.dart' as charts;
import 'package:flutter/material.dart';
class SimpleBarChart extends StatelessWidget {
  final List<charts.Series> seriesList;
  final bool animate;
  SimpleBarChart(this.seriesList, {this.animate});
  /// Creates a [BarChart] with sample data and no transition.
  factory SimpleBarChart.withSampleData() {
    return new SimpleBarChart(
      _createSampleData(),
      // Disable animations for image tests.
      animate: false,
    );
  }
  
  Widget build(BuildContext context) {
    return new charts.BarChart(
      seriesList,
      animate: animate,
    );
  }
  /// Create one series with sample hard coded data.
  static List<charts.Series<OrdinalSales, String>> _createSampleData() {
    final data = [
      new OrdinalSales('2014', 5),
      new OrdinalSales('2015', 25),
      new OrdinalSales('2016', 100),
      new OrdinalSales('2017', 75),
    ];
    return [
      new charts.Series<OrdinalSales, String>(
        id: 'Sales',
        colorFn: (_, __) => charts.MaterialPalette.blue.shadeDefault,
        domainFn: (OrdinalSales sales, _) => sales.year,
        measureFn: (OrdinalSales sales, _) => sales.sales,
        data: data,
      )
    ];
  }
}
/// Sample ordinal data type.
class OrdinalSales {
  final String year;
  final int sales;

  OrdinalSales(this.year, this.sales);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48

效果如下

效果

简单讲解一下各个部分的作用

import 'package:charts_flutter/flutter.dart' as charts;
1

引入 chart 包,as charts 是将 chart 包中的所有内容定义了一个命名空间,这样我们需要调用包中的内容只需要 charts.xxx 就可以了。

先定义一个数据类用来支持图表的数据没块的类型

class OrdinalSales {
  final String year;
  final int sales;

  OrdinalSales(this.year, this.sales);
}
1
2
3
4
5
6

这里如果是使用的时间序列,那么final String year就只能使用final DateTime year

然后使用_createSampleData方法把数据组装成 charts 支持的格式

static List<charts.Series<OrdinalSales, String>> _createSampleData() {
    final data = [
      new OrdinalSales('2014', 5),
      new OrdinalSales('2015', 25),
      new OrdinalSales('2016', 100),
      new OrdinalSales('2017', 75),
    ];

    return [
      new charts.Series<OrdinalSales, String>(
        id: 'Sales',
        colorFn: (_, __) => charts.MaterialPalette.blue.shadeDefault,
        domainFn: (OrdinalSales sales, _) => sales.year,
        measureFn: (OrdinalSales sales, _) => sales.sales,
        data: data,
      )
    ];
  }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

这里使用 static 的原因是前面调用它的地方是使用的 factory 关键字来定义,其它调用的地方就可以通过 SimpleBarChart.withSampleData()来完成组件调用,一般这个方法会传一个 datas 参数用来初始化从接口中获取的动态数据。这个方法返回一个 List<charts.Series<OridinalSales,String>>类型, 其中 Series 类型的定义如下:

  • id:单个数据系统的唯一标识符,用来呈现多个系列的图表
  • colorFn:用来定义当前数据的柱状图颜色
  • domain:表示需要观测的事物,比如'店铺'
  • measure:用来表示数据,比如'店铺的销售额'
  • data:数据源

接着就是定一个 build 方法用于渲染图表数据

@override
Widget build(BuildContext context) {
    return new charts.BarChart(
      seriesList,
      animate: animate,
    );
}
1
2
3
4
5
6
7

charts.BarChart就是定义一个普通的图表,其中定义图表的类还有TimeSeriesChart(根据时间为横坐标定义图表),LineChart(折线图),OrdinalComboChartPieChart(饼状图),ScatterPlotChart

自定义横坐标

大多数情况数据表的横坐标数据可能只是一个 int 类型的数字或者一个从接口获取的英文参数,我们需要把它转化为有可读性的文字,下面是一个例子:

Widget build(BuildContext context){
    final ticks=new charts.StaticOrdinalTickProviderSpec([
        new charts.TickSpec(
            '2018',
            label: '2018年',
            style: new charts.TextStyleSpec(//可对x轴设置颜色等
                color: new charts.Color(r: 0x4C, g: 0xAF, b: 0x50)))
        ),
         new charts.TickSpec(
            '2019',
            label: '2019年',
            style: new charts.TextStyleSpec(//可对x轴设置颜色等
                color: new charts.Color(r: 0x4C, g: 0xAF, b: 0x50)))
        ),
    ]);
    return new charts.BarChart(
      seriesList,
      animate: animate,
      domainAxis: new charts.OrdinalAxisSpec(
        tickProviderSpec: ticks,
      ),
    );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

效果如下:

对 BarChart 类传参 domainAxis,初始化一个 OrdinalAxisSpec 类(因为这里我使用的是 BarCharts 所以使用的是 OrdinalAxisSpec,如果是 TimeSeriesChart 那么需要使用 DateTimeAxisSpec),TickSpec 中第一个参数是对应 new OrdinalSales('2018', 5)中的 2018,label 表示需要展示的数据,style 可用于定义横坐标字体大小颜色等

自定义纵坐标

与自定义横坐标类似,纵坐标是通过 primaryMeasureAxis 来定义的,例如

...
    return new charts.BarChart(
      seriesList,
      animate: animate,
      domainAxis: new charts.OrdinalAxisSpec(
        tickProviderSpec: ticks,
      ),
      primaryMeasureAxis:new charts.NumericAxisSpec(
                tickFormatterSpec: new charts.BasicNumericTickFormatterSpec((value)=>{
                    '${value / 10000}万';
                });
      ),
    );
...
1
2
3
4
5
6
7
8
9
10
11
12
13
14

BasicNumericTickFormatterSpec 会传一个回调函数,函数中的参数是 num 类型的原始数据,比如原来数据是 100000,那么会变成 10 万来展示。

这种方式也可以用来定义横坐标。

定义点击信息显示

如果我点击了一个图表上的一列,如何让它展示具体的数据呢,charts 提供了一个方式来获得点击的具体信息

...
selectionModels: [
    new charts.SelectionModelConfig(
      type: charts.SelectionModelType.info,
      changedListener: _onSelectionChanged,
    )
  ],
...
void _onSelectionChanged(charts.SelectionModel model){
    ...
}
1
2
3
4
5
6
7
8
9
10
11

其中 model 是点击后返回的一些信息,可通过 model.selectedDatum 获取到点击那条信息,具体要获取哪些通过打断点去查看其中的信息取自己需要的数据就行了。

我自己定义了一个点击后显示的组件,有需要的自取:

class ShowDetail extends StatelessWidget {
  final String time;
  final String type;
  final double number;
  final bool left;
  ShowDetail({this.time, this.type, this.number, this.left = true});
  @override
  Widget build(BuildContext context) {
    var style = TextStyle(color: Colors.white, fontSize: 10);
    // var position = left ? {'left': 0} : {'right': -10};
    return Positioned(
      right: left ? null : -10,
      left: left ? 50 : null,
      child: Container(
          width: 125,
          // height: 35,
          color: Color.fromRGBO(0, 0, 0, 0.7),
          padding: EdgeInsets.all(4),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              Text(
                time,
                style: style,
              ),
              Container(
                width: 165,
                height: 15,
                child: Stack(
                  children: [
                    Positioned(
                      child: new CustomPaint(
                          size: new Size(13, 13), painter: new MyPainter()),
                    ),
                    Positioned(
                        left: 10,
                        child: Text(
                          type,
                          style: style,
                        )),
                    Positioned(
                      right: 1,
                      child: Text(
                        number.toString(),
                        style: style,
                      ),
                    ),
                  ],
                ),
              ),
            ],
          )),
    );
  }
}

//小点点
class MyPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    Paint _paint = Paint()
      ..colorFilter = ColorFilter.mode(
          Color.fromARGB(243, 190, 35, 1), BlendMode.srcATop) //颜色渲染模式
      ..filterQuality = FilterQuality.high; //颜色渲染模式的质量
    canvas.drawCircle(new Offset(6, 8), 4, _paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return false;
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72

调用示例


ShowDetail(
  time: clickTime,
  type: type,
  number: cost,
  left: left,
)
1
2
3
4
5
6
7

其中 time 表示横坐标内容,type 表示此图表类型,number 表示纵坐标内容,left 表示居左还是居右。这是根据我自己的业务写的,你们可以自行修改,完整代码可查看这里

效果:

组件缺陷及解决方案

数据变化不刷新问题

给定义图表外部添加一个 Container 组件并添加 key 值。

点击报错

问题:当我使用折线图的时候,点击折线图速度后使用 setState 会报错'_drawAreaBoundsOutdated == false': is not true.。

解决:这算是 charts 库的缺陷,目前我也没找到很好的解决方案,下面方案能解决报错

Future.delayed(const Duration(milliseconds: 500), () {
  setState((){
    ...
  })
}
1
2
3
4
5
Last Updated: 8/12/2020, 1:34:59 PM