Flutter篇 (二)繪製貝塞爾曲線 、折線 、柱狀圖,支持觸摸

     之前寫過一篇Android原生繪製曲線圖的博客,動畫效果不要太絲滑,那麼現在到了Flutter,該如何實現類似的效果呢?如果你熟悉android的Canvas,那麼恭喜你, 你將很快上手Flutter的Canvas繪製各種圖形,因爲實現方式基本上與android是一模一樣

先看下要實現的基本效果:

             

    Flutter中如果想要自定義繪製,那麼你需要用到 CustomPaint 和 CustomPainter ;  CustomPaint是Widget的子類,先來看下構造方法

const CustomPaint({
    Key key,
    this.painter,
    this.foregroundPainter,
    this.size = Size.zero,
    this.isComplex = false,
    this.willChange = false,
    Widget child,
  }) :super(key: key, child: child);

      我們只需要關心三個參數,painterforegroundPainter child  , 這裏需要說明一下,painter 是繪製的 backgroud 層,而child 是在backgroud之上繪製,foregroundPainter 是在 child 之上繪製,所以這裏就有了個層級關係,這跟android裏面的backgroud與foreground是一個意思,那這兩個painter的應用場景是什麼呢?假如你只是單純的想繪製一個圖形,只用painter就可以了,但是如果你想給繪製區域添加一個背景(顏色,圖片,等等),這時候如果使用 painter是會有問題的,painter的繪製會被child 層覆蓋掉,此時你只需要將painter替換成foregroundPainter,然會顏色或者圖片傳遞給child即可。

      如果是Android繪製幾何圖形,應該是重寫View的onLayout() 和 onDraw方法,但是Flutter實現繪製,必須繼承CustomPainter並重寫 paint(Canvas canvas, Size size)和 shouldRepaint (CustomPainter oldDelegate) 方法 ,第一個參數canvas就是我們繪製的畫布了(跟Android一模一樣),paint第二個參數Size就是上面CustomPaint構造方法傳入的size, 決定繪製區域的寬高信息

       既然Size已經確定了,現在就定義下繪製區域的邊界,一般我做類似的UI,都會定義一個最基本的padding, 一般取值爲16 , 因爲繪製的內容與座標軸之間需要找到一個基準線,這樣更容易繪製,而且調試邊距也很靈活

double startX, endX, startY, endY;//定義繪製區域的邊界
static const double basePadding = 16; //默認的邊距
double fixedHeight, fixedWidth; //去除padding後曲線的真實寬高
bool isShowXyRuler; //是否顯示xy刻度
List<ChatBean> chatBeans;//數據源

class ChatBean {
  String x;
  double y;
  int millisSeconds;
  Color color;

  ChatBean({@required this.x, @required this.y, this.millisSeconds, this.color});
}

       然後在paint()方法中拿到Size,確定繪製區域的座標

///計算邊界
  void initBorder(Size size) {
    print('size - - > $size');
    this.size = size;
    startX = yNum > 0 ? basePadding * 2.5 : basePadding * 2; //預留出y軸刻度值所佔的空間
    endX = size.width - basePadding * 2;
    startY = size.height - (isShowXyRuler ? basePadding * 3 : basePadding);
    endY = basePadding * 2;
    fixedHeight = startY - endY;
    fixedWidth = endX - startX;
    maxMin = calculateMaxMin(chatBeans);
  }

        maxMin是定義存儲曲線中最大值和最小值的

///計算極值 最大值,最小值
  List<double> calculateMaxMin(List<ChatBean> chatBeans) {
    if (chatBeans == null || chatBeans.length == 0) return [0, 0];
    double max = 0.0, min = 0.0;
    for (ChatBean bean in chatBeans) {
      if (max < bean.y) {
        max = bean.y;
      }
      if (min > bean.y) {
        min = bean.y;
      }
    }
    return [max, min];
  }

       初始化畫筆 .. 是dart中的獨特語法,代表使用對象的返回值調用屬性或方法

var paint = Paint()
      ..isAntiAlias = true//抗鋸齒
      ..strokeWidth = 2
      ..strokeCap = StrokeCap.round//折線連接處圓滑處理
      ..color = xyColor
      ..style = PaintingStyle.stroke;//描邊

      繪製座標軸,這裏在確定好的邊界基礎上再次xy軸橫向和縱向各自增加一倍的padding,不然顯得太緊湊

canvas.drawLine(Offset(startX, startY),Offset(endX + basePadding, startY), paint); //x軸
canvas.drawLine(Offset(startX, startY),Offset(startX, endY - basePadding), paint); //y軸

       繪製 X 軸刻度,定義爲最多繪製7組數據 ,rulerWidth就是刻度的長度定義爲8

int length = chatBeans.length > 7 ? 7 : chatBeans.length; //最多繪製7個
double DW = fixedWidth / (length - 1); //兩個點之間的x方向距離
double DH = fixedHeight / (length - 1); //兩個點之間的y方向距離
for (int i = 0; i < length; i++) {
     ///繪製x軸文本
     TextPainter(
            textAlign: TextAlign.center,
            ellipsis: '.',
            text: TextSpan(
                text: chatBeans[i].x,
                style: TextStyle(color: fontColor, fontSize: fontSize)),
            textDirection: TextDirection.ltr)
          ..layout(minWidth: 40, maxWidth: 40)
          ..paint(canvas, Offset(startX + DW * i - 20, startY + basePadding));

      ///x軸刻度
      canvas.drawLine(Offset(startX + DW * i, startY),Offset(startX + DW * i, startY - rulerWidth), paint);
   }

    這裏要說明一點,Flutter繪製文本,並不能像android那樣調用canvas.drawText () ,  而是通過TextPainter來渲染的,

構造TextPainter 你必須指定文字的方向 textDirection 和 寬度 layout ,最後調用paint方法,指定座標進行繪製   

   繪製 Y 軸刻度,y軸的刻度數量並不需要跟隨數據源的長度,只需要按照一定數量(yNum )平分y軸最大值即可

      int yLength = yNum + 1; //包含原點,所以 +1
      double dValue = maxMin[0] / yNum; //一段對應的值
      double dV = fixedHeight / yNum; //一段對應的高度
      for (int i = 0; i < yLength; i++) {
        ///繪製y軸文本,保留1位小數
        var yValue = (dValue * i).toStringAsFixed(isShowFloat ? 1 : 0);
        TextPainter(
            textAlign: TextAlign.center,
            ellipsis: '.',
            maxLines: 1,
            text: TextSpan(  
                text: '$yValue',
                style: TextStyle(color: fontColor, fontSize: fontSize)),
            textDirection: TextDirection.rtl)
          ..layout(minWidth: 40, maxWidth: 40)
          ..paint(canvas, Offset(startX - 40, startY - dV * i - fontSize / 2));

        ///y軸刻度
        canvas.drawLine(Offset(startX, startY - dV * (i)),Offset(startX + rulerWidth, startY - dV * (i)), paint);
      }

   現在座標軸和刻度已經繪製完成了,基本上與原生一致,只是代碼方式有些區別,接下來的曲線也是一模一樣的,繪製貝塞爾曲線其實也不難,主要是找到起點和兩個座標之間的輔助點, 貝塞爾曲線的原理可以參考這裏

path.cubicTo(double x1, double y1, double x2, double y2, double x3, double y3)
path = Path();
double preX, preY, currentX, currentY;
int length = chatBeans.length > 7 ? 7 : chatBeans.length;
double W = fixedWidth / (length - 1); //兩個點之間的x方向距離

  遍歷數據源的第一個元素時,需要做個判斷,index=0時,需要將path move到此處

if (i == 0) {
     path.moveTo(startX, (startY - chatBeans[i].y / maxMin[0] * fixedHeight));
     continue;
   }

   添加後面的座標時,需要找輔助點

currentX = startX + W * i;
preX = startX + W * (i - 1);

preY = (startY - chatBeans[i - 1].y / maxMin[0] * fixedHeight);
currentY = (startY - chatBeans[i].y / maxMin[0] * fixedHeight);

path.cubicTo(
              (preX + currentX) / 2, preY,
              (preX + currentX) / 2, currentY,
              currentX, currentY
            );

如果是要畫折線而非曲線,第一步還是path.moveTo  ,折線不需要找輔助點,所以後續可以直接添加座標,path.lineTo  

最後將path繪製出來

canvas.drawPath(newPath, paint);

雖然曲線已經成功繪製,但是這樣顯得很枯燥,如果可以看到繪製過程那就會更加有趣味性,這時候就需要通過動畫來更新曲線的path的長度了,一般Android中我會用ValueAnimator.ofFloat(start ,end ) 來開啓一個動畫 ,在Flutter中,動畫也是非常簡單實用

_controller = AnimationController(vsync: this, duration: widget.duration);
      Tween(begin: 0.0, end: widget.duration.inMilliseconds.toDouble())
          .animate(_controller)
            ..addStatusListener((status) {
              if (status == AnimationStatus.completed) {
                print('繪製完成');
              }
            })
            ..addListener(() {
              _value = _controller.value;//當前動畫值
              setState(() {});
            });
      _controller.forward();

 動畫執行過程中,我們會及時獲取到當前的動畫進度 _value, 此時就需要一段完整的path跟隨動畫值 等比繪製了 ,之前在Android中我們可以用 PathMeasure 來測量path ,然後根據動畫進度不斷地截取,就實現了像貪吃蛇一樣的效果, 但是在Flutter中,我並沒有找到PathMeasure 這個類,相反的,PathMeasure 在Flutter竟然是個私有的類 _PathMeasure ,經過一通百度 和 google,也沒有找到類似的案例。難道沒有人給造輪子,就必須要停止我前進的步伐了嘛,不急,顯然Path這個類裏面有很多方法,就這樣我走上了一條反覆測試的不歸路...

幸運的是,在翻閱了google 官方Flutter api 後,終於找到了突破口

    哈哈,藏得還挺深吶,就是這個 PathMetrics 類,path.computeMetrics() 的返回值 ,是用來將path解析成矩陣的一個工具

var pathMetrics = path.computeMetrics(forceClosed: false);

    有個參數 forceClosed , 表示是否要連接path的起始點 ,我們這裏當然不要啦 ,computeMetrics方法返回的是PathMetrics對象,調用 toList () 可以獲取到 多個path組成的 List<PathMetric> ; 集合中的每個元素代表一段path的矩陣 , 奇怪,爲什麼是多個path 呢 ???

    當時我也是懵着猜測的,歷史總是驚人的相似,被我給猜對了,不曉得你們有沒有發現,Path有個方法可以添加多個Path , 

path.addPath(path, offset);

    當我每調用一次 addPath()或者 moveTo() ,lsit . length就增加1,所以上面提到的多個path的集合 就不難理解了 ,因爲我們這裏只有一個path, 所以我們的 list 中只有一個元素 , 元素中包含一段path, 現在我們獲取到了描述path的矩陣PathMetric

  PathMetric . length 就是這段path的長度了,唉,爲了找到你 ,我容易嗎 !

另外還有個關鍵的方法,可以將pathMetric按照給定的位置區間截取,最後返回這段path, 這就跟android中的PathMeasure.getSegment()是一樣

extractPath(double start, double end,{ bool startWithMoveTo:true }) → Path
給定起始和停止距離,返回中間段。

現在是時候將前面獲取到的當前動畫值 value 用起來了,找到當前path的length乘以value即是當前path的最新長度

var pathMetrics = path.computeMetrics(forceClosed: true);
var list = pathMetrics.toList();
var length = value * list.length.toInt();
Path newPath = new Path();
for (int i = 0; i < length; i++) {
     var extractPath =list[i].extractPath(0, list[i].length * value, startWithMoveTo: true);
      newPath.addPath(extractPath, Offset(0, 0));
    }
 canvas.drawPath(newPath, paint);

走到這裏,好像跨過了山和大海,得了,困死了,睡了、睡了...

現在曲線和折線都已經繪製完成了,不過剛開始的demo裏還有個漸變色的部分沒有完成,貌似有了漸變色以後,顯得不那麼單調了,其實,我們繪圖所用到的Paint還有一個屬性shader,可以繪製線條或區域的漸變色,LinearGradient可實現線性漸變的效果,默認爲從左到右繪製,你可以通過beginend屬性自定義繪製的方向,我們這裏需要指定爲從上至下,並且顏色類型爲數組的形式,所以你可以傳入多個顏色值來繪製

var shader = LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              tileMode: TileMode.clamp,
              colors: shaderColors)
          .createShader(Rect.fromLTRB(startX, endY, startX, startY));

 值得注意的是,通過 createShader的方式創建shader,你需要指定繪製區域的邊界,我們這裏要實現的是從上至下,所以就以y軸爲基準,指定從上至下的繪製方向

既然是繪製漸變色,所以畫筆的樣式必須設置爲填充狀態

Paint shadowPaint = new Paint();
      shadowPaint
        ..shader = shader
        ..isAntiAlias = true
        ..style = PaintingStyle.fill;

另外,漸變色的區域我們是通過path來指定上面的邊界的,所以我們還需要指定path下面部分的起點和終點,這樣形成一個閉環,才能確定出完整的區域

///從path的最後一個點連接起始點,形成一個閉環
      shadowPath
        ..lineTo(startX + fixedWidth * value, startY)
        ..lineTo(startX, startY)
        ..close();
      canvas..drawPath(shadowPath, shadowPaint);

至此,即可實現帶有漸變色的曲線或者折線,也許你有個疑問,畫折線爲什麼也要用path呢,不是可以直接drawLine嗎 ?機智如我,添加到path以後,可以更方便的繪製,添加動畫也很方便

另附上最終的實現效果,至於觸摸操作就不打算闡述了,可以參考以下代碼

代碼已發佈到  Dart社區 https://pub.dev/flutter/packages?q=flutter_chart

GitHub倉庫鏈接 https://github.com/good-good-study/flutter_chart

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章