当前位置:主页 > 移动开发 > Android代码 >

Android 贝塞尔曲线绘制一个波浪球

时间:2022-08-11 11:28:10 | 栏目:Android代码 | 点击:

前言

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

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

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

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

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

一、绘制 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,
  });

  @override
  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),
    );
  }

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

二、构建 circlePath

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

  @override
  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 的中间分隔线作为水平线,在水平线的上下根据贝塞尔曲线绘制出连续的波浪线

  @override
  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;

  @override
  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();
  }

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

  @override
  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);
  }

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

使用方式:

SizedBox(
	width: 300,
	height: 300,
	child: WaveLoading(
  		text: "開",
  		fontSize: 210,
  		backgroundColor: Colors.lightBlue,
  		foregroundColor: Colors.white,
  		waveColor: Colors.lightBlue,
)

源代码看这里:WaveLoadingWidget

您可能感兴趣的文章:

相关文章