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

栏目: Android · 发布时间: 5年前

内容简介:最近用Flutter做了一个天气类的app,我也是新手,对flutter理解还不是很深入,但是开发过程中的编程思想给了我很大的启发。Dart语言特性很优秀,单线程模型,异步io,初始化列表,函数也是对象,链式调用等等,flutter的设计思想很前卫。好了,马屁只拍到这里,下面讲一下在开发过程中我碰到的一个关于自定义view和触摸事件处理的经验。看一下效果图:主要有两个功能,一是绘制折线图添加文字和图片,二十点击事件,点击不同的时间点弹出的对话框显示的时间也不同。fluttert提供的自定义控件API与安卓

最近用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 / 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),
      ),
    ),
  );
}
复制代码

最后完成了:

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 == 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方法。


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Tales from Facebook

Tales from Facebook

Daniel Miller / Polity Press / 2011-4-1 / GBP 55.00

Facebook is now used by nearly 500 million people throughout the world, many of whom spend several hours a day on this site. Once the preserve of youth, the largest increase in usage today is amongst ......一起来看看 《Tales from Facebook》 这本书的介绍吧!

RGB CMYK 转换工具
RGB CMYK 转换工具

RGB CMYK 互转工具

HEX HSV 转换工具
HEX HSV 转换工具

HEX HSV 互换工具