Bootstrap

【Flutter 专题】三步搞定会转的饼状图

    和尚在日常学习中会使用到基本的饼状图,现在 pub 库中已经有了很多非常多的插件,和尚尝试自己绘制一版简单的饼状图,;今天给大家分享一下【0 基础学习 Flutter】系列中的【三步搞定会转的饼状图】;饼状图在我们日常中是很常见的,但是乍一看不仅需要图形绘制,还需要手势操作与动画的结合,整体看来非常复杂;于是今天和尚对其进行拆解,分为三步骤,轻松搞定会转的饼状图;和尚自定义了一个 ACEPieWidget 小组件;

ACEPieWidget

1. 绘制饼状图

    为了方便区分饼状图中颜色分类和数据比例,和尚先定义几个基本的属性分类,以及基本的数据比例数据;添加几个圆形 Container 即可;和尚为了节省时间,部分代码预先编辑好,重点部分和尚会做详细介绍;

    其中和尚定义的 ACEPieWidget 仅需要一个传递一个 List> 即可,主要是获取对应 item 名称和比例;

List> _listData = [];

整体思路

    和尚通过 CustomPaint 来绘制饼状图,之前和尚有整理过 Canvas 相关方法的博客,这次和尚也是通过最基本的 drawArc 绘制扇形并拼接成一个圆的方式;

_chartWid() => Container(
    height: 250.0,
    color: Colors.grey.withOpacity(0.2),
    child: Row(children: [
      Column(children: [
        _itemChartWid(BillType.BILL_PAR_NORMAL),
        _itemChartWid(BillType.BILL_PAR_FIT),
        _itemChartWid(BillType.BILL_PAR_MARRY),
        _itemChartWid(BillType.BILL_PAR_CAR),
        _itemChartWid(BillType.BILL_PAR_BABY)
      ]),
      Expanded(child: ACEPieWidget(_listData), flex: 1),
      Column(children: [
        _itemChartWid(BillType.BILL_PAR_PET),
        _itemChartWid(BillType.BILL_PAR_STUDY),
        _itemChartWid(BillType.BILL_PAR_BUSINESS),
        _itemChartWid(BillType.BILL_PAR_MONEY),
        _itemChartWid(BillType.BILL_PAR_OTHER)
      ])
    ]));
    
_getListData() {
  _listData.clear();
  Map map01 = Map();
  map01[BillType.BILL_PAR_NORMAL] = 800.67;
  Map map02 = Map();
  map02[BillType.BILL_PAR_FIT] = 323.4;
  Map map03 = Map();
  map03[BillType.BILL_PAR_MARRY] = 480;
  Map map04 = Map();
  map04[BillType.BILL_PAR_CAR] = 1357.9;
  Map map05 = Map();
  map05[BillType.BILL_PAR_BABY] = 30;
  Map map06 = Map();
  map06[BillType.BILL_PAR_PET] = 600.78;
  Map map07 = Map();
  map07[BillType.BILL_PAR_STUDY] = 125.9;
  Map map08 = Map();
  map08[BillType.BILL_PAR_BUSINESS] = 99;
  Map map09 = Map();
  map09[BillType.BILL_PAR_MONEY] = 37.5;
  Map map10 = Map();
  map10[BillType.BILL_PAR_OTHER] = 10;

  _listData.add(map01);
  _listData.add(map02);
  _listData.add(map03);
  _listData.add(map04);
  _listData.add(map05);
  _listData.add(map06);
  _listData.add(map07);
  _listData.add(map08);
  _listData.add(map09);
  _listData.add(map10);
}

    核心思路是遍历 ListData 并获取各个子类别数据比例和旋转角度,然后进行不同颜色的扇形图绘制,最终拼接为完整饼状图;注意:各角度的计算不要出错哦!

    这样第一步就基本搞定了;

@override
void paint(Canvas canvas, Size size) {
  double startAngle = 0.0, sweepAngle = 0.0;
  Rect _circle = Rect.fromCircle(
      center: Offset(size.width * 0.5, size.height * 0.5),
      radius: _pieRadius);
  Paint _paint = Paint()
    ..color = Colors.grey
    ..strokeWidth = 4.0
    ..style = PaintingStyle.fill;
  
  _sumData();
  if (_rotateAngle == null) {
    _rotateAngle = 0.0;
  }
  if (_listData != null) {
    for (int i = 0; i < _listData.length; i++) {
      startAngle += sweepAngle;
      sweepAngle = _listData[i].values.first * 2 * math.pi / _sum;
      canvas.drawArc(_circle, startAngle + _rotateAngle, sweepAngle, true,
          _paint..color = _subPaint(_listData[i].keys.first));
    }
  }
  _sum = 0.0;
}

2. 绘制文本信息

    第二步是在绘制好的饼状图中绘制文本信息,为了展示效果更好,和尚规定只有在扇形角度大于等于 30 度时才进行文本信息的绘制;

    其中 Canvas 的初始绘制点默认是以屏幕左上角为坐标原点,此时在扇形面内进行绘制时首先需要通过 translate() 平移坐标系至饼状图圆心;

    绘制文字的角度要与扇形的角平分线平行,此时通过 rotate() 对坐标系进行适当角度的旋转;

    和尚可以通过 Paragraph 获取文字绘制时所占据高度,因此在通过 drawParagraph 绘制文字时适当设置文字起始坐标,y 轴坐标向上平移文字高度的一半;而对于 x 坐标,和尚适当向右平移一部分,这样就不会文字全部贴紧圆心,而发生重叠问题;

    在文字绘制结束之后,将坐标系 rotate() 旋转回正常水平竖直方向,并将起始坐标 translate() 平移恢复至屏幕左上角;

    遍历操作即可完成第二步操作;

ACEPiePainter(this._listData, this._rotateAngle);

@override
void paint(Canvas canvas, Size size) {
  double startAngle = 0.0, sweepAngle = 0.0;
  Rect _circle = Rect.fromCircle(
      center: Offset(size.width * 0.5, size.height * 0.5),
      radius: _pieRadius);
  Paint _paint = Paint()
    ..color = Colors.grey
    ..strokeWidth = 4.0
    ..style = PaintingStyle.fill;
  ParagraphBuilder _pb = ParagraphBuilder(ParagraphStyle(
      textAlign: TextAlign.left,
      fontWeight: FontWeight.w600,
      fontStyle: FontStyle.normal,
      fontSize: 14))
    ..pushStyle(ui.TextStyle(color: Colors.white));
  ParagraphConstraints _paragraph =
      ParagraphConstraints(width: size.width * 0.5);
  _sumData();
  if (_rotateAngle == null) {
    _rotateAngle = 0.0;
  }
  if (_listData != null) {
    for (int i = 0; i < _listData.length; i++) {
      startAngle += sweepAngle;
      sweepAngle = _listData[i].values.first * 2 * math.pi / _sum;
      canvas.drawArc(_circle, startAngle + _rotateAngle, sweepAngle, true,
          _paint..color = _subPaint(_listData[i].keys.first));

      if (sweepAngle >= math.pi / 6) {
        // 1. 平移坐标系
        canvas.translate(size.width * 0.5, size.height * 0.5);
        // 2. 设置旋转角度
        canvas.rotate(startAngle + sweepAngle * 0.5 + _rotateAngle);
        // 3. 文字绘制
        Paragraph paragraph = (_pb..addText(_subName)).build()
          ..layout(_paragraph);
        canvas.drawParagraph(
            paragraph, Offset(50.0, 0.0 - paragraph.height * 0.5));
        // 4. 恢复旋转角度
        canvas.rotate(-startAngle - sweepAngle * 0.5 - _rotateAngle);
        // 5. 恢复起始坐标
        canvas.translate(-size.width * 0.5, -size.height * 0.5);
      }
    }
  }
  _sum = 0.0;
}

3. 添加手势操作

    第三步增加手势操作,也是相对复杂的一步,对于手势操作可以直接用 Gesture 直接处理,但是和尚防止后期添加其他需求,重写 PanGestureRecognizer 来对手势操作进行监听,与 Gesture 基本一致;为了方便理解整个过程和尚又分为三小步;

3.1 手势范围

    第一小步是确定手势范围,和尚限制手势操作在 CustomPaint 整个绘制区域内即可,只需要设置 RawGestureDetector 子元素为 CustomPaint 范围即可;

_piePainterDetector() => RawGestureDetector(
      child: _piePainter(),
      gestures: {
        ACEPieGestureRecognizer:
            GestureRecognizerFactoryWithHandlers(
                () => ACEPieGestureRecognizer(),
                (ACEPieGestureRecognizer gesture) {
          gesture.onDown = (detail) {
            
          };
          gesture.onUpdate = (detail) {
            
          };
          gesture.onEnd = (detail) {
           
          };
        })
      });

3.2 旋转角度

    之后就是对旋转角度的处理了,根据图中可以方便理解,和尚预想的想法是,通过 gesture.onUpdate 更新手势坐标,与初始坐标差来定位旋转角度;

    其中饼状图绘制是采用的笛卡尔坐标系,以绘制区左上角为坐标系原点;而居中的饼状图圆心是在整个组件所在的屏幕尺寸中心;

    因此和尚采用通用 RenderBox 的方式获取自定义组件所占屏幕尺寸并获取饼状图圆心坐标;其中需要注意的是手势监听的 Offset details 获取坐标方式略有不同:detail.localPosition 获取的是当前组件内相对于左上角坐标原点的相对位置,而 detail.globalPosition 获取的是整个设备屏幕左上角坐标的实际位置,和尚刚开始通过 localPosition 方式获取,计算得出的角度受 Widget 所占位置及尺寸影响,差别较大,建议使用 globalPosition 方式;

RenderBox box = _key.currentContext.findRenderObject();
Offset offset = box.localToGlobal(Offset.zero);
Offset _centerOffset = Offset(offset.dx + box.size.width * 0.5, offset.dy + box.size.height * 0.5);

    通过 gesture.onUpdate 更新后的坐标点与更新前的坐标点,再结合饼状图圆心坐标,三点确定一个三角形,通过余弦定律获取手势操作的夹角,从而重新绘制饼状图;如图所示,由 AB 或 又 CD,分别与圆心 O 组成三角形 AOBCOD,其中根据夹角 AOB 和 夹角 COD 进行余弦函数计算即可;

_rotateAngle() {
  var _onDownLen = sqrt(pow(_startOffset.dx - _centerOffset.dx, 2) +
      pow(_startOffset.dy - _centerOffset.dy, 2));
  var _onUpdateLen = sqrt(pow(_updateOffset.dx - _centerOffset.dx, 2) +
      pow(_updateOffset.dy - _centerOffset.dy, 2));
  var _downToUpdateLen = sqrt(pow((_startOffset.dx - _updateOffset.dx), 2) +
      pow((_startOffset.dy - _updateOffset.dy), 2));
  var _cosAngle = (_onDownLen * _onDownLen + _onUpdateLen * _onUpdateLen -
          _downToUpdateLen * _downToUpdateLen) / (2 * _onDownLen * _onUpdateLen);
  rotateAngle += acos(_cosAngle);
  setState(() {});
}

_angle(_aPoint, _bPoint, _oPoint) {
  var _oALen = sqrt(pow(_aPoint.dx - _oPoint.dx, 2) + pow(_aPoint.dy - _oPoint.dy, 2));
  var _oBLen = sqrt(pow(_bPoint.dx - _oPoint.dx, 2) + pow(_bPoint.dy - _oPoint.dy, 2));
  var _aBLen = sqrt(pow(_aPoint.dx - _bPoint.dx, 2) + pow(_aPoint.dy - _bPoint.dy, 2));
  var _cosAngle = (pow(_oALen, 2) + pow(_oBLen, 2) - pow(_aBLen, 2)) / (2 * _oALen * _oBLen);
  return acos(_cosAngle);
}

3.3 旋转方向

    和尚通过上述方式获取三角形角度后发现旋转的方向只能是顺时针旋转,反向的逆时针手势却未生效;其原因是通过余弦定律转换的角度都为正数,需要通过向量方式进行方向正负的判断;因此就有了第三步:设置旋转方向;于是和尚更换了另一种方式,以饼状图圆心为坐标轴原点,水平向右设置一个单位向量,再通过前后手势变更的坐标进行计算两个角度,相差即是夹角;

    如图所示,由 EA 的夹角可以计算为由角 BOE 减去角 BOA;而由 AE 的夹角可以理解为由角 BOA 减去角 BOE;两种方式角度是相反的,这样就避免了余弦函数永远为正数的情况;

    其中在计算的时候用到一些 dart:math 函数库中基本的数学函数公式;计算所得的角度加在饼状图遍历绘制的扇形图角度中即可;其中注意在文字绘制时也要注意旋转坐标系角度;

_piePainterDetector() => RawGestureDetector(
      child: _piePainter(),
      gestures: {
        ACEPieGestureRecognizer:
            GestureRecognizerFactoryWithHandlers(
                () => ACEPieGestureRecognizer(),
                (ACEPieGestureRecognizer gesture) {
          gesture.onDown = (detail) {
            RenderBox box = _key.currentContext.findRenderObject();
            Offset offset = box.localToGlobal(Offset.zero);
            _centerOffset = Offset(offset.dx + box.size.width * 0.5,
                offset.dy + box.size.height * 0.5);
            _startOffset = detail.globalPosition;
          };
          gesture.onUpdate = (detail) {
            _updateOffset = detail.globalPosition;
            setState(() => rotateAngle += _rotateAngle());
            _startOffset = _updateOffset;
          };
          gesture.onEnd = (detail) {
            print('--onEnd--$detail--${detail.primaryVelocity}---${detail.velocity}---${detail.velocity.pixelsPerSecond}');
          };
        })
      });
      
_rotateAngle() {
  if (_startOffset.dy < _centerOffset.dy) {
    gestureDirection = -1;
  } else {
    gestureDirection = 1;
  }
  var _updateAngle = gestureDirection *
      _angle(_updateOffset, Offset(_centerOffset.dx + 100, _centerOffset.dy), _centerOffset);
  if (_updateOffset.dy < _centerOffset.dy) {
    gestureDirection = -1;
  } else {
    gestureDirection = 1;
  }
  var _startAngle = gestureDirection * _angle(_startOffset, Offset(_centerOffset.dx + 100, _centerOffset.dy), _centerOffset);
  return (_updateAngle - _startAngle);
}

_sumData() {
  if (_listData != null) {
    for (int i = 0; i < _listData.length; i++) {
      _sum += _listData[i].values.first;
    }
  }
}

    至此一个会转的饼状图就搞定了,完整的代码和尚已经上传到 GitHub 上,有需要的可以了解一下,和尚建议大家先自己尝试一下,毕竟实践出真知嘛;

    因为有些小细节还是需要大家自己体会一下;例如:饼状图旋转角度的计算,余弦函数的正负情况,文本绘制的起始坐标等一系列小的问题;

    

    今天的分享就到此为止了,谢谢大家!

来源: 阿策小和尚