内容简介:最近用Flutter做了一个天气类的app,我也是新手,对flutter理解还不是很深入,但是开发过程中的编程思想给了我很大的启发。Dart语言特性很优秀,单线程模型,异步io,初始化列表,函数也是对象,链式调用等等,flutter的设计思想很前卫。好了,马屁只拍到这里,下面讲一下在开发过程中我碰到的一个关于自定义view和触摸事件处理的经验。看一下效果图:主要有两个功能,一是绘制折线图添加文字和图片,二十点击事件,点击不同的时间点弹出的对话框显示的时间也不同。fluttert提供的自定义控件API与安卓
最近用Flutter做了一个天气类的app,我也是新手,对flutter理解还不是很深入,但是开发过程中的编程思想给了我很大的启发。Dart语言特性很优秀,单线程模型,异步io,初始化列表,函数也是对象,链式调用等等,flutter的设计思想很前卫。好了,马屁只拍到这里,下面讲一下在开发过程中我碰到的一个关于自定义view和触摸事件处理的经验。看一下效果图:
主要有两个功能,一是绘制折线图添加文字和图片,二十点击事件,点击不同的时间点弹出的对话框显示的时间也不同。
绘制流程
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); 复制代码
这是在画布的中心位置画了一个半径为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 中要好用一些:
- points只是绘制普通的点
- lines会将两个点俩在一起绘制一条线段,list中的0,1绘制一条线段,2,3绘制一条线段,但是1,2之间不会有线段。
- 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。官方推荐的绘制流程如下:
- 获取ImageStream,获取的方式有多种,可以是
[AssetImage]
或者[NetworkImage]
,最后基本是通过ImageStream resolve(ImageConfiguration configuration)
来调用。 - 为ImageStream创建添加监听器
void addListener(ImageListener listener, { ImageErrorListener onError })
,当每次回调后都需要创建一个新的CustomPainter来绘制新的图像。 - 在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方法。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
"笨办法"学Python
肖 (Zed A.Shaw) / 王巍巍 / 人民邮电出版社 / 2014-11-1 / CNY 49.00
本书是一本Python入门书籍,适合对计算机了解不多,没有学过编程,但对编程感兴趣的读者学习使用。这本书以习题的方式引导读者一步一步学习编程,从简单的打印一直讲到完整项目的实现,让初学者从基础的编程技术入手,最终体验到软件开发的基本过程。 本书结构非常简单,共包括52个习题,其中26个覆盖了输入/输出、变量和函数三个主题,另外26个覆盖了一些比较高级的话题,如条件判断、循环、类和对象、代码测......一起来看看 《"笨办法"学Python》 这本书的介绍吧!