『Flutter-繪制篇』自定義View在天氣 APP 中的實戰(zhàn)應(yīng)用

前言

前不久,利用周末時間學(xué)習(xí)并完成一個簡單的 Flutter 項目 - 簡悅天氣簡約不簡單,豐富不復(fù)雜,這是一款簡約風(fēng)格的 flutter 天氣項目,提供實時、多日、24 小時、臺風(fēng)路徑以及生活指數(shù)等服務(wù),支持定位、刪除、搜索等操作。

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

圖1

開始

本身作為天氣 APP,自定義繪制自然少不了,首頁多樣的背景效果,炫酷的雨雪效果,展示當前空氣質(zhì)量和體感的圓環(huán)效果,動態(tài)溫度折線圖和日出日落圖。

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

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

  • 動態(tài)降雨折線圖


    rain_chart
  • 多日折線圖


    day_chart
  • 24小時折線圖


    hour_chart
  • AQI圓弧


    aqi_chart
  • 日出日落圖


    sun_chart

繪制

接下來,會以上述效果作為切入點,由簡到難,由靜態(tài)到動態(tài),逐步分析繪制前數(shù)據(jù)的準備和繪制時相關(guān)接口調(diào)用,最后,總結(jié)出折線圖繪制的通用思路,對后續(xù)有相關(guān)需求的小伙伴提供幫助。

AQI圓弧

aqi_chart

先從最簡單圓弧圖開始,如上圖可看到的信息有:半透明的圓弧,純白色的圓弧,居中的 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. 在半透明圓弧基礎(chǔ)上,根據(jù) ratio (currentAqiValue / totalAqiValue) 繪制純白色圓弧

  3. 依次繪制中間 AQIValueAQIDesc。Flutter 繪制文本跟 Android 比起來略微有點麻煩,通過構(gòu)造 ui.Paragraph 對象,然后調(diào)用 canvas.drawParagraph(Paragraph paragraph, Offset offset) 方法進行繪制。一般通過封裝好的靜態(tài)初始化方法構(gòu)建 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;
      }
    

關(guān)鍵詞: addArcParagraphdrawParagraph

日出日落貝塞爾曲線

sun_chart

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

繼續(xù)分解步驟:

  1. 繪制 虛曲線,首先確認起點和終點,通過 _path.quadraticBezierTo(double x1, double y1, double x2, double y2) 繪制貝塞爾曲線,參數(shù)需要傳入 控制點 坐標和 終點 坐標。很遺憾 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 輕松實現(xiàn)。對二階貝塞爾曲線稍有了解的可以知道,其主要由起始點和控制點組成,這三個值稍有變化,都很難做到重疊,所以得另辟蹊徑。

    Android 中有 PathMeasure 可以對 Path 進行分段,然后根據(jù)需要繪制的段數(shù)進行控制。同樣,F(xiàn)lutter 也有對應(yīng)的 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));
    

    關(guān)鍵詞: quadraticBezierTodashPathcomputeMetricsgetTangentForOffsetclipRectdrawCircle

多日折線圖

  • 多日折線圖


    day_chart

上下的文字區(qū)域繪制根據(jù)各自高度順延繪制即可,只要預(yù)留出中間折線的繪制區(qū)域即可。中間的折線區(qū)域又可以繼續(xù)平分成 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. 根據(jù)溫度計算x,y值,目前已知折線的高度 itemHeight, 具體溫度 temp,起點 topLineStartY,最高最低溫度已經(jīng)實際溫度,即可算出溫度對應(yīng)的 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);
    }
    

關(guān)鍵詞: 最大最小值

動態(tài)降雨折線圖

rain_chart

終于到了今天最難的角登場,只是對比前幾個比較難,在上述折線的基礎(chǔ)上加了折線入場動畫。話不多說咱們開始吧,上圖可拆成三部分,背景(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("現(xiàn)在", _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));

  // 繪制左側(cè)文字
  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. 繪制折線,最大值不用計算已經(jīng)知道 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. 漸變效果,復(fù)用折線 path,通過 ui.Gradient.linear 創(chuàng)建漸變區(qū)域,然后設(shè)置到 _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,這個就是控制動畫效果關(guān)鍵變量,區(qū)間 [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;
  });
});

最終的動態(tài)折線效果即可完成。

關(guān)鍵詞:drawLineui.Gradient.linearAnimationController

總結(jié)

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

  1. 對 待實現(xiàn)效果進行分析,找出關(guān)鍵信息進行分層分步,找出靜態(tài)數(shù)據(jù)和動態(tài)數(shù)據(jù),也就是常量和變量。
  2. 計算好基礎(chǔ)數(shù)據(jù),比如整體寬高,單位寬高,起始值,最大最小值
  3. 有了數(shù)據(jù)支撐,根據(jù)效果調(diào)用對應(yīng)的繪制 API,設(shè)置 paint 的相關(guān)屬性,完成繪制
  4. 如果有動畫,以控制變量作為切入口,動畫本身只關(guān)注變量值的改變,而不用考慮變量對繪制的影響
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。