Flutter自定义折线图并添加点击事件

Posted 郭霖

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Flutter自定义折线图并添加点击事件相关的知识,希望对你有一定的参考价值。



今日科技快讯


11月28日,电动汽车制造商特斯拉的股票在周二和周三收盘时出现连续下跌,因为之前有报告称特斯拉今年10月份在中国的汽车销售同比下降了70%。 特斯拉10月份中国销量欠佳 致使股价出现连续下跌 中国汽车协会的一位负责人对路透社称,行业统计数据表明特斯拉10月份在中国这个世界最大的汽车市场仅售出了211辆汽车。


作者简介


本篇来自 saka 的投稿,给大家讲解利用 Flutter 来实现自定义折线图,希望对大家有所帮助。

https://juejin.im/user/5855ebea8d6d810065a4befa


前言


最近用 Flutter 做了一个天气类的 app,我也是新手,对 flutter 理解还不是很深入,但是开发过程中的编程思想给了我很大的启发。Dart 语言特性很优秀,单线程模型,异步io,初始化列表,函数也是对象,链式调用等等,flutter 的设计思想很前卫。好了,马屁只拍到这里,下面讲一下在开发过程中我碰到的一个关于自定义 view 和触摸事件处理的经验。看一下效果图:

Flutter自定义折线图并添加点击事件

主要有两个功能,一是绘制折线图添加文字和图片,二是点击事件,点击不同的时间点弹出的对话框显示的时间也不同。


绘制流程


fluttert提供的自定义控件API与安卓中的极为相似,同样是canvas和paint,细节上有一些改动,不过上手应该很容易。这里我们应该使用到三个相关类:

  • StatefulWidget

  • CustomPaint

  • Custompainter

StatefulWidget 类是 flutter 中必知必会的基础类,用来将我们的自定义 view 封装成为一个单独的有状态的控件,并可以传入一些参数,来刷新UI,这里不做详细说明了。

CustomPaint 类是自定义 view 必须要掌握的类,它继承自SingleChildRenderObjectWidget,官方对他的定义就是提供一个 canvas,当被要求绘制时,它首先会调用 painter 来绘制自身的内容,然后再绘制子 view,最后调用foregroundPainter 来绘制前景,这个和 recyclerview 绘制流程很相似。

Custompainter 类是一个画笔工具,这里我们只介绍这一个工具类。必须重写 void paint(Canvas canvas, Size size)方法来绘制我们预期的效果。这里的两个参数比较简单,一个就是画布,size 表示位置和大小。

Canvas 的坐标系同 android 中一样,左上角是原点,向右为 x 轴正方向,向下为 y 轴正方向,掌握了这点绘制容易很多。

废话不多说了,直接开干。


构建StatefulWidget


首先建好一个类,继承StatefulWidget,并传入一下变量作为构建的参数:

 final List<HourlyForecast> hourlyList;//天气数据列表
 final String imagePath;//图片路径
 final EdgeInsetsGeometry padding;//padding
 final Size size;//大小
 final void Function(int index) onTapUp;//点击事件的回调方法

因为要在初始化列表中使用这些变量,所以做成了 final,表示我也不想修改他们,注意最后一个变量是一个函数,参数为点击的位置索引,这也是 dart 的语言特性,可以把函数作为对象。

HourlyForecast 是从和风天气的接口中返回的实体类,主要数据如下:

class HourlyForecast {
  String time; //    预报时间,格式yyyy-MM-dd hh:mm 2013-12-30 13:00
  String tmp; //    温度  2
  String cond_code; //    天气状况代码  101
  String cond_txt; //天气状况代码    多云
  String wind_deg; //风向360角度    290
  String wind_dir; //风向    西北
  String wind_sc; //风力    3-4
  String wind_spd; //风速,公里/小时    15
  String hum; //    相对湿度    30
  String pres; //大气压强    1030
  String dew; //露点温度    12
  String cloud; //云量    23
  bool isDay;

  HourlyForecast.formJson(Map<String, dynamic> json)
      : time = json['time'],
        tmp = json['tmp'],
        cond_code = json['cond_code'],
        cond_txt = json['cond_txt'],
        wind_deg = json['wind_deg'],
        wind_dir = json['wind_dir'],
        wind_sc = json['wind_sc'],
        wind_spd = json['wind_spd'],
        hum = json['hum'],
        pres = json['pres'],
        dew = json['dew'],
        cloud = json['cloud'] {
    isDay = DateTime.parse(time).hour > 6 && DateTime.parse(time).hour < 18;
  }

  String getHourTime() {
    return time.split(' ')[1];
  }
}

其中 HourlyForecast.formJson(Map<String, dynamic> json)方法是 dart 中常用的简单json 解析方式,可以直接从 convert 包中的 map 数据导出为实体类。

定义好了 Widget,我们还需要定义一个 State 来管理 Widget 的状态。看一下 build 方法:

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTapUp: (TapUpDetails detail) {
        print('onTapUp');
        onTap(context, detail);
      },
      child: CustomSingleChildLayout(
        delegate: _SakaLayoutDelegate(widget.size, widget.padding),
        child: CustomPaint(
          painter: _HourlyForecastPaint(context, widget.hourlyList,
              widget.padding.deflateSize(widget.size), areaListCallback,
              imagePath: widget.imagePath,
              iconDay: iconDay,
              iconDayRect: iconDayRect,
              iconNight: iconNight,
              iconNightRect: iconNightRect),
        ),
      ),
    );
  }

最外层是一个 GestureDectecor,flutter 中使用这种方式处理点击事件是最简单的一种方式,但是要注意一点 OnTapUp 事件中只能获取点击的全局位置,我们需要将他转换为控件的相对坐标系位置,后边会详细讲解这里的坑。


构建CustomPaint


有实质内容的就是这个 GestureDectector 中的 CustomSingleChildLayout 控件,这个控件是一个非常简但是非常实用的类,它只能装载一个控件,并且将自己和子控件委托给SingleChildLayoutDelegate 来定位子控件在父控件中的位置。

 class _SakaLayoutDelegate extends SingleChildLayoutDelegate {
  final Size size;
  final EdgeInsetsGeometry padding;

  _SakaLayoutDelegate(this.size, this.padding)
      : assert(size != null),
        assert(padding != null);

  @override
  Size getSize(BoxConstraints constraints) {
    return size;
  }

  @override
  bool shouldRelayout(_SakaLayoutDelegate oldDelegate) {
    return this.size != oldDelegate.size;
  }

  @override
  BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
    return BoxConstraints.tight(padding.deflateSize(size));
  }

  @override
  Offset getPositionForChild(Size size, Size childSize) {
    return Offset((size.width - childSize.width) / 2,
        (size.height - childSize.height) / 2);
  }
}

这是类中的主要代码,getSize 返回父控件的大小,这里我直接使用的从 StatefulWidget中传入的参数作为父控件的大小。

shouldRelayout 是重新布局的条件,这里我直接判断为大小变化时重新布局,这种判断方式已经满足了我的需求。

getPositionForChild 返回的是子控件在父控件中的位置,这里我需要子控件居中,所以返回了相对大小一半的一个偏移量。

这样我们就通过这种定位方式将父控件的大小,子控件的 padding,位置定位好了。

CustomPaint 中的 painter 变量必须设置,这是绘制的主要实现方法,也就是我们后边将要讲的 CustomPainter 类。

CustomPaint 的 size 变量不能为空,默认是0,所以我们上边采用了SingleChildLayoutDelegate 来设置 CustomPaint 的大小,否则他将会不显示。


构建CustomPainter


先看一下如何重写这个CustomPainter中的方法:

@override
  void paint(Canvas canvas, Size size) {
    var rect = Offset.zero & size;
    canvas.clipRect(rect);//剪切画布
    drawPoint(canvas);//绘制点和折线和对应的数字、图标等
  }

第一行我们找到了一个 rect,这个 rect 就是我们需要绘制的区域,需要把画布裁剪到只在区域中,否则画笔会超出这个区域绘制。这个 rect 的判定使用的 Offset 的运算符重载函数,通过这个操作产生一个 rect,它的左上角位置就是 offset,它的大小就是 size的大小,非常风骚的运算符重载,我只在 C++ 中看到过。

这里我们做一个简单的对比:

canvas.drawCircle(size.center(Offset.zero), 150, p);

Flutter自定义折线图并添加点击事件

这是在画布的中心位置画了一个半径为200的圆,可以看到已经超出了画布的范围,但是绘制的圆还在。

    var rect = Offset.zero & size;
    canvas.clipRect(rect);
    canvas.drawCircle(size.center(Offset.zero), 150, p);

Flutter自定义折线图并添加点击事件

这是在剪切画布后的效果。后者才是我们需要的。

看一下主要的绘制方法:

void drawPoint(Canvas canvas) {
    canvas.save();
    canvas.translate(increaseX / 20.0);
    canvas.drawPoints(ui.PointMode.polygon, points, p);
    canvas.drawPoints(ui.PointMode.points, points, pointP);
    for (int i = 0; i < tempTextList.length; i++) {
      Offset point = points[i];
      canvas.drawParagraph(
          tempTextList[i], point - Offset(this.tempTextSize, 20.0));
      canvas.drawParagraph(hourTextList[i], Offset(point.dx - 150.0));
      canvas.drawImageRect(
          tempList[i].isDay ? iconDay : iconNight,
          tempList[i].isDay ? iconDayRect : iconNightRect,
          Offset(point.dx - iconSize.width / 2this.hourTextSize + 10.0) &
              iconSize,
          p);
    }
    canvas.restore();
  }

因为有若干个天气数据,需要将可绘制区域的横向长度根据天气数据的个数均分,每个天气数据占据一定范围。 绘制点和图标文字的时候,需要在这个范围中间绘制,所以我们将画布的坐标系向右平移这个范围的一半的值,然后在画布上绘制,绘制完成后再将画布复原,这些点就显示在中间位置上了。 点的绘制有三种方式,枚举类型 PointMode 中定义了:points,lines,polygons。这三种方式比 java 中要好用一些:

  1. points只是绘制普通的点

  2. lines会将两个点俩在一起绘制一条线段,list 中的0,1绘制一条线段,2,3绘制一条线段,但是1,2之间不会有线段。

  3. polygons 会将所有点连成一条线


绘制文字


绘制文字和原有的绘制文字方法相差很多, 有两种方式,一种是构造 TextPainter,设置好参数后通过 void paint(Canvas canvas, Offset offset)来绘制文字,另一种是需要调用 void drawParagraph(Paragraph paragraph, Offset offset)方法,我这里选择的后者。第二个参数 offset 就是绘制的位置,比较简单,主要看一下第一个参数 Paragraph,这是我们定义文字的主要方式。

Paragraph 来自 dart.ui 库,是有引擎创建的类,不能被继承,官方推荐使用ParagraphBuilder 来构造 Paragraph。

 ui.ParagraphBuilder paragraphBuilder = ui.ParagraphBuilder(
        ui.ParagraphStyle(
          textAlign: TextAlign.center,
          fontSize: 10.0,
          textDirection: TextDirection.ltr,
          maxLines: 1,
        ),
      )
        ..pushStyle(
          ui.TextStyle(
              color: Colors.black87, textBaseline: ui.TextBaseline.alphabetic),
        )
        ..addText(tmp.toInt().toString());
 ui.Paragraph paragraph = paragraphBuilder.build()
        ..layout(ui.ParagraphConstraints(width: 20.0));

builder 只允许传入一个 ParagraphStyle 参数,它的构造方法中的参数都是构造 Text 常用的一些参数。

TextAlign textAlign, //文字位置
TextDirection textDirection,//文字方向
FontWeight fontWeight,//文字权重
FontStyle fontStyle,//文字样式
int maxLines,//最大行数
String fontFamily,//字体
double fontSize,//文字大小
double lineHeight,//文字的最大高度
String ellipsis,//缩略显示
Locale locale,//本地化

上面的例子中只使用了一些用的到的参数。 构造完成后通过链式调用调用调用 void pushStyle(TextStyle style)来设置一些临时的样式,这些样式可以通过调用 void pop()来撤销。添加文字通过使用 void addText(String text),最后调用 build 方法来完成一个 paragraph 的构造。


绘制图片


绘制图片也稍微麻烦。这里我是用的是 void drawImageRect(Image image, Rect src, Rect dst, Paint paint)方法,和 Android 中的·基本一致,这里主要是讲一下第一个参数 Image 的获取。

这个 Image 也是 dart.ui 中的类,同样是引擎创建的,不同于 widget 中的 Image。官方推荐的绘制流程如下:

  1. 获取 ImageStream,获取的方式有多种,可以是 [AssetImage] 或者 [NetworkImage],最后基本是通过 ImageStream resolve(ImageConfiguration configuration)来调用。

  2. 为 ImageStream 创建添加监听器 void addListener(ImageListener listener, { ImageErrorListener onError }),当每次回调后都需要创建一个新的 CustomPainter来绘制新的图像。

  3. 在 canvas 中调用 drawimage 等一系列方法

这里我们在 StatefulWidget 中重写一下:

@override
  void didChangeDependencies() {
    super.didChangeDependencies();
    AssetImage('images/day.png').resolve(createLocalImageConfiguration(context))
      ..addListener((ImageInfo image, bool synchronousCall) {
        iconDay = image.image;
        iconDayRect = Rect.fromLTWH(
            0.00.0, iconDay.width.toDouble(), iconDay.height.toDouble());
        setState(() {});
      });
    ImageStream night = AssetImage('images/night.png')
        .resolve(createLocalImageConfiguration(context));
    night.addListener((ImageInfo image, bool synchronousCall) {
      iconNight = image.image;
      iconNightRect = Rect.fromLTWH(
          0.00.0, iconNight.width.toDouble(), iconNight.height.toDouble());
      setState(() {});
    });
  }

将获得的 image 传入全局变量 iconNight 和 iconNightDay,然后在前边提到的 build 方法中使用这些变量:

 @override
Widget build(BuildContext context) {
  return GestureDetector(
    onTapUp: (TapUpDetails detail) {
      print('onTapUp');
      onTap(context, detail);
    },
    child: CustomSingleChildLayout(
      delegate: _SakaLayoutDelegate(widget.size, widget.padding),
      child: CustomPaint(
        painter: _HourlyForecastPaint(context, widget.hourlyList,
            widget.padding.deflateSize(widget.size), areaListCallback,
            imagePath: widget.imagePath,
            iconDay: iconDay,
            iconDayRect: iconDayRect,
            iconNight: iconNight,
            iconNightRect: iconNightRect),
      ),
    ),
  );
}

最后完成了:

Flutter自定义折线图并添加点击事件


处理点击事件


处理点击事件主要是注意一下全局坐标与控件内坐标的转换。

首先我们在 CustomPainter 中设置一个函数参数:final void Function(List<double> xList) areaListCallback; 这个函数在构造函数中直接使用:

 if (this. areaListCallback == null) {
      return;
}
areaListCallback(points.map((f) => f.dx + increaseX).toList());

上边的参数中 points 是每个根据天气个数均分区域的起始位置,这里我们通过 map 函数将这些点转化为区域的x轴最大位置,这个函数会回传给 StatefulWidget 中的 State 类,

  void areaListCallback(List<double> xList) {
    print(xList);
    this.xList = xList;
  }

点击时的 onTap 函数:

  void onTap(BuildContext context, TapUpDetails detail) {
   if (widget.onTapUp == nullreturn;
   RenderBox renderBox = context.findRenderObject();
   Offset localPosition = renderBox.globalToLocal(detail.globalPosition);
   widget.onTapUp(getIndex(localPosition));
 }
 int getIndex(Offset globalOffset) {
   int i = -1;
   double relativePositionX =
       globalOffset.dx - widget.padding.collapsedSize.width / 2;
   for (double a in xList) {
     i++;
     if (relativePositionX >= 0 && relativePositionX <= a) {
       break;
     }
   }
   return i;
 }

void onTap(BuildContext context, TapUpDetails detail)中 TapUpDetails 一个全局位置获取的量,需要转换为本地坐标。 上述中通过 context.findRenderObject()方法来找到当前控件的 RenderBox,通过 renderBox.globalToLocal(detail.globalPosition)将全局坐标系转换为当前坐标系,这样当点击某个区域时就会调用getIndex方法来寻找索引,传值给 onTap 方法。


总结


关于 Flutter 的自定义 view 就说这么多,有什么问题欢迎留言交流!


欢迎长按下图 -> 识别图中二维码

以上是关于Flutter自定义折线图并添加点击事件的主要内容,如果未能解决你的问题,请参考以下文章

Flutter/Dart 为 Google Maps Marker 添加自定义点击事件

echarts 折线图点击高亮

R语言ggplot2可视化:使用geom_line函数可视化折线图并自定义设置折线图的不同区间使用不同颜色

excel画图怎么自定义横纵坐标

Excel中制作折线图的横坐标是按时间顺序自动排列的,请问如何设置在横坐标上显示自定义的时间?

自定义view——折线图