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

*本篇文章已受权微信公众号 guolin_blog (郭霖)独家发布java

前言

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

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

绘制流程

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

StatefulWidget CustomPaint Custompaintercanvas

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

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

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

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

废话很少说了,直接开干。函数

构建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);
复制代码

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

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

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

看一下主要的绘制方法:

void drawPoint(Canvas canvas) {
    canvas.save();
    canvas.translate(increaseX / 2, 0.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 - 15, 0.0));
      canvas.drawImageRect(
          tempList[i].isDay ? iconDay : iconNight,
          tempList[i].isDay ? iconDayRect : iconNightRect,
          Offset(point.dx - iconSize.width / 2, this.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.0, 0.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.0, 0.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),
      ),
    ),
  );
}
复制代码

最后完成了:

处理点击事件

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

首先咱们在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 == null) return;
   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方法。

相关文章
相关标签/搜索