Flutter自定义控件之饼状图、大转盘

*本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布   

Flutter中的自定义饼状图

    • 效果图
    • 控件功能
    • Flutter中的canvas和paint
      • 大致流程:
    • 画圆弧
        • 声明一些必要元素
        • 声明一只画笔painter
    • 画扇形
    • 在扇形上画文字
    • 系稳安全带,准备加速
    • 惯性转动
      • 雾里探花
      • 动画的运用

效果图

入门 时下最火的移动开发技术Flutter 也 快一年了 ,一直没遇到 入门级但又不失逼格的自定义控件,最近她来了,看似简单,实则蕴含flutter中的绘制基础、动画基础,以及融合了数学、物理学的高难度控件,按照国际性惯例先上图:

看着是不是很简单?那我们上车出发~

控件功能

1、饼状图,显示各元素的比例。
2、各元素上有文字说明
3、圆盘可跟着手势转动
4、快速滑动抬起手后,可惯性滚动直至停止

Flutter中的canvas和paint

搞安卓的 同学都知道 自定义View 中抛开组合控件,需要用到Canvas和Paint,那么在flutter中也一样,给你个画布canvas和画笔paint,可以画出属于你自己的世界。

大致流程:

1、新建类继承于CustomPainter实现paint()和shouldRepaint()方法
2、在paint方法中绘制你想要的内容
3、借助于 CustomPaint Widget来构建自己的Widget

具体细节不作阐述,不熟悉的同学可以移步官方教程Flutter中文网

画圆弧

声明一些必要元素


  ///比例集合
  List angles;

  ///文案集合
  List contents;

  ///颜色集合
    List colors;  

声明一只画笔painter

    Paint paint = Paint()
      ..color = Colors.red //颜色 红色
      ..strokeWidth = 3.0 // 宽度3.0
      ..isAntiAlias = true //是否抗锯齿 是
      ..style = PaintingStyle.fill; // 模式 填充

画扇形

毕竟都是谷歌旗下产品,api名称和安卓都一模一样,对于安卓同学来说就是换个地方画东西而已。

 void drawArc(Rect rect, double startAngle, double sweepAngle, bool useCenter, Paint paint)

五个参数都是知名答意,现在是,startAngle(起始角度,参照点是以水平方向为0Ω dart中 就是0pi)、sweepAngle(扫过的区域角度)、useCenter(扇形区域是否实心)、paint(画笔) 我们都有了,就差一个rect,那我们就new一个出来。

     //声明一个包裹 以控件中心为中心,控件宽一半为半径的圆  的正方形。 
    Rect rect = Rect.fromCircle(
        center: Offset(size.width / 2, size.height / 2),
        radius: size.width / 2);

下面我们就可以遍历比例angles集合,把扇形区域画出来了。

  double startAngles=0,标记每个元素的其实角度
    //画扇形
    for (int i = 0; i < angles.length; i++) {
      paint..color = colors[i];//取到颜色
      canvas.drawArc(rect, 2 * pi * startAngles
          2 * pi * angles[i], true, paint);
      startAngles += angles[i];
    }

这样我们就把比例画出来了,到此只能说稳如狗。
Flutter自定义控件之饼状图、大转盘_第1张图片

在扇形上画文字

扇形画完就该在上面写 一些文字用以说明 某个区域代表什么意思了 ,此时暗暗回忆了一下,安卓里面的画文字就是 canvas.drawText…,不过可惜了,flutter中画文字和安卓里面不一样!!!先看一下画文字的方法:

drawParagraph(Paragraph paragraph, Offset offset) ;

看着很简单,由此可见,画文字不需要用到画笔,需要的是Paragraph以及Offset,一个是段落一个是偏移量的意思,切记偏移量在这里指的是文字左上角的 位置,不是安卓里面的左下角。那么重头戏就是paragraph这个变量了。大概也就是长这样:

      // 新建一个段落建造器,然后将文字基本信息填入;
      ParagraphBuilder pb = ParagraphBuilder(ParagraphStyle(
        textAlign: TextAlign.left,
        fontWeight: FontWeight.w300,
        fontStyle: FontStyle.normal,
        fontSize: 15.0,
      ));
      pb.pushStyle(ui.TextStyle(color: Colors.white));
      pb.addText(text]);
      // 设置文本的宽度约束
      ParagraphConstraints pc = ParagraphConstraints(width: 400);
      // 这里需要先layout,将宽度约束填入,否则无法绘制
      Paragraph paragraph = pb.build()..layout(pc);

这里主要说一点懵逼的地方,ParagraphConstraints实例的时候,必须要声明width作为 宽,要不后面build的时候无效,不知道是我没搞明白还是另有其他方法,实际上我们也不知道 文案具体的宽高,也没找到 安卓中 测量 一段文本宽高的方法,知道的同学 务必请留言说一下啊,感谢!

下面我们就可以画文本了,画之前要想清楚一个问题,drawParagraph方法里面没有涉及角度的东西,所以画的时候我们只能通过把canvas 移到圆环中心,并且 旋转的方法 让文案进行旋转,这一点 和安卓里面是一样的,下面 上主要代码:

 double startAngles = 0;

    for (int i = 0; i < contents.length; i++) {
      canvas.save();//这里先把canvas保存个副本,待会移动旋转完 好还原

      // 新建一个段落建造器,然后将文字基本信息填入;
       ParagraphBuilder pb = ParagraphBuilder...上面那一段,拿到paragraph.
   
      // 文字左上角起始点
      Offset offset = Offset(60, 0 - paragraph.height / 2);//由于没找到算文本宽度的方法,这里文本x轴的七点模拟写成 了60。

      canvas.translate(size.width / 2, size.height / 2);//先把canvas移动到中心点
      double roaAngle =    2 * pi * (startAngles + angles[i] / 2) ;
      canvas.rotate(roaAngle);  //再将画布旋转一定角度,具体这个角度怎么来的, 稍微琢磨一下就明白了
      
      canvas.drawParagraph(paragraph, offset);

      canvas.restore();//画完后重置 画布
      startAngles += angles[i]; //把当前元素角度累加
    }
   

这样我们就把文案画在 比例 圆环的内部了,是不是很简单:

Flutter自定义控件之饼状图、大转盘_第2张图片

系稳安全带,准备加速

到此基础绘制完成了,暂时只涉及到来了 一些简单的绘制,也是给各位老铁温习一下而已,当然对于新手 , 应该还是有些难度的。下面我们要加速了,光有个图不是我们的目的,我们还得让他跟着手指动起来,这里就涉及到了手势处理。

玩安卓自定义View时,里面的onTouchEvent,以及稍微长一点的onInterruptTouchEvent 是我们经常光顾的地方,一些列的 手势传递,诸如down、move、up、flying 这些多少次把我们折磨的体无完肤,那么在flutter中手势处理用什么呢?那就是GestureDetector这个Widget,涉及到拖动的方法回调是这三个:


   1、onPanDown(DragDownDetails details)         --------- 手指按下时触发 

   2、onPanUpdate(DragUpdateDetails details)     ---------手指移动时触发

   3、onPanEnd (DragEndDetails details)          --------- 手指抬起时触发

可以看见一些列的 down、move、up 操作都能拿到了,下面开始看看怎么用。

脑海中冥想一下滑在圆盘上面,让圆盘跟着动,当手放下的时候 我们从圆心到手指画一条圆半径长的线段,当手指移动的时候,手指到圆心的距离肯定是会变化的,我们只要能做到手指滑动的时候让手指一直在刚才那条线段上就大工搞成了,想想是不是很简单!!!那么问题来了,如何做到这样呢?要是垂直或者水平滑动 就很简单了,计算偏移量,然后 累加,更新偏移量。。。。。。问题是这里不是水平或者垂直运动,而是绕着圆心滑动,那么这里改用什么计算偏移量呢?没错,就是角度,假设我们从A点滑到B点,只要计算出A、B两点以圆心O为顶点形成的角不就可以了 ?如下图:

Flutter自定义控件之饼状图、大转盘_第3张图片

每次 onPanUpdate 回调的时候,我们 根据此时的 x、y坐标计算出 角AOB的大小,然后累加,绘制的时候 加上这个角度就可以了。那么这个角AOB 怎么算呢?我相信很多人都忘记了三角函数了,这里有很多方法,需要注意的是这里涉及到 正负问题,由于高中的三角函数也忘得差不多了 我就化繁为简,计算角BOC的大小,然后计算角AOC的大小,然后做减法就得到带方向的 角BOA 了。
角BOC和角AOC 的算法就很多了,首先三角形内角公式:
在这里插入图片描述
再者就是通过sin和cos求得,具体细节就不阐述了,想不明白的同学可以留言或者看代码细节。伪代码如下


double getTurns() {

    /// o点(offset.dx+radius,offset.dy+radius).
    /// C点 (offset.dx+2*radius,offset.dy+radius).
    /// oc距离  radius
    /// A点 (pAx,pAy),
    /// B点  (pBx,pBy).

    /// AC距离
    double acDistance = ChartUtils.distanceForTwoPoint(
        offset.dx + 2 * widget.radius, offset.dy + widget.radius, pAx, pAy);

    /// AO距离
    double aoDistance = ChartUtils.distanceForTwoPoint(
        offset.dx + widget.radius, offset.dy + widget.radius, pAx, pAy);


    double ocdistance = widget.radius;

///根据手在上半圆还是下半圆, 决定角是正还是负
    int c = 1;

    if (pAy < (offset.dy + widget.radius)) {
      c = -1;
    }
    ///计算 cos aoc 的值 ,然后拿到 角 aoc
    double cosAOC = (aoDistance * aoDistance + ocdistance * ocdistance -  acDistance * acDistance) / (2 * aoDistance * ocdistance);
        
    double AOC = c * acos(cosAOC);

    /// BC距离
    double bcDistance = ChartUtils.distanceForTwoPoint(
        offset.dx + 2 * widget.radius, offset.dy + widget.radius, pBx, pBy);

    /// BO距离
    double boDistance = ChartUtils.distanceForTwoPoint(
        offset.dx + widget.radius, offset.dy + widget.radius, pBx, pBy);

    c = 1;
    if (pBy < (offset.dy + widget.radius)) {
      c = -1;
    }

    ///计算 cos boc 的值,然后拿到角 boc;
    double cosBOC = (boDistance * boDistance +ocdistance * ocdistance -  bcDistance * bcDistance) /   (2 * boDistance * ocdistance);
    double BOC = c * acos(cosBOC);

    return BOC - AOC;
  }

这样圆盘就跟着手动起来啦,是不是还是可以接受,这里主要就是用到了数学的三角形角度计算以及带一点极限思想而已,极限就是A移动到B的过程。

惯性转动

主要功能实现了,还有最后一步,当然也是最复杂的一步,上一步我们实现了圆盘跟着手滑动,那么手指抬起来的时候 肯定存在一个 滑动速度,按照正常思维,这个时候,圆盘应该在此速度之下继续滚动,而不是手指一抬就停止,那么这里又改如何是好呢?

雾里探花

再次闭上我们的双眼,开始冥想,开阔点的想一想地球绕着太阳旋转的过程, 狭隘点想着一头驴在拉磨,拉着拉着绳子断了,这个时候磨在惯性驱动下,如果没有摩擦力的影响,肯定也会一直转下去,由于存在摩擦力,滚两下就停下来了,这里有三个因素:
1、 绳子断了的时候,线速度v,
2、摩擦力 f,
3、绳子系在磨上的点到磨中心点的距离 r。
怎么样,满满的物理感。那这几个因素该怎么用呢?
其实是一个道理,这个时候我们同样要拿到线速度相对于圆心对应的角速度jv,然后给一个每秒减少的角速度量,也就是加速度:ja,通过动画或者拿到移动特定时间转过的角度 累加上,刷新UI,就完成了,想想这样是能行得通。那么问题核心就是求角速度了,这是最难最复杂的地方了,由于手指抬起时我们可以拿到此时x、y方向上的速度velocityX、velocitY,下面再借用大神的一张图细说线速度与角速度的关系:
Flutter自定义控件之饼状图、大转盘_第4张图片


1、手势up时,拿到当时的velocityX和velocityY,就能得到矢量线速度lineSpeed,并且计算出其与水平线的夹角vectorAngle;
2、然后根据up点的坐标值和中心点坐标值,计算up点到中心点的连线与水平线的夹角levelAngle;
3、通过vectorAngle与levelAngle获取circleLineAngle;
4、勾股定理lineSpeed与circleLineAngle获取circleSpeed;
5、角速度 w 与线速度 v 的关系:wr = v,得到角速度,其中r是触摸点到中心点的距离;
6、减速度绘制刷新UI。

由于计算量过于庞大,就不上代码了,总之就为了拿到此时的 角速度jiaoV。

动画的运用

这样假设我们通过各种骚气操作拿到了角速度jiaoV,然后回到flutter 的动画上来,由于flutter 没提供安卓中的减速度过程的回调方法,这里我提供两种方法,

  1. 我们假设从时间 tA到tB ,分别计算这两个时间点的角速度vA和vB,这样就拿到了 tA到tB 这个时间段转过的角度jAB,做累加,这是一种方法。
  2. jiaoV除以角速度ja,得到一共惯性运动的时间t,这样我们就拿到了动画运行时间,同样假设时间点tC,可以计算0-tC之间滑动的过得角度jC,这样直接把这个角度在绘制的时候加上就可以了,主要涉及的就是路程、时间、加速度公式得到转过的角度,伪代码如下:



      /// 按照 va的加速度 拿到 滚动的时间 。 也就是 结束后 惯性动画的 执行 时间, 高中物理
      double t = ChartUtils.abs(inertiaInitAngle) , // vA;毫秒级
      
      double animalValue = turns;//由于不是累加,定义一个变量作为当前旋转的角度
      var time = new DateTime.now();//动画开启时 的 时间戳
      int direction = 1; ///方向控制参数,用以最后的角度是加还是减
      if (inertiaInitAngle < 0) {
        direction = -1;
      }
      
      //flutter中的动画,不熟悉的同学可以看下官网
      autoAnimationController = AnimationController(
          duration: Duration(milliseconds: (t * 1000).toInt()), vsync: this)
        ..addListener(() {
          var animalTime = new DateTime.now();
          double t1 = animalTime.millisecondsSinceEpoch - time.millisecondsSinceEpoch;//动画执行中间的某个时间点
          setState(() {
            double s1 = (2 * inertiaInitAngle - direction * vA * (t1 / 1000)) *t1 / (2 * 1000);//路程、时间、加速度公式得到转过的角度。
            turns = animalValue + s1;
          });
        });

      autoAnimationController.forward();

这样我们的圆盘就有了惯性运动啦,快哉快哉,作为入门级自定义控件还是值得一练,当然可以根据需求 让他自动旋转、实现抽奖转盘的特定功能,这里不做多阐述,抛砖引玉即可。

感想:感觉自定义控件终极思想都是数学问题、空间思维偏多,加以API辅助。

时间蹉跎,记得按时撸码。

Flutter架构系列之BaseWidget封装

代码已全部上传github,欢迎star、fork,如果有问题 , 欢迎加QQ群 661614986。
Flutter自定义控件之饼状图、大转盘_第5张图片

你可能感兴趣的:(fluttr)