Flutter篇 (二)繪制貝塞爾曲線 、折線 躏精、柱狀圖渣刷,支持觸摸

之前寫過一篇Android原生繪制曲線圖的博客,動(dòng)畫效果不要太絲滑矗烛,那么現(xiàn)在到了Flutter辅柴,該如何實(shí)現(xiàn)類似的效果呢?如果你熟悉android的Canvas,那么恭喜你, 你將很快上手Flutter的Canvas繪制各種圖形,因?yàn)閷?shí)現(xiàn)方式基本上與android是一模一樣

先看下要實(shí)現(xiàn)的基本效果:

Flutter中如果想要自定義繪制碌嘀,那么你需要用到 **CustomPaint **和 CustomPainter ; CustomPaint是Widget的子類涣旨,先來看下構(gòu)造方法

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);

我們只需要關(guān)心三個(gè)參數(shù),painter筏餐,foregroundPainterchild 开泽, 這里需要說明一下,painter 是繪制的 backgroud 層魁瞪,而child 是在backgroud之上繪制穆律,foregroundPainter 是在 child 之上繪制,所以這里就有了個(gè)層級(jí)關(guān)系导俘,這跟android里面的backgroud與foreground是一個(gè)意思峦耘,那這兩個(gè)painter的應(yīng)用場(chǎng)景是什么呢?假如你只是單純的想繪制一個(gè)圖形旅薄,只用painter就可以了辅髓,但是如果你想給繪制區(qū)域添加一個(gè)背景(顏色,圖片少梁,等等)洛口,這時(shí)候如果使用 painter是會(huì)有問題的,painter的繪制會(huì)被child 層覆蓋掉凯沪,此時(shí)你只需要將painter替換成foregroundPainter,然會(huì)顏色或者圖片傳遞給child即可第焰。

如果是Android繪制幾何圖形,應(yīng)該是重寫View的onLayout() 和 onDraw方法妨马,但是Flutter實(shí)現(xiàn)繪制挺举,必須繼承CustomPainter并重寫 paint(Canvas canvas, Size size)和 shouldRepaint (CustomPainter oldDelegate) 方法 ,第一個(gè)參數(shù)canvas就是我們繪制的畫布了(跟Android一模一樣)烘跺,paint第二個(gè)參數(shù)Size就是上面CustomPaint構(gòu)造方法傳入的size, 決定繪制區(qū)域的寬高信息

既然Size已經(jīng)確定了湘纵,現(xiàn)在就定義下繪制區(qū)域的邊界,一般我做類似的UI,都會(huì)定義一個(gè)最基本的padding, 一般取值為16 滤淳, 因?yàn)槔L制的內(nèi)容與坐標(biāo)軸之間需要找到一個(gè)基準(zhǔn)線梧喷,這樣更容易繪制,而且調(diào)試邊距也很靈活

double startX, endX, startY, endY;//定義繪制區(qū)域的邊界
static const double basePadding = 16; //默認(rèn)的邊距
double fixedHeight, fixedWidth; //去除padding后曲線的真實(shí)寬高
bool isShowXyRuler; //是否顯示xy刻度
List<ChatBean> chatBeans;//數(shù)據(jù)源

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

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

然后在paint()方法中拿到Size,確定繪制區(qū)域的坐標(biāo)

///計(jì)算邊界
  void initBorder(Size size) {
    print('size - - > $size');
    this.size = size;
    startX = yNum > 0 ? basePadding * 2.5 : basePadding * 2; //預(yù)留出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是定義存儲(chǔ)曲線中最大值和最小值的

///計(jì)算極值 最大值,最小值
  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中的獨(dú)特語法脖咐,代表使用對(duì)象的返回值調(diào)用屬性或方法

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

繪制坐標(biāo)軸伤柄,這里在確定好的邊界基礎(chǔ)上再次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組數(shù)據(jù) 文搂,rulerWidth就是刻度的長(zhǎng)度定義為8

int length = chatBeans.length > 7 ? 7 : chatBeans.length; //最多繪制7個(gè)
double DW = fixedWidth / (length - 1); //兩個(gè)點(diǎn)之間的x方向距離
double DH = fixedHeight / (length - 1); //兩個(gè)點(diǎn)之間的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);
   }

這里要說明一點(diǎn)适刀,F(xiàn)lutter繪制文本,并不能像android那樣調(diào)用canvas.drawText () , 而是通過TextPainter來渲染的;
構(gòu)造TextPainter 你必須指定文字的方向 textDirection 和 寬度 layout ,最后調(diào)用paint方法煤蹭,指定坐標(biāo)進(jìn)行繪制;
繪制 Y 軸刻度笔喉,y軸的刻度數(shù)量并不需要跟隨數(shù)據(jù)源的長(zhǎng)度取视,只需要按照一定數(shù)量(**yNum **)平分y軸最大值即可。

      int yLength = yNum + 1; //包含原點(diǎn),所以 +1
      double dValue = maxMin[0] / yNum; //一段對(duì)應(yīng)的值
      double dV = fixedHeight / yNum; //一段對(duì)應(yīng)的高度
      for (int i = 0; i < yLength; i++) {
        ///繪制y軸文本常挚,保留1位小數(shù)
        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);
      }

現(xiàn)在坐標(biāo)軸和刻度已經(jīng)繪制完成了作谭,基本上與原生一致,只是代碼方式有些區(qū)別奄毡,接下來的曲線也是一模一樣的折欠,繪制貝塞爾曲線其實(shí)也不難,主要是找到起點(diǎn)和兩個(gè)坐標(biāo)之間的輔助點(diǎn)吼过, 貝塞爾曲線的原理可以參考這里

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); //兩個(gè)點(diǎn)之間的x方向距離

遍歷數(shù)據(jù)源的第一個(gè)元素時(shí)锐秦,需要做個(gè)判斷,index=0時(shí)盗忱,需要將path move到此處

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

添加后面的坐標(biāo)時(shí)酱床,需要找輔助點(diǎn)

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 趟佃,折線不需要找輔助點(diǎn)扇谣,所以后續(xù)可以直接添加坐標(biāo),path.lineTo

最后將path繪制出來

canvas.drawPath(newPath, paint);

雖然曲線已經(jīng)成功繪制闲昭,但是這樣顯得很枯燥罐寨,如果可以看到繪制過程那就會(huì)更加有趣味性,這時(shí)候就需要通過動(dòng)畫來更新曲線的path的長(zhǎng)度了序矩,一般Android中我會(huì)用ValueAnimator.ofFloat(start ,end ) 來開啟一個(gè)動(dòng)畫 ,在Flutter中衩茸,動(dòng)畫也是非常簡(jiǎn)單實(shí)用

_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;//當(dāng)前動(dòng)畫值
              setState(() {});
            });
      _controller.forward();

動(dòng)畫執(zhí)行過程中,我們會(huì)及時(shí)獲取到當(dāng)前的動(dòng)畫進(jìn)度 _value, 此時(shí)就需要一段完整的path跟隨動(dòng)畫值 等比繪制了 贮泞,之前在Android中我們可以用 PathMeasure 來測(cè)量path ,然后根據(jù)動(dòng)畫進(jìn)度不斷地截取幔烛,就實(shí)現(xiàn)了像貪吃蛇一樣的效果啃擦, 但是在Flutter中,我并沒有找到PathMeasure 這個(gè)類饿悬,相反的令蛉,PathMeasure 在Flutter竟然是個(gè)私有的類 _PathMeasure ,經(jīng)過一通百度 和 google狡恬,也沒有找到類似的案例珠叔。難道沒有人給造輪子,就必須要停止我前進(jìn)的步伐了嘛弟劲,不急祷安,顯然Path這個(gè)類里面有很多方法,就這樣我走上了一條反復(fù)測(cè)試的不歸路...

幸運(yùn)的是兔乞,在翻閱了google 官方Flutter api 后汇鞭,終于找到了突破口

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

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

有個(gè)參數(shù) forceClosed , 表示是否要連接path的起始點(diǎn) 读整,我們這里當(dāng)然不要啦 簿训,computeMetrics方法返回的是PathMetrics對(duì)象,調(diào)用 toList () 可以獲取到 多個(gè)path組成的 List<PathMetric> ; 集合中的每個(gè)元素代表一段path的矩陣 米间, 奇怪强品,為什么是多個(gè)path 呢 ?车伞?择懂?
當(dāng)時(shí)我也是懵著猜測(cè)的,歷史總是驚人的相似另玖,被我給猜對(duì)了困曙,不曉得你們有沒有發(fā)現(xiàn),**Path有個(gè)方法可以添加多個(gè)Path , **

path.addPath(path, offset);

當(dāng)我每調(diào)用一次 addPath()或者 moveTo() 谦去,lsit . length就增加1慷丽,所以上面提到的多個(gè)path的集合 就不難理解了 ,因?yàn)槲覀冞@里只有一個(gè)path, 所以我們的 list 中只有一個(gè)元素 , 元素中包含一段path, 現(xiàn)在我們獲取到了描述path的矩陣PathMetric

PathMetric . length 就是這段path的長(zhǎng)度了鳄哭,唉要糊,為了找到你 ,我容易嗎 妆丘!
另外還有個(gè)關(guān)鍵的方法锄俄,可以將pathMetric按照給定的位置區(qū)間截取,最后返回這段path, 這就跟android中的PathMeasure.getSegment()是一樣

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

現(xiàn)在是時(shí)候?qū)⑶懊娅@取到的當(dāng)前動(dòng)畫值 value 用起來了毅戈,找到當(dāng)前path的length乘以value即是當(dāng)前path的最新長(zhǎng)度

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);

走到這里,好像跨過了山和大海愤惰,得了苇经,困死了,睡了宦言、睡了...

現(xiàn)在曲線和折線都已經(jīng)繪制完成了扇单,不過剛開始的demo里還有個(gè)漸變色的部分沒有完成,貌似有了漸變色以后奠旺,顯得不那么單調(diào)了令花,其實(shí)阻桅,我們繪圖所用到的Paint還有一個(gè)屬性shader,可以繪制線條或區(qū)域的漸變色兼都,LinearGradient可實(shí)現(xiàn)線性漸變的效果嫂沉,默認(rèn)為從左到右繪制,你可以通過beginend屬性自定義繪制的方向扮碧,我們這里需要指定為從上至下趟章,并且顏色類型為數(shù)組的形式,所以你可以傳入多個(gè)顏色值來繪制

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

值得注意的是慎王,通過 createShader的方式創(chuàng)建shader蚓土,你需要指定繪制區(qū)域的邊界,我們這里要實(shí)現(xiàn)的是從上至下赖淤,所以就以y軸為基準(zhǔn)蜀漆,指定從上至下的繪制方向
既然是繪制漸變色,所以畫筆的樣式必須設(shè)置為填充狀態(tài)

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

另外咱旱,漸變色的區(qū)域我們是通過path來指定上面的邊界的确丢,所以我們還需要指定path下面部分的起點(diǎn)和終點(diǎn),這樣形成一個(gè)閉環(huán)吐限,才能確定出完整的區(qū)域

///從path的最后一個(gè)點(diǎn)連接起始點(diǎn)鲜侥,形成一個(gè)閉環(huán)
      shadowPath
        ..lineTo(startX + fixedWidth * value, startY)
        ..lineTo(startX, startY)
        ..close();
      canvas..drawPath(shadowPath, shadowPaint);

至此,即可實(shí)現(xiàn)帶有漸變色的曲線或者折線诸典,也許你有個(gè)疑問描函,畫折線為什么也要用path呢,不是可以直接drawLine嗎 狐粱?機(jī)智如我舀寓,添加到path以后,可以更方便的繪制肌蜻,添加動(dòng)畫也很方便; 另附上最終的實(shí)現(xiàn)效果互墓,至于觸摸操作就不打算闡述了,可以參考以下代碼

代碼已發(fā)布到 Dart https://pub.dev/flutter/packages?q=flutter_chart
GitHub倉(cāng)庫鏈接 https://github.com/good-good-study/flutter_chart

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末宋欺,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子胰伍,更是在濱河造成了極大的恐慌齿诞,老刑警劉巖,帶你破解...
    沈念sama閱讀 212,718評(píng)論 6 492
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件骂租,死亡現(xiàn)場(chǎng)離奇詭異祷杈,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)渗饮,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,683評(píng)論 3 385
  • 文/潘曉璐 我一進(jìn)店門但汞,熙熙樓的掌柜王于貴愁眉苦臉地迎上來宿刮,“玉大人,你說我怎么就攤上這事私蕾〗┤保” “怎么了?”我有些...
    開封第一講書人閱讀 158,207評(píng)論 0 348
  • 文/不壞的土叔 我叫張陵踩叭,是天一觀的道長(zhǎng)磕潮。 經(jīng)常有香客問我,道長(zhǎng)容贝,這世上最難降的妖魔是什么自脯? 我笑而不...
    開封第一講書人閱讀 56,755評(píng)論 1 284
  • 正文 為了忘掉前任,我火速辦了婚禮斤富,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己楷掉,他們只是感情好张吉,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,862評(píng)論 6 386
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著脚囊,像睡著了一般龟糕。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上悔耘,一...
    開封第一講書人閱讀 50,050評(píng)論 1 291
  • 那天讲岁,我揣著相機(jī)與錄音,去河邊找鬼衬以。 笑死缓艳,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的看峻。 我是一名探鬼主播阶淘,決...
    沈念sama閱讀 39,136評(píng)論 3 410
  • 文/蒼蘭香墨 我猛地睜開眼,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼互妓!你這毒婦竟也來了溪窒?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,882評(píng)論 0 268
  • 序言:老撾萬榮一對(duì)情侶失蹤冯勉,失蹤者是張志新(化名)和其女友劉穎澈蚌,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體灼狰,經(jīng)...
    沈念sama閱讀 44,330評(píng)論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡宛瞄,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,651評(píng)論 2 327
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了交胚。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片份汗。...
    茶點(diǎn)故事閱讀 38,789評(píng)論 1 341
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡盈电,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出杯活,到底是詐尸還是另有隱情匆帚,我是刑警寧澤,帶...
    沈念sama閱讀 34,477評(píng)論 4 333
  • 正文 年R本政府宣布轩猩,位于F島的核電站卷扮,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏均践。R本人自食惡果不足惜晤锹,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 40,135評(píng)論 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望彤委。 院中可真熱鬧鞭铆,春花似錦、人聲如沸焦影。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,864評(píng)論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽斯辰。三九已至舶担,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間彬呻,已是汗流浹背衣陶。 一陣腳步聲響...
    開封第一講書人閱讀 32,099評(píng)論 1 267
  • 我被黑心中介騙來泰國(guó)打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留闸氮,地道東北人剪况。 一個(gè)月前我還...
    沈念sama閱讀 46,598評(píng)論 2 362
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像蒲跨,于是被迫代替她去往敵國(guó)和親译断。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,697評(píng)論 2 351