『Flutter-繪製篇』自定義View在天氣 APP 中的實戰應用

前言

前不久,利用週末時間學習並完成一個簡單的 Flutter 項目 - 簡悅天氣簡約不簡單,豐富不復雜,這是一款簡約風格的 flutter 天氣項目,提供實時、多日、24 小時、颱風路徑以及生活指數等服務,支持定位、刪除、搜索等操作。

下圖爲主頁效果,可以 點擊這裏 進行下載 apk 體驗:

開始

本身作爲天氣 APP,自定義繪製自然少不了,首頁多樣的背景效果,炫酷的雨雪效果,展示當前空氣質量和體感的圓環效果,動態溫度折線圖和日出日落圖。

其實 pub.dev 上已經有不少 chart 插件,提供豐富的圖表類型,支持各種動畫和手勢。但是如果是像本項目,使用場景並不需要手勢,且沒有複雜的動畫,只存在折線這種形態,完全可以自己實現。一方面可以鞏固和拓展 flutter 的繪製相關知識點,另一方面根據自己的實際需求,可以擁有更多的定製化功能。

先看一下最終效果,其中包括:

  • 動態降雨折線圖


  • 多日折線圖


  • 24小時折線圖


  • AQI圓弧


  • 日出日落圖


繪製

接下來,會以上述效果作爲切入點,由簡到難,由靜態到動態,逐步分析繪製前數據的準備和繪製時相關接口調用,最後,總結出折線圖繪製的通用思路,對後續有相關需求的小夥伴提供幫助。

AQI圓弧

先從最簡單圓弧圖開始,如上圖可看到的信息有:半透明的圓弧,純白色的圓弧,居中的 AQI 值以及其底部的文字描述。對於此圖而言,只需要知道 ratio: 白色圓弧佔比、AQIValue 和 AQIDesc。

這個簡單直接先上代碼再分析。

  @override
  void paint(Canvas canvas, Size size) {
    weatherPrint("AqiChartPainter size:$size");
    var radius = size.height / 2 - 10;
    var centerX = size.width / 2;
    var centerY = size.height / 2;
    var centerOffset = Offset(centerX, centerY);
    // 繪製半透明圓弧
    _path.reset();
    _path.addArc(Rect.fromCircle(center: centerOffset, radius: radius),
        pi * 0.7, pi * 1.6);
    _paint.style = PaintingStyle.stroke;
    _paint.strokeWidth = 4;
    _paint.strokeCap = StrokeCap.round;
    _paint.color = Colors.white38;
    canvas.drawPath(_path, _paint);
    // 繪製純白色圓弧
    _path.reset();
    _path.addArc(Rect.fromCircle(center: centerOffset, radius: radius),
        pi * 0.7, pi * 1.6 * ratio);
    _paint.color = Colors.white;
    canvas.drawPath(_path, _paint);
    // 繪製 AQIValue
    var valuePara = UiUtils.getParagraph(value, 30);
    canvas.drawParagraph(
        valuePara,
        Offset(centerOffset.dx - valuePara.width / 2,
            centerOffset.dy - valuePara.height / 2));
    // 繪製 AQIDesc
    var descPara = UiUtils.getParagraph("$desc", 15);
    canvas.drawParagraph(
        descPara,
        Offset(centerOffset.dx - valuePara.width / 2,
            centerOffset.dy + valuePara.height / 2));
  }

將步驟進行分解:

  1. 先繪製半透明圓弧,確認中心點座標和半徑,通過 _path.addArc(Rect oval, double startAngle, double sweepAngle) 方法進行繪製。oval: 圓弧所在矩形,startAngle: 起始角度(以鐘錶爲例,0爲3點方向),sweepAngle: 劃過角度(默認方向順時針)。

  2. 在半透明圓弧基礎上,根據 ratio (currentAqiValue / totalAqiValue) 繪製純白色圓弧

  3. 依次繪製中間 AQIValueAQIDesc。Flutter 繪製文本跟 Android 比起來略微有點麻煩,通過構造 ui.Paragraph 對象,然後調用 canvas.drawParagraph(Paragraph paragraph, Offset offset) 方法進行繪製。一般通過封裝好的靜態初始化方法構建 ui.Paragraph 對象:

      static ui.Paragraph getParagraph(String text, double textSize,
          {Color color = Colors.white, double itemWidth = 100}) {
        var pb = ui.ParagraphBuilder(ui.ParagraphStyle(
          textAlign: TextAlign.center, //居中
          fontSize: textSize, //大小
        ));
        pb.addText(text);
        pb.pushStyle(ui.TextStyle(color: color));
        var paragraph = pb.build()..layout(ui.ParagraphConstraints(width: itemWidth));
        return paragraph;
      }
    

關鍵詞: addArcParagraphdrawParagraph

日出日落貝塞爾曲線

上圖看起來像是圓弧,其實是使用二階貝塞爾曲線進行繪製。圖中涵蓋的信息並不多,其中包括左右日出日落時間、整體虛曲線、動態實曲線和當前時間。對於需要的數據除了日出日落時間,還需要根據 (nowTime - sunriseTime)/(sunsetTime - sunriseTime) 獲取佔比 ratio。

繼續分解步驟:

  1. 繪製 虛曲線,首先確認起點和終點,通過 _path.quadraticBezierTo(double x1, double y1, double x2, double y2) 繪製貝塞爾曲線,參數需要傳入 控制點 座標和 終點 座標。很遺憾 Flutter 沒有提供虛線的接口,借用 path_drawing 插件中的 dashPath(Path source, {@required CircularIntervalList<double> dashArray,DashOffset dashOffset,}) 方法進行虛線的繪製。

    var height = size.height;
    var width = size.width;
    double startX = marginLeftRight;
    double startY = height - marginBottom;
    double endX = width - marginLeftRight;
    double endY = startY;
    _path.reset();
    _path.moveTo(startX, startY);
    _path.quadraticBezierTo(width / 2, marginTop, endX, endY);
    _paint.color = Colors.white;
    _paint.style = PaintingStyle.stroke;
    _paint.strokeWidth = 1.5;
    canvas.drawPath(
      dashPath(_path, dashArray: CircularIntervalList<double>([10, 5])),
      _paint);
    
  2. 繪製 實虛線,這裏遇到一個問題,已知比例 ratio,在虛曲線上繪製實曲線(保證重疊),不同於直線或者弧線,通過控制 xy 或者 sweepAngle 輕鬆實現。對二階貝塞爾曲線稍有了解的可以知道,其主要由起始點和控制點組成,這三個值稍有變化,都很難做到重疊,所以得另闢蹊徑。

    Android 中有 PathMeasure 可以對 Path 進行分段,然後根據需要繪製的段數進行控制。同樣,Flutter 也有對應的 API:

    var metrics = _path.computeMetrics();
    var pm = metrics.elementAt(0);
    Offset sunOffset = pm.getTangentForOffset(pm.length * ratio).position;
    canvas.save();
    canvas.clipRect(Rect.fromLTWH(0, 0, sunOffset.dx, height));
    canvas.drawPath(_path, _paint);
    canvas.restore();
    

    通過 getTangentForOffset 得到 ratio 下在曲線上的 x,y 座標點,然後 _path.clipRect() 對虛曲線裁剪最終得到實曲線。

  3. 繪製小太陽和當前時間,知道曲線上的 x,y 座標,這就好辦了

    _paint.style = PaintingStyle.fill;
    _paint.color = Colors.yellow;
    canvas.drawCircle(sunOffset, 6, _paint);
    
    var now = DateTime.now();
    String nowTimeStr = "${now.hour}:${now.minute}";
    var nowTimePara = UiUtils.getParagraph(nowTimeStr, 14);
    canvas.drawParagraph(nowTimePara,
                         Offset(sunOffset.dx - nowTimePara.width / 2, sunOffset.dy + 10));
    

    關鍵詞: quadraticBezierTodashPathcomputeMetricsgetTangentForOffsetclipRectdrawCircle

多日折線圖

  • 多日折線圖


上下的文字區域繪製根據各自高度順延繪製即可,只要預留出中間折線的繪製區域即可。中間的折線區域又可以繼續平分成 top 和 bottom 兩個折線,各自繪製各自的,互不干擾。

折線圖的繪製思路分爲三步:找出最大最小值、計算單位溫度的 y 值和遍歷繪製

  1. 遍歷找出 top 和 bottom 的最大最小值

    void setMinMax() {
      _data.forEach((element) {
        if (element.dayTemp > topMaxTemp) {
          topMaxTemp = element.dayTemp;
        }
        if (element.dayTemp < topMinTemp) {
          topMinTemp = element.dayTemp;
        }
        if (element.nightTemp > bottomMaxTemp) {
          bottomMaxTemp = element.nightTemp;
        }
        if (element.nightTemp < bottomMinTemp) {
          bottomMinTemp = element.nightTemp;
        }
      });
    }
    
  2. 根據溫度計算x,y值,目前已知折線的高度 itemHeight, 具體溫度 temp,起點 topLineStartY,最高最低溫度已經實際溫度,即可算出溫度對應的 y 座標值,x座標值

    getTopLineY(int temp) {
      if (temp == topMaxTemp) {
        return topLineStartY;
      }
      return topLineStartY +
        (topMaxTemp - temp) / (topMaxTemp - topMinTemp) * lineHeight;
    }
    x = startX + index*itemWidth;
    
  3. 開始繪製,x,y 都知道了,直線、原點以及文字都可以進行遍歷繪製了

    _paint.color = Colors.white;
    var topOffset = Offset(startX, getTopLineY(element.dayTemp));
    var bottomOffset = Offset(startX, getBottomLineY(element.dayTemp));
    _paint.style = PaintingStyle.fill;
    // 繪製折線上的圓點
    canvas.drawCircle(topOffset, 3, _paint);
    canvas.drawCircle(bottomOffset, 3, _paint);
    
    // 繪製圓點上下的溫度值
    var topTempPara = UiUtils.getParagraph("${element.dayTemp}°", mainTextSize, itemWidth: itemWith);
    canvas.drawParagraph(
      topTempPara, Offset(topOffset.dx - topTempPara.width / 2, topOffset.dy - topTempPara.height - 5));
    var bottomTempPara = UiUtils.getParagraph("${element.dayTemp}°", mainTextSize, itemWidth: itemWith);
    canvas.drawParagraph(
      bottomTempPara, Offset(bottomOffset.dx - bottomTempPara.width / 2, bottomOffset.dy + 5));
    
    // 繪製折線
    if (index == 0) {
      _topPath.moveTo(topOffset.dx, topOffset.dy);
      _bottomPath.moveTo(bottomOffset.dx, bottomOffset.dy);
    } else {
      _topPath.lineTo(topOffset.dx, topOffset.dy);
      _bottomPath.lineTo(bottomOffset.dx, bottomOffset.dy);
    }
    startX += itemWith;
    });
    _paint.strokeWidth = 2;
    _paint.style = PaintingStyle.stroke;
    canvas.drawPath(_topPath, _paint);
    canvas.drawPath(_bottomPath, _paint);
    }
    

關鍵詞: 最大最小值

動態降雨折線圖

終於到了今天最難的角登場,只是對比前幾個比較難,在上述折線的基礎上加了折線入場動畫。話不多說咱們開始吧,上圖可拆成三部分,背景(y軸,xy軸描述)、漸變折線和動畫

背景

x 軸被二等分,y 軸被三等分,計算出 xItemWidth 和 yItemHeight,然後繪製線和文字

void drawBg(Canvas canvas, Size size) {
  // 繪製背景 line
  double itemHeight = (size.height - _marginBottom) / 3;
  double bgLineWidth = size.width - _marginLeft - _marginRight;
  _paint.style = PaintingStyle.stroke;
  _paint.strokeWidth = 1;
  _paint.color = Colors.white.withAlpha(100);
  for (int i = 0; i < 4; i++) {
    var startOffset = Offset(_marginLeft, itemHeight * i);
    var endOffset = Offset(_marginLeft + bgLineWidth, itemHeight * i);
    canvas.drawLine(startOffset, endOffset, _paint);
  }

  // 繪製底部文字
  var hourY = size.height - _marginBottom + _timeMarginTop;
  var nowPara = UiUtils.getParagraph("現在", _textSize, itemWidth: bgLineWidth / 3);
  canvas.drawParagraph(nowPara, Offset(_marginLeft - nowPara.width / 2, hourY));
  var onePara = UiUtils.getParagraph("1小時後", _textSize, itemWidth: bgLineWidth / 3);
  canvas.drawParagraph(onePara, Offset(_marginLeft + bgLineWidth / 2 - onePara.width / 2, hourY));
  var twoPara = UiUtils.getParagraph("2小時後", _textSize, itemWidth: bgLineWidth / 3);
  canvas.drawParagraph(twoPara, Offset(_marginLeft + bgLineWidth - twoPara.width / 2, hourY));

  // 繪製左側文字
  var bigPara = UiUtils.getParagraph("大", _textSize);
  canvas.drawParagraph(bigPara, Offset(_marginLeft / 2 - bigPara.width / 2, 0));
  var middlePara = UiUtils.getParagraph("中", _textSize);
  canvas.drawParagraph(middlePara, Offset(_marginLeft / 2 - middlePara.width / 2, itemHeight));
  var smallPara = UiUtils.getParagraph("小", _textSize);
  canvas.drawParagraph(smallPara, Offset(_marginLeft / 2 - smallPara.width / 2, itemHeight * 2));

}

漸變折線

  1. 繪製折線,最大值不用計算已經知道 yMax = 1.0,xMax = 120,可以計算出點的 x,y 座標值,然後進行遍歷繪製

    double width = size.width - _marginLeft - _marginRight;
    double height =  size.height - _marginBottom;
    double startX = _marginLeft;
    double itemWidth = width / 120;
    double itemHeight = height / 100;
    _linePath.reset();
    for (int i = 0; i < _data.length; i++) {
      double y = height - _data[i] * 100 * itemHeight * _ratio;
      double x = startX + i * itemWidth;
      if (i == 0) {
        _linePath.moveTo(x, y);
      } else {
        _linePath.lineTo(x, y);
      }
    }
    _linePaint.style = PaintingStyle.stroke;
    _linePaint.strokeWidth = 1;
    _linePaint.color = Colors.white;
    canvas.drawPath(_linePath, _linePaint);
    _linePath.lineTo(width + startX, height);
    _linePath.lineTo(startX, height);
    _linePath.close();
    
  2. 漸變效果,複用折線 path,通過 ui.Gradient.linear 創建漸變區域,然後設置到 _linePaint.shader

    var gradient = ui.Gradient.linear(
      Offset(0, 0),
      Offset(0, height),
      <Color>[
        const Color(0xFFffffff),
        const Color(0x00FFFFFF)
      ],
    );
    _linePaint.style = PaintingStyle.fill;
    _linePaint.shader = gradient;
    canvas.drawPath(_linePath, _linePaint);
    

入場動畫

漸變折線#1 中對 y 的計算 double y = height - _data[i] * 100 * itemHeight * _ratio; 中提到了 _ratio,這個就是控制動畫效果關鍵變量,區間 [0,1],0爲y=0.0 的直線,1爲實際的折線圖效果。

而這個 _ratio 有動畫進行控制:

_controller =
  AnimationController(duration: Duration(milliseconds: 250), vsync: this);
CurvedAnimation(parent: _controller, curve: Curves.linear);
_controller.addListener(() {
  setState(() {
    _ratio = _controller.value;
  });
});

最終的動態折線效果即可完成。

關鍵詞:drawLineui.Gradient.linearAnimationController

總結

整體下來,無論是圓弧、曲線還是折線或者類似簡單的繪製都有章可循。

  1. 對 待實現效果進行分析,找出關鍵信息進行分層分步,找出靜態數據和動態數據,也就是常量和變量。
  2. 計算好基礎數據,比如整體寬高,單位寬高,起始值,最大最小值
  3. 有了數據支撐,根據效果調用對應的繪製 API,設置 paint 的相關屬性,完成繪製
  4. 如果有動畫,以控制變量作爲切入口,動畫本身只關注變量值的改變,而不用考慮變量對繪製的影響
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章