Flutter自定義折線圖並新增點選事件

saka發表於2018-11-21

*本篇文章已授權微信公眾號 guolin_blog (郭霖)獨家釋出

前言

最近用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方法。

相關文章