使用Flutter与贝塞尔曲线画一个波浪球

本文详细介绍了如何在Flutter中通过自定义Painter类实现一个带文本的波浪球动画效果。首先绘制背景文本,然后构建最大圆形路径并绘制波浪线,接着取圆和波浪线的交集形成波浪球,再限制绘制区域以实现不同颜色的文本重叠效果,并通过AnimationController实现波浪动态移动。文章提供了完整的代码实现和使用方法。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

当 flutter 的现有组件无法满足产品要求的 UI 效果时,我们就需要通过自绘组件的方式来进行实现了。本篇文章就来介绍如何用 flutter 自定义实现一个带文本的波浪球,效果如下所示:

在这里插入图片描述

先来总结下 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 的交集 combinePath,用 waveColor 填充, 此时就得到了半圆形的球形波浪了
  5. 利用 canvas.clipPath(combinePath) 方法裁切画布,再绘制颜色为 foregroundColor 的文本,此时绘制的 foregroundColor 文本只会显示 combinePath 范围内的部分,也即只会显示下半部分,使得两次不同时间绘制的文本重叠在了一起,从而得到了有不同颜色范围的文本
  6. 利用 AnimationController 不断改变 wavePath 的起始点的 X 坐标,同时重新刷新 UI,从而得到波浪不断从左往右起伏运行的动态效果

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

一、绘制 backgroundColor 文本

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

这里就来继承 CustomPainter 类,在 paint 方法中先来绘制颜色为 backgroundColor 的文本。flutter 的 canvas 对象没有提供直接 drawText 的 API,所以其绘制文本的步骤相对原生的自定义 View 要稍微麻烦一点

class _WaveLoadingPainter extends CustomPainter {
  final String text;

  final double fontSize;

  final double animatedValue;

  final Color backgroundColor;

  final Color foregroundColor;

  final Color waveColor;

  _WaveLoadingPainter({
    required this.text,
    required this.fontSize,
    required this.animatedValue,
    required this.backgroundColor,
    required this.foregroundColor,
    required this.waveColor,
  });

  
  void paint(Canvas canvas, Size size) {
    final side = min(size.width, size.height);
    _drawText(canvas: canvas, side: side, color: backgroundColor);
  }

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

  
  bool shouldRepaint(CustomPainter oldDelegate) {
    return animatedValue != (oldDelegate as _WaveLoadingPainter).animatedValue;
  }
}

在这里插入图片描述

二、构建 circlePath

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

  
  void paint(Canvas canvas, Size size) {
    final side = min(size.width, size.height);
    _drawText(canvas: canvas, side: side, color: backgroundColor);

    final circlePath = Path();
    circlePath.addArc(Rect.fromLTWH(0, 0, side, side), 0, 2 * pi);
  }

三、绘制波浪线

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

  
  void paint(Canvas canvas, Size size) {
    final side = min(size.width, size.height);
    _drawText(canvas: canvas, side: side, color: backgroundColor);

    final circlePath = Path();
    circlePath.addArc(Rect.fromLTWH(0, 0, side, side), 0, 2 * pi);

    final waveWidth = side * 0.8;
    final waveHeight = side / 6;
    final wavePath = Path();
    final radius = side / 2.0;
    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);
    }
    //为了方便读者理解,这里把 wavePath 绘制出来,实际上不需要
    final paint = Paint()
      ..isAntiAlias = true
      ..style = PaintingStyle.fill
      ..strokeWidth = 3
      ..color = waveColor;
    canvas.drawPath(wavePath, paint);
  }

在这里插入图片描述

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

wavePath.relativeLineTo(0, radius);
wavePath.lineTo(-waveWidth, side);
wavePath.close();
//为了方便读者理解,这里把 wavePath 绘制出来,实际上不需要
final paint = Paint()
  ..isAntiAlias = true
  ..style = PaintingStyle.fill
  ..strokeWidth = 3
  ..color = waveColor;
canvas.drawPath(wavePath, paint);

wavePath 闭合后,此时半圆的颜色就会铺满了

在这里插入图片描述

四、取交集

取 circlePath 和 wavePath 的交集,就得到一个半圆形波浪球了

final paint = Paint()
  ..isAntiAlias = true
  ..style = PaintingStyle.fill
  ..strokeWidth = 3
  ..color = waveColor;
final combinePath = Path.combine(PathOperation.intersect, circlePath, wavePath);
canvas.drawPath(combinePath, paint);

在这里插入图片描述

五、绘制 foregroundColor 文本

文本的颜色是分为上下两部分的,上半部分颜色为 backgroundColor,下半部分为 foregroundColor。在第一步的时候已经绘制了颜色为 backgroundColor 的文本了,foregroundColor 文本不需要显示上半部分,所以在绘制 foregroundColor 文本之前需要先把绘制区域限定在 combinePath 内,使得两次不同时间绘制的文本重叠在了一起,从而得到有不同颜色范围的文本

canvas.clipPath(combinePath);
_drawText(canvas: canvas, side: side, color: foregroundColor);

在这里插入图片描述

六、添加动画

现在已经绘制好静态时的效果了,可以考虑如何使 widget 动起来了

要实现动态效果也很简单,只要不断改变贝塞尔曲线的起始点坐标,使之不断从左往右移动,就可以营造出波浪从左往右前进的效果了。_WaveLoadingPainter 根据外部传入的动画值 animatedValue 来设置 wavePath 的起始坐标点即可,生成 animatedValue 的逻辑和其它绘制参数均由 _WaveLoadingState 来提供

class _WaveLoadingState extends State<WaveLoading>
    with SingleTickerProviderStateMixin {
  String get _text => widget.text;

  double get _fontSize => widget.fontSize;

  Color get _backgroundColor => widget.backgroundColor;

  Color get _foregroundColor => widget.foregroundColor;

  Color get _waveColor => widget.waveColor;

  late AnimationController _controller;

  late Animation<double> _animation;

  
  void initState() {
    super.initState();
    _controller = AnimationController(
        duration: const Duration(milliseconds: 700), vsync: this);
    _animation = Tween(
      begin: 0.0,
      end: 1.0,
    ).animate(_controller)
      ..addListener(() {
        setState(() => {});
      });
    _controller.repeat();
  }

  
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return RepaintBoundary(
      child: CustomPaint(
        painter: _WaveLoadingPainter(
          text: _text,
          fontSize: _fontSize,
          animatedValue: _animation.value,
          backgroundColor: _backgroundColor,
          foregroundColor: _foregroundColor,
          waveColor: _waveColor,
        ),
      ),
    );
  }
}

_WaveLoadingPainter 根据 animatedValue 来设置 wavePath 的起始坐标点

wavePath.moveTo((animatedValue - 1) * waveWidth, radius);

七、使用

最后将 _WaveLoadingState 包裹到 StatefulWidget 中,在 StatefulWidget 中开放可以自定义配置的参数就可以了

class WaveLoading extends StatefulWidget {
  final String text;

  final double fontSize;

  final Color backgroundColor;

  final Color foregroundColor;

  final Color waveColor;

  WaveLoading({
    Key? key,
    required this.text,
    required this.fontSize,
    required this.backgroundColor,
    required this.foregroundColor,
    required this.waveColor,
  }) : super(key: key) {
    assert(text.isNotEmpty && fontSize > 0);
  }

  
  State<StatefulWidget> createState() {
    return _WaveLoadingState();
  }
}

使用方式:

SizedBox(
	width: 300,
	height: 300,
	child: WaveLoading(
  		text: "開",
  		fontSize: 210,
  		backgroundColor: Colors.lightBlue,
  		foregroundColor: Colors.white,
  		waveColor: Colors.lightBlue,
)
<think>嗯,用户问的是如何在Flutter使用ClipPath实现贝塞尔曲线效果。首先,我需要回忆一下Flutter中ClipPath和贝塞尔曲线相关的知识。记得ClipPath是用来裁剪组件形状的,而贝塞尔曲线则是通过控制点来定义曲线路径的。 首先,用户可能需要一个具体的例子,所以应该提供一个简单的示例代码。根据引用[2],Flutter贝塞尔曲线的API,可以用CustomClipper来实现。引用[3]提到CustomClipper是一个抽象类,需要实现getClip方法。所以示例中需要创建一个继承自CustomClipper的类,比如BézierClipper,然后在getClip里绘制贝塞尔曲线路径。 然后,考虑如何绘制贝塞尔曲线贝塞尔曲线通常需要起点、控制点和终点。例如,二次贝塞尔曲线使用一个控制点,可以用quadraticBezierTo方法。在示例中,可能从左上角开始,到右下角,中间经过一个控制点,形成曲线效果。 接下来,在ClipPath控件中使用这个自定义的clipper。根据引用[1]和[4],ClipPath需要包裹一个Container,设置颜色和尺寸,比如蓝色的容器。然后把这个ClipPath放在Scaffold的body里,可能用Center来居中显示。 还要注意Clipper的shouldReclip方法,通常返回false,除非需要动态变化。这样每次布局变化时不会重新裁剪,提升性能。 另外,用户可能需要知道如何调整曲线的形状,比如修改控制点的位置。所以步骤中应该说明每个参数的作用,比如path.quadraticBezierTo的参数是控制点和终点坐标,调整这些可以改变曲线形状。 还要提醒性能问题,引用[2]提到使用图片会增加资源体积,而用ClipPath和贝塞尔可以避免这点。但复杂的裁剪可能会影响性能,所以需要根据情况优化。 最后,确保代码格式正确,使用正确的缩进和语法,比如CustomClipper的泛型类型是Path,以及正确使用ClipPath的clipper属性。 总结步骤应该是:创建CustomClipper子类,实现getClip方法绘制贝塞尔路径,在ClipPath中使用这个clipper,调整参数控制曲线形状,注意事项包括性能和动态裁剪的可能性。</think>### Flutter使用ClipPath实现贝塞尔曲线效果教程 #### 一、实现步骤 1. **创建自定义剪裁类** 继承`CustomClipper<Path>`并实现`getClip`方法,使用贝塞尔曲线绘制路径: ```dart class BezierClipper extends CustomClipper<Path> { @override Path getClip(Size size) { final path = Path(); path.moveTo(0, size.height * 0.3); // 起点 path.quadraticBezierTo( // 二次贝塞尔曲线 size.width / 2, size.height * 0.8, // 控制点 size.width, size.height * 0.3 // 终点 ); path.lineTo(size.width, 0); // 闭合路径 path.lineTo(0, 0); return path; } @override bool shouldReclip(covariant CustomClipper<Path> oldClipper) => false; } ``` 2. **应用剪裁控件** 将自定义剪裁类ClipPath结合使用: ```dart Scaffold( appBar: AppBar(title: const Text('贝塞尔曲线效果')), body: Center( child: ClipPath( clipper: BezierClipper(), child: Container( width: 300, height: 200, color: Colors.blue, ), ), ), ) ``` #### 二、参数调整技巧 - **曲线弧度**:修改`quadraticBezierTo`的控制点坐标,例如`size.height * 0.8`改为`size.height * 1.2`可加大弧度 - **曲线方向**:调整`moveTo`的起点位置,如`size.height * 0.3`改为`size.height * 0.7`实现下凹效果 - **动态剪裁**:在`shouldReclip`返回true后配合动控制器可实现动态形变 #### 三、注意事项 1. 性能优化建议:复杂裁剪建议配合`RepaintBoundary`使用[^3] 2. 设计适配:通过`MediaQuery`获取屏幕尺寸实现响应式布局[^4] 3. 组合应用:可`Stack`控件配合实现多层曲线叠加效果[^2]
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值