*本篇文章已受權微信公衆號 guolin_blog (郭霖)獨家發佈java
最近用Flutter作了一個天氣類的app,我也是新手,對flutter理解還不是很深刻,可是開發過程當中的編程思想給了我很大的啓發。Dart語言特性很優秀,單線程模型,異步io,初始化列表,函數也是對象,鏈式調用等等,flutter的設計思想很前衛。好了,馬屁只拍到這裏,下面講一下在開發過程當中我碰到的一個關於自定義view和觸摸事件處理的經驗。看一下效果圖:android
主要有兩個功能,一是繪製折線圖添加文字和圖片,二是點擊事件,點擊不一樣的時間點彈出的對話框顯示的時間也不一樣。編程
fluttert提供的自定義控件API與安卓中的極爲類似,一樣是canvas和paint,細節上有一些改動,不過上手應該很容易。這裏咱們應該使用到三個相關類:json
StatefulWidget
CustomPaint
Custompainter
canvas
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
,並傳入一下變量做爲構建的參數:
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事件中只能獲取點擊的全局位置,咱們須要將他轉換爲控件的相對座標系位置,後邊會詳細講解這裏的坑。
有實質內容的就是這個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
中的方法:
@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中要好用一些:
繪製文字和原有的繪製文字方法相差不少, 有兩種方式,一種是構造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。官方推薦的繪製流程以下:
[AssetImage]
或者 [NetworkImage]
,最後基本是經過ImageStream resolve(ImageConfiguration configuration)
來調用。void addListener(ImageListener listener, { ImageErrorListener onError })
,當每次回調後都須要建立一個新的CustomPainter來繪製新的圖像。這裏咱們在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方法。