[-Flutter 自定义组件-] 蛛网图+绘制+动画实践

在Android的时候自定义过蛛网图,花了半天时间。复刻到Flutter只用了不到20分钟
不得不说Flutter中的Canvas对安卓玩家还是非常友好的,越来越觉得Flutter非常有趣。
在视图方面,Flutter确实要比原生更胜一筹。本文你将学到:

1.三角函数的使用
2.Flutter中如何用绘制文字
3.动画在绘图中的实际运用
4.Canvas绘图的相关相关方法
5.Flutter中一个组件的封装
image
image
---->[使用方法]-------------
var show = AbilityWidget(
    ability: Ability(duration: 1500, 
    image: AssetImage("images/lifei.jpeg"),
    radius: 100,
        color: Colors.black,
        data: {
        "语文": 40.0,
        "数学": 30.0,
        "英语": 20.0,
        "政治": 40.0,
        "音乐": 80.0,
        "生物": 50.0,
        "化学": 60.0,
        "地理": 80.0,

    }));

1.静态蛛网图

第一步就是如何将一串数据映射成下面的图表:

var data = {
  "攻击力": 70.0,
  "生命": 90.0,
  "闪避": 50.0,
  "暴击": 70.0,
  "破格": 80.0,
  "格挡": 100.0,
};
image

1.1:创建AbilityWidget组件

线新建一个StatelessWidget的组件使用AbilityPainter进行绘制
这里先定义画笔、路径等成员变量

import 'package:flutter/material.dart';

class AbilityWidget extends StatefulWidget {
  @override
  _AbilityWidgetState createState() => _AbilityWidgetState();
}

class _AbilityWidgetState extends State<AbilityWidget>{

  @override
  Widget build(BuildContext context) {
    var paint = CustomPaint(
      painter: AbilityPainter(),
    );

    return SizedBox(width: 200, height: 200, child: paint,);
  }
}

class AbilityPainter extends CustomPainter {
  var data = {
    "攻击力": 70.0,
    "生命": 90.0,
    "闪避": 50.0,
    "暴击": 70.0,
    "破格": 80.0,
    "格挡": 100.0,
  };

  double mRadius = 100; //外圆半径
  Paint mLinePaint; //线画笔
  Paint mAbilityPaint; //区域画笔
  Paint mFillPaint;//填充画笔

  Path mLinePath;//短直线路径
  Path mAbilityPath;//范围路径

  AbilityPainter() {
    mLinePath = Path();
    mAbilityPath = Path();
    mLinePaint = Paint()
      ..color = Colors.black
      ..style = PaintingStyle.stroke
      ..strokeWidth=0.008 * mRadius
      ..isAntiAlias = true;

    mFillPaint = Paint() //填充画笔
      ..strokeWidth = 0.05 * mRadius
      ..color = Colors.black
      ..isAntiAlias = true;
    mAbilityPaint = Paint()
      ..color = Color(0x8897C5FE)
      ..isAntiAlias = true;
  }
  
  @override
  void paint(Canvas canvas, Size size) {
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return true;
  }
}

1.2.绘制外圈

为了减少变量值,让尺寸具有很好的联动性(等比扩缩),小黑条的长宽将取决于最大半径mRadius
则:小黑条长:mRadius*0.08 小黑条宽:mRadius*0.05 所以r2=mRadius-mRadius*0.08

外圈绘制.png
@override
void paint(Canvas canvas, Size size) {
    canvas.translate(mRadius, mRadius); //移动坐标系
    drawOutCircle(canvas);
}

//绘制外圈
void drawOutCircle(Canvas canvas) {
  canvas.save();//新建图层
  canvas.drawCircle(Offset(0, 0), mRadius, mLinePaint);//圆形的绘制
  double r2 = mRadius - 0.08 * mRadius; //下圆半径
  canvas.drawCircle(Offset(0, 0), r2, mLinePaint);
  for (var i = 0.0; i < 22; i++) {//循环画出小黑条
    canvas.save();//新建图层
    canvas.rotate(360 / 22 * i / 180 * pi);//旋转:注意传入的是弧度(与Android不同)
    canvas.drawLine(Offset(0, -mRadius), Offset(0, -r2), mFillPaint);//线的绘制
    canvas.restore();//释放图层
  }
  canvas.restore();//释放图层
}

1.3.绘制内圈

同样尺寸和最外圆看齐,这里绘制有一丢丢复杂,你需要了解canvas和path的使用
看不懂的可转到canvaspath,如果看了这两篇还问绘制有什么技巧的,可转到这里

内圈绘制.png
@override
void paint(Canvas canvas, Size size) {
    canvas.translate(mRadius, mRadius); //移动坐标系
    drawOutCircle(canvas);
    drawInnerCircle(canvas);
}

//绘制内圈圆
drawInnerCircle(Canvas canvas) {
  double innerRadius = 0.618 * mRadius;//内圆半径
  canvas.drawCircle(Offset(0, 0), innerRadius, mLinePaint);
  canvas.save();
  for (var i = 0; i < 6; i++) {//遍历6条线
    canvas.save();
    canvas.rotate(60 * i.toDouble() / 180 * pi); //每次旋转60°
    mPath.moveTo(0, -innerRadius);
    mPath.relativeLineTo(0, innerRadius); //线的路径
    for (int j = 1; j < 6; j++) {
      mPath.moveTo(-mRadius * 0.02, innerRadius / 6 * j);
      mPath.relativeLineTo(mRadius * 0.02 * 2, 0);
    } //加5条小线
    canvas.drawPath(mPath, mLinePaint); //绘制线
    canvas.restore();
  }
  canvas.restore();
}

1.3.绘制文字

Flutter中绘制文字可有点略坑,我这里简单的封了一个drawText函数用来画文字
记得导入ui库,使用Paragraph进行文字的设置,drawParagraph进行绘制

image
import 'dart:ui' as ui;

//绘制文字
void drawInfoText(Canvas canvas) {
  double r2 = mRadius - 0.08 * mRadius; //下圆半径
  for (int i = 0; i < data.length; i++) {
    canvas.save();
    canvas.rotate(360 / data.length * i / 180 * pi + pi);
    drawText(canvas, data.keys.toList()[i], Offset(-50, r2 - 0.22 * mRadius),
        fontSize: mRadius * 0.1);
    canvas.restore();
  }
}

//绘制文字
drawText(Canvas canvas, String text, Offset offset,
    {Color color=Colors.black,
    double maxWith = 100,
    double fontSize,
    String fontFamily,
    TextAlign textAlign=TextAlign.center,
    FontWeight fontWeight=FontWeight.bold}) {
  //  绘制文字
  var paragraphBuilder = ui.ParagraphBuilder(
    ui.ParagraphStyle(
      fontFamily: fontFamily,
      textAlign: textAlign,
      fontSize: fontSize,
      fontWeight: fontWeight,
    ),
  );
  paragraphBuilder.pushStyle(
      ui.TextStyle(color: color, textBaseline: ui.TextBaseline.alphabetic));
  paragraphBuilder.addText(text);
  var paragraph = paragraphBuilder.build();
  paragraph.layout(ui.ParagraphConstraints(width: maxWith));
  canvas.drawParagraph(paragraph, Offset(offset.dx, offset.dy));
}

1.4.绘制范围

最后也是最难的一块,你准备好草稿纸了吗?

image
//绘制区域
drawAbility(Canvas canvas, List<double> value) {
  double step = mRadius*0.618 / 6; //每小段的长度
  mAbilityPath.moveTo(0, -value[0] / 20 * step); //起点
  for (int i = 1; i < 6; i++) {
    double mark = value[i] / 20;//占几段
    mAbilityPath.lineTo(
        mark * step * cos(pi / 180 * (-30 + 60 * (i - 1))),
        mark * step * sin(pi / 180 * (-30 + 60 * (i - 1))));
  }
  mAbilityPath.close();
  canvas.drawPath(mAbilityPath, mAbilityPaint);
}

2.动画效果

让外圈转和内圈相反方向转,所以可以让内圈和外圈分成两个组件放在一个Stack里

2.1:抽离外圈
class OutlinePainter extends CustomPainter {
  double mRadius = 100; //外圆半径
  Paint mLinePaint; //线画笔
  Paint mFillPaint; //填充画笔

  OutlinePainter() {
    mLinePaint = Paint()
      ..color = Colors.black
      ..style = PaintingStyle.stroke
      ..strokeWidth = 0.008 * mRadius
      ..isAntiAlias = true;

    mFillPaint = Paint() //填充画笔
      ..strokeWidth = 0.05 * mRadius
      ..color = Colors.black
      ..isAntiAlias = true;
  }

  @override
  void paint(Canvas canvas, Size size) {
    drawOutCircle(canvas);

  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    // TODO: implement shouldRepaint
    return true;
  }

  //绘制外圈
  void drawOutCircle(Canvas canvas) {
    canvas.save(); //新建图层
    canvas.drawCircle(Offset(0, 0), mRadius, mLinePaint); //圆形的绘制
    double r2 = mRadius - 0.08 * mRadius; //下圆半径
    canvas.drawCircle(Offset(0, 0), r2, mLinePaint);
    for (var i = 0.0; i < 22; i++) {
      //循环画出小黑条
      canvas.save(); //新建图层
      canvas.rotate(360 / 22 * i / 180 * pi); //旋转:注意传入的是弧度(与Android不同)
      canvas.drawLine(Offset(0, -mRadius), Offset(0, -r2), mFillPaint); //线的绘制
      canvas.restore(); //释放图层
    }
    canvas.restore(); //释放图层
  }
}

2.2:使用动画

这里用Stack进行组件的堆叠

class _AbilityWidgetState extends State<AbilityWidget>
    with SingleTickerProviderStateMixin {
  var _angle = 0.0;
  AnimationController controller;
  Animation<double> animation;
  @override
  void initState() {
    super.initState();
    controller = AnimationController(
        ////创建 Animation对象
        duration: const Duration(milliseconds: 2000), //时长
        vsync: this);
    var tween = Tween(begin: 0.0, end: 360.0); //创建从25到150变化的Animatable对象
    animation = tween.animate(controller); //执行animate方法,生成
    animation.addListener(() {
      setState(() {
        _angle = animation.value;
      });
    });
    controller.forward();
  }
  @override
  Widget build(BuildContext context) {
    var paint = CustomPaint(
      painter: AbilityPainter(),
    );
    var outlinePainter = Transform.rotate(
      angle: _angle / 180 * pi,
      child: CustomPaint(
        painter: OutlinePainter(),
      ),
    );
    var img = Transform.rotate(
      angle: _angle / 180 * pi,
      child: Opacity(
        opacity: animation.value / 360 * 0.4,
        child: ClipOval(
          child: Image.asset(
            "images/娜美.jpg",
            width: 200,
            height: 200,
            fit: BoxFit.cover,
          ),
        ),
      ),
    );
    var center = Transform.rotate(
        angle: -_angle / 180 * pi,
        child: Transform.scale(
          scale: 0.5 + animation.value / 360 / 2,
          child: SizedBox(
            width: 200,
            height: 200,
            child: paint,
          ),
        ));
    return Center(
      child: Stack(
        alignment: Alignment.center,
        children: <Widget>[img, center, outlinePainter],
      ),
    );
  }
}

3.组件封装

到现在逻辑上没有问题了,剩下的就是对组件的封装,将一些量进行提取
下面就是简单封装了一下,还有很多乱七八糟的没封装,比如颜色,动画效果等。

image
import 'dart:math';
import 'dart:ui' as ui;

import 'package:flutter/material.dart';

class Ability {
  double radius;
  int duration;
  ImageProvider image;
  Map<String,double> data;
  Color color;

  Ability({this.radius, this.duration, this.image, this.data, this.color});

}

class AbilityWidget extends StatefulWidget {
  AbilityWidget({Key key, this.ability}) : super(key: key);

  final Ability ability;

  @override
  _AbilityWidgetState createState() => _AbilityWidgetState();
}

class _AbilityWidgetState extends State<AbilityWidget>
    with SingleTickerProviderStateMixin {
  var _angle = 0.0;
  AnimationController controller;
  Animation<double> animation;

  @override
  void initState() {
    super.initState();


    controller = AnimationController(
        ////创建 Animation对象
        duration: Duration(milliseconds: widget.ability.duration), //时长
        vsync: this);
    
    var curveTween = CurveTween(curve:Cubic(0.96, 0.13, 0.1, 1.2));//创建curveTween
    var tween=Tween(begin: 0.0, end: 360.0);
    animation = tween.animate(curveTween.animate(controller));


    animation.addListener(() {
      setState(() {
        _angle = animation.value;
        print(_angle);
      });
    });
    controller.forward();
  }

  @override
  Widget build(BuildContext context) {
    var paint = CustomPaint(
      painter: AbilityPainter(widget.ability.radius,widget.ability.data),
    );

    var outlinePainter = Transform.rotate(
      angle: _angle / 180 * pi,
      child: CustomPaint(
        painter: OutlinePainter(widget.ability.radius ),
      ),
    );

    var img = Transform.rotate(
      angle: _angle / 180 * pi,
      child: Opacity(
        opacity: animation.value / 360 * 0.4,
        child: ClipRRect(
          borderRadius: BorderRadius.circular(widget.ability.radius),
          child: Image(
            image: widget.ability.image,
            width: widget.ability.radius * 2,
            height: widget.ability.radius * 2,
            fit: BoxFit.cover,
          ),
        ),
      ),
    );

    var center = Transform.rotate(
        angle: -_angle / 180 * pi,
        child: Transform.scale(
          scale: 0.5 + animation.value / 360 / 2,
          child: SizedBox(
            width: widget.ability.radius * 2,
            height: widget.ability.radius * 2,
            child: paint,
          ),
        ));

    return Center(
      child: Stack(
        alignment: Alignment.center,
        children: <Widget>[img, center, outlinePainter],
      ),
    );
  }
}

class OutlinePainter extends CustomPainter {
  double _radius; //外圆半径
  Paint mLinePaint; //线画笔
  Paint mFillPaint; //填充画笔

  OutlinePainter(this._radius) {
    mLinePaint = Paint()
      ..color = Colors.black
      ..style = PaintingStyle.stroke
      ..strokeWidth = 0.008 * _radius
      ..isAntiAlias = true;

    mFillPaint = Paint() //填充画笔
      ..strokeWidth = 0.05 * _radius
      ..color = Colors.black
      ..isAntiAlias = true;
  }

  @override
  void paint(Canvas canvas, Size size) {
    drawOutCircle(canvas);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    // TODO: implement shouldRepaint
    return true;
  }

  //绘制外圈
  void drawOutCircle(Canvas canvas) {
    canvas.save(); //新建图层
    canvas.drawCircle(Offset(0, 0), _radius, mLinePaint); //圆形的绘制
    double r2 = _radius - 0.08 * _radius; //下圆半径
    canvas.drawCircle(Offset(0, 0), r2, mLinePaint);
    for (var i = 0.0; i < 22; i++) {
      //循环画出小黑条
      canvas.save(); //新建图层
      canvas.rotate(360 / 22 * i / 180 * pi); //旋转:注意传入的是弧度(与Android不同)
      canvas.drawLine(Offset(0, -_radius), Offset(0, -r2), mFillPaint); //线的绘制
      canvas.restore(); //释放图层
    }
    canvas.restore(); //释放图层
  }
}

class AbilityPainter extends CustomPainter {

  Map<String, double>  _data;
  double _r; //外圆半径
  Paint mLinePaint; //线画笔
  Paint mAbilityPaint; //区域画笔
  Paint mFillPaint; //填充画笔

  Path mLinePath; //短直线路径
  Path mAbilityPath; //范围路径

  AbilityPainter(this._r, this._data) {
    mLinePath = Path();
    mAbilityPath = Path();
    mLinePaint = Paint()
      ..color = Colors.black
      ..style = PaintingStyle.stroke
      ..strokeWidth = 0.008 * _r
      ..isAntiAlias = true;

    mFillPaint = Paint() //填充画笔
      ..strokeWidth = 0.05 * _r
      ..color = Colors.black
      ..isAntiAlias = true;
    mAbilityPaint = Paint()
      ..color = Color(0x8897C5FE)
      ..isAntiAlias = true;
  }

  @override
  void paint(Canvas canvas, Size size) {
    //剪切画布
    Rect rect = Offset.zero & size;
    canvas.clipRect(rect);

    canvas.translate(_r, _r); //移动坐标系
    drawInnerCircle(canvas);
    drawInfoText(canvas);
    drawAbility(canvas, _data.values.toList());
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return true;
  }

  //绘制内圈圆
  drawInnerCircle(Canvas canvas) {
    double innerRadius = 0.618 * _r; //内圆半径
    canvas.drawCircle(Offset(0, 0), innerRadius, mLinePaint);
    canvas.save();
    for (var i = 0; i < _data.length; i++) {
      //遍历6条线
      canvas.save();
      canvas.rotate(360/_data.length * i.toDouble() / 180 * pi); //每次旋转60°
      mLinePath.moveTo(0, -innerRadius);
      mLinePath.relativeLineTo(0, innerRadius); //线的路径
      for (int j = 1; j < _data.length; j++) {
        mLinePath.moveTo(-_r * 0.02, innerRadius / _data.length * j);
        mLinePath.relativeLineTo(_r * 0.02 * 2, 0);
      } //加5条小线
      canvas.drawPath(mLinePath, mLinePaint); //绘制线
      canvas.restore();
    }
    canvas.restore();
  }

  //绘制文字
  void drawInfoText(Canvas canvas) {
    double r2 = _r - 0.08 * _r; //下圆半径
    for (int i = 0; i < _data.length; i++) {
      canvas.save();
      canvas.rotate(360 / _data.length * i / 180 * pi + pi);
      drawText(canvas, _data.keys.toList()[i], Offset(-50, r2 - 0.22 * _r),
          fontSize: _r * 0.1);
      canvas.restore();
    }
  }

  //绘制区域
  drawAbility(Canvas canvas, List<double> value) {
    double step = _r * 0.618 / _data.length; //每小段的长度
    mAbilityPath.moveTo(0, -value[0] / (100/_data.length) * step); //起点
    for (int i = 1; i < _data.length; i++) {
      double mark = value[i] /  (100/_data.length);

      var deg=pi/180*(360/_data.length * i - 90);

      mAbilityPath.lineTo(mark * step * cos(deg), mark * step * sin(deg));
    }
    mAbilityPath.close();
    canvas.drawPath(mAbilityPath, mAbilityPaint);
  }

  //绘制文字
  drawText(Canvas canvas, String text, Offset offset,
      {Color color = Colors.black,
      double maxWith = 100,
      double fontSize,
      String fontFamily,
      TextAlign textAlign = TextAlign.center,
      FontWeight fontWeight = FontWeight.bold}) {
    //  绘制文字
    var paragraphBuilder = ui.ParagraphBuilder(
      ui.ParagraphStyle(
        fontFamily: fontFamily,
        textAlign: textAlign,
        fontSize: fontSize,
        fontWeight: fontWeight,
      ),
    );
    paragraphBuilder.pushStyle(
        ui.TextStyle(color: color, textBaseline: ui.TextBaseline.alphabetic));
    paragraphBuilder.addText(text);
    var paragraph = paragraphBuilder.build();
    paragraph.layout(ui.ParagraphConstraints(width: maxWith));
    canvas.drawParagraph(paragraph, Offset(offset.dx, offset.dy));
  }
}

结语

本文到此接近尾声了,如果想快速尝鲜Flutter,《Flutter七日》会是你的必备佳品;如果想细细探究它,那就跟随我的脚步,完成一次Flutter之旅。
另外本人有一个Flutter微信交流群,欢迎小伙伴加入,共同探讨Flutter的问题,本人微信号:zdl1994328,期待与你的交流与切磋。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,732评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,496评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,264评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,807评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,806评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,675评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,029评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,683评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,704评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,666评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,773评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,413评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,016评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,204评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,083评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,503评论 2 343