Flutter 实战 - 用贝塞尔曲线画一个带文本的波浪球 Widget

flutter 中的自定义 Widget 算作是 flutter 体系中比较高阶的知识点之一了,相当于原生开发中的自定义 View,以我个人的感受来说,自定义 widget 的难度要低于自定义 View,不过由于当前 flutter 的开源库还不算多丰富,所以有些效果还是需要开发者自己动手来实现,而本篇文章就来介绍如何用 flutter 来实现一个带文本的波浪球 Widget,实现的的效果如下所示:

源代码点击这里下载:https://github.com/leavesC/flutter_do

先来总结下该 WaveLoadingWidget 的特点,这样才能归纳出实现该效果所需的步骤

  1. widget 的主体是一个不规则的半圆,顶部以类似于波浪的形式从左往右上下波动运行
  2. 球形波浪可以自定义颜色,此处以 waveColor 命名
  3. 波浪的起伏线将嵌入的文本分为上下两种颜色,上边的文本颜色以 backgroundColor 命名,下边的文本颜色以 foregroundColor 命名,文本的颜色一直在动态变化中

虽然波浪是不断运动的,但只要能够绘制出其中一帧的图形,其动态效果就能通过不断改变波浪的位置参数来完成,所以这里先把该 widget 当成静态的,先实现其静态效果即可

将绘制步骤拆解为以下几步:

  1. 绘制颜色为 backgroundColor 的文本,将其绘制在 canvas 的最底层
  2. 根据 widget 的宽高信息构建一个不超出范围的最大圆形路径 circlePath
  3. 以 circlePath 的水平中间线作为波浪的起伏线,在起伏线的上边和下边分别利用贝塞尔曲线绘制一段连续的波浪 path,将 path 的首尾两端以矩形的形式连接在一起,构成 wavePath,wavePath 的底部会与 circlePath 的底部相交于一点
  4. 取 circlePath 和 wavePath 的交集 targetPath,用 waveColor 填充, 此时就得到了半圆形的球形波浪了
  5. 利用 canvas.clipPath(targetPath) 方法裁切画布,再绘制颜色为 foregroundColor 的文本,此时绘制的 foregroundColor 文本只会显示 targetPath 范围内的部分,从而使两次不同时间绘制的文本重叠在了一起,得到了有不同颜色范围的文本
  6. 利用 flutter 动画不断改变 wavePath 的起始点的 X 坐标,同时重新绘制 UI,从而得到波浪不断从左往右前进的效果

现在就来一步步实现以上的绘制步骤吧

一、初始化画笔

flutter 通过抽象类 CustomPainter 为开发者提供了自绘 UI 的入口,其内部的抽象方法 void paint(Canvas canvas, Size size) 提供了画布对象 canvas 以及包含 widget 宽高信息的 size 对象

此处就来继承 CustomPainter 类,初始化画笔对象以及各个配置参数(要绘制的文本,颜色值等)

class WaveLoadingPainter extends CustomPainter {
  //如果外部没有指定颜色值,则使用此默认颜色值
  static final Color defaultColor = Colors.lightBlue;

  //画笔对象
  var _paint = Paint();

  //圆形路径
  Path _circlePath = Path();

  //波浪路径
  Path _wavePath = Path();

  //要显示的文本
  final String text;

  //字体大小
  final double fontSize;

  final Color backgroundColor;

  final Color foregroundColor;

  final Color waveColor;

  WaveLoadingPainter(
      {this.text,
      this.fontSize,
      this.backgroundColor,
      this.foregroundColor,
      this.waveColor}) {
    _paint
      ..isAntiAlias = true
      ..style = PaintingStyle.fill
      ..strokeWidth = 3
      ..color = waveColor ?? defaultColor;
  }

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

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

二、绘制 backgroundColor 文本

flutter 的 canvas 对象没有提供直接 drawText 的 API,其绘制文本的步骤相对原生的自定义 View 要比较麻烦

@override
  void paint(Canvas canvas, Size size) {
    double side = min(size.width, size.height);
    double radius = side / 2.0;

    _drawText(canvas: canvas, side: side, colors: backgroundColor);
      
    ···
  }

  void _drawText({Canvas canvas, double side, Color colors}) {
    ParagraphBuilder pb = ParagraphBuilder(ParagraphStyle(
      textAlign: TextAlign.center,
      fontStyle: FontStyle.normal,
      fontSize: fontSize ?? 0,
    ));
    pb.pushStyle(ui.TextStyle(color: colors ?? defaultColor));
    pb.addText(text);
    ParagraphConstraints pc = ParagraphConstraints(width: fontSize ?? 0);
    Paragraph paragraph = pb.build()..layout(pc);
    canvas.drawParagraph(
        paragraph,
        Offset(
            (side - paragraph.width) / 2.0, (side - paragraph.height) / 2.0));
  }

三、构建圆形路径 circlePath

取 widget 的宽和高的最小值作为圆的直径大小,以此构建出一个不超出 widget 范围的最大圆形路径

 @override
  void paint(Canvas canvas, Size size) {
    double side = min(size.width, size.height);
    double radius = side / 2.0;

    _drawText(canvas: canvas, side: side, colors: backgroundColor);
      
    _circlePath.reset();
    _circlePath.addArc(Rect.fromLTWH(0, 0, side, side), 0, 2 * pi);

    ···
  }

四、利用贝塞尔曲线绘制波浪线

此处波浪的宽度和高度就根据一个固定的比例值来求值,以 _circlePath 的中间分隔线作为水平线,在水平线上下根据贝塞尔曲线绘制出连续的波浪线

  @override
  void paint(Canvas canvas, Size size) {
    double side = min(size.width, size.height);
    double radius = side / 2.0;

    _drawText(canvas: canvas, side: side, colors: backgroundColor);

    _circlePath.reset();
    _circlePath.addArc(Rect.fromLTWH(0, 0, side, side), 0, 2 * pi);

    double waveWidth = side * 0.8;
    double waveHeight = side / 6;
    _wavePath.reset();
    _wavePath.moveTo(-waveWidth, radius);
    for (double i = -waveWidth; i < side; i += waveWidth) {
      _wavePath.relativeQuadraticBezierTo(
          waveWidth / 4, -waveHeight, waveWidth / 2, 0);
      _wavePath.relativeQuadraticBezierTo(
          waveWidth / 4, waveHeight, waveWidth / 2, 0);
    }

    //为了方便读者理解,这里把路径绘制出来,实际上不需要
    canvas.drawPath(_wavePath, _paint);

  }

此时绘制的曲线还处于非闭合状态,需要将 _wavePath 的首尾两端连接起来,这样才可以和 _circlePath 做交集

    _wavePath.relativeLineTo(0, radius);
    _wavePath.lineTo(-waveWidth, side);
    _wavePath.close();

_wavePath 闭合后,此时绘制出来的图形就如下所示

五、取 _circlePath 和 _wavePath 的交集

_circlePath 和 _wavePath 的交集就是一个半圆形波浪了

    var combine = Path.combine(PathOperation.intersect, _circlePath, _wavePath);
    canvas.drawPath(combine, _paint);

    //为了方便读者理解,这里把路径绘制出来,实际上不需要
    canvas.drawPath(combine, _paint);

六、裁切画布并绘制顶层文本

文本的颜色是分为上下两部分的,foregroundColor 颜色的文本不需要显示上半部分,所以在绘制 foregroundColor 文本的时候需要把上半部分文本给裁切掉,使两次不同时间绘制的文本重叠在了一起,得到了有不同颜色范围的文本

    canvas.clipPath(combine);
    _drawText(canvas: canvas, side: side, colors: foregroundColor);

八、添加动画

现在已经绘制好了单独一帧时的效果图了,可以考虑使 widget 动起来了

只要不断改变贝塞尔曲线的起始点坐标,使之不断从左往右移动,就可以营造出波浪从左往右前进的效果了。WaveLoadingPainter 只负责根据外部传入的动画值 animatedValue 来绘制 UI,构造 animatedValue 的逻辑则由外部的 _WaveLoadingWidgetState 进行处理,这里规定 animatedValue 的值是从 0 递增到 1,在开始构建 _wavePath 前只需要移动其起始坐标点即可

 @override
  void paint(Canvas canvas, Size size) {
    double side = min(size.width, size.height);
    double radius = side / 2.0;

    _drawText(canvas: canvas, side: side, colors: backgroundColor);

    _circlePath.reset();
    _circlePath.addArc(Rect.fromLTWH(0, 0, side, side), 0, 2 * pi);

    double waveWidth = side * 0.8;
    double waveHeight = side / 6;
    _wavePath.reset();
    _wavePath.moveTo((animatedValue - 1) * waveWidth, radius);
    for (double i = -waveWidth; i < side; i += waveWidth) {
      _wavePath.relativeQuadraticBezierTo(
          waveWidth / 4, -waveHeight, waveWidth / 2, 0);
      _wavePath.relativeQuadraticBezierTo(
          waveWidth / 4, waveHeight, waveWidth / 2, 0);
    }
    _wavePath.relativeLineTo(0, radius);
    _wavePath.lineTo(-waveWidth, side);
    _wavePath.close();

    var combine = Path.combine(PathOperation.intersect, _circlePath, _wavePath);
    canvas.drawPath(combine, _paint);

    canvas.clipPath(combine);
    _drawText(canvas: canvas, side: side, colors: foregroundColor);
  }
class _WaveLoadingWidgetState extends State<WaveLoadingWidget>
    with SingleTickerProviderStateMixin {
  final String text;

  final double fontSize;

  final Color backgroundColor;

  final Color foregroundColor;

  final Color waveColor;

  AnimationController controller;

  Animation<double> animation;

  _WaveLoadingWidgetState(
      {@required this.text,
      @required this.fontSize,
      @required this.backgroundColor,
      @required this.foregroundColor,
      @required this.waveColor});

  @override
  void initState() {
    super.initState();
    controller =
        AnimationController(duration: const Duration(seconds: 1), vsync: this);
    controller.addStatusListener((status) {
      switch (status) {
        case AnimationStatus.dismissed:
          print("dismissed");
          break;
        case AnimationStatus.forward:
          print("forward");
          break;
        case AnimationStatus.reverse:
          print("reverse");
          break;
        case AnimationStatus.completed:
          print("completed");
          break;
      }
    });

    animation = Tween(
      begin: 0.0,
      end: 1.0,
    ).animate(controller)
      ..addListener(() {
        setState(() => {});
      });
    controller.repeat();
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      painter: WaveLoadingPainter(
        text: text,
        fontSize: fontSize,
        animatedValue: animation.value,
        backgroundColor: backgroundColor,
        foregroundColor: foregroundColor,
        waveColor: waveColor,
      ),
    );
  }
}

九、包裹为 StatefulWidget 并使用

之后只要将 WaveLoadingPainter 包裹到 StatefulWidget 中即可,在 StatefulWidget 中开放可以自定义配置的参数就可以了

class WaveLoadingWidget extends StatefulWidget {
  final String text;

  final double fontSize;

  final Color backgroundColor;

  final Color foregroundColor;

  final Color waveColor;

  WaveLoadingWidget(
      {@required this.text,
      @required this.fontSize,
      @required this.backgroundColor,
      @required this.foregroundColor,
      @required this.waveColor}) {
    assert(text != null && text.length == 1);
    assert(fontSize != null && fontSize > 0);
  }

  @override
  _WaveLoadingWidgetState createState() => _WaveLoadingWidgetState(
        text: text,
        fontSize: fontSize,
        backgroundColor: backgroundColor,
        foregroundColor: foregroundColor,
        waveColor: waveColor,
      );
}

使用方式就类似于一般的系统 widget

        Container(
            width: 300,
            height: 300,
            child: WaveLoadingWidget(
              text: "锲",
              fontSize: 215,
              backgroundColor: Colors.lightBlue,
              foregroundColor: Colors.white,
              waveColor: Colors.lightBlue,
            ),
          ),
          Container(
            width: 250,
            height: 250,
            child: WaveLoadingWidget(
              text: "而",
              fontSize: 175,
              backgroundColor: Colors.indigoAccent,
              foregroundColor: Colors.white,
              waveColor: Colors.indigoAccent,
            ),
          ),

源代码点击这里下载:https://github.com/leavesC/flutter_do

此外该项目也提供了 N 多个常用 Widget 和自定义 Widget 的使用及实现方法,涵盖了系统 Widget 、布局容器、动画、高阶功能、自定义 Widget 等内容,欢迎 star

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念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

推荐阅读更多精彩内容