[-Flutter 自定義元件-] 蛛網圖+繪製+動畫實踐

張風捷特烈發表於2019-07-31

在Android的時候自定義過蛛網圖,花了半天時間。復刻到Flutter只用了不到20分鐘
不得不說Flutter中的Canvas對安卓玩家還是非常友好的,越來越覺得Flutter非常有趣。
在檢視方面,Flutter確實要比原生更勝一籌。本文你將學到:

1.三角函式的使用
2.Flutter中如何用繪製文字
3.動畫在繪圖中的實際運用
4.Canvas繪圖的相關相關方法
5.Flutter中一個元件的封裝
複製程式碼

[-Flutter 自定義元件-] 蛛網圖+繪製+動畫實踐

[-Flutter 自定義元件-] 蛛網圖+繪製+動畫實踐

---->[使用方法]-------------
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,
};
複製程式碼

[-Flutter 自定義元件-] 蛛網圖+繪製+動畫實踐


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進行繪製

[-Flutter 自定義元件-] 蛛網圖+繪製+動畫實踐

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.繪製範圍

最後也是最難的一塊,你準備好草稿紙了嗎?

[-Flutter 自定義元件-] 蛛網圖+繪製+動畫實踐

//繪製區域
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.元件封裝

到現在邏輯上沒有問題了,剩下的就是對元件的封裝,將一些量進行提取
下面就是簡單封裝了一下,還有很多亂七八糟的沒封裝,比如顏色,動畫效果等。

[-Flutter 自定義元件-] 蛛網圖+繪製+動畫實踐

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,期待與你的交流與切磋。

相關文章