【Flutter学习笔记】10.5 自绘实例:圆形背景渐变进度条

362 阅读5分钟

参考资料: 《Flutter实战·第二版》10.5 自绘实例:圆形背景渐变进度条


本节要实现一个圆形背景渐变进度条,它有以下几个需求:

  1. 支持多种渐变背景色;
  2. 支持任意弧度,即可以不为完整的圆;
  3. 可以自定义粗细、两端是否圆角等样式。

首先是实现GradientCircularProgressIndicator这个组件,组件主要由一个调整圆角偏移的Transform.rotateCustomPaint自绘组件组成。自绘组件中包含以下几个属性:

属性名含义
strokeWidth画笔宽度
strokeCapRound进度条末端是否圆角
value进度值(进度条显示长度)
backgroundColor背景色
colors渐变色
total总弧度(可以不为完整圆,2Π为完整圆)
radius圆的半径
stops渐变色的终止位置,对应colors

其中半径、渐变色列表是必选参数,其余可根据需要进行配置。CustomPainter中实现具体的UI绘制逻辑,这里shouldRepaint()函数简单返回true,一般来说应该根据画笔属性是否变化来确定返回true还是false。 绘制时,Paint函数中首先根据半径创建Size对象,确定画布的大小。首先绘制背景,根据传入的背景色绘制一个弧形,弧形绘制在rect对象的内部。rect定义如下:

Rect rect = Offset(_offset, _offset) & Size(  
      size.width - strokeWidth,  
      size.height - strokeWidth  
  ); 

在Flutter中,Offset表示一个坐标点,而Size表示一个区域的尺寸。通过&操作符将OffsetSize结合起来,可以方便地创建一个具有指定位置和尺寸的Rect(矩形)对象。这种用法是Flutter框架特定上下文中的语法糖,它使得对象组合变得简洁和直观。

这个圆弧绘制在以2倍半径为宽高的矩形中,从绘制位置可以看出,边框部分是空出了一个strokeWidth的宽度:

编写代码时要注意设置默认值,进行空保护。如果是圆角形态的进度条,需要对起始角度进行一些调整:

if (strokeCapRound) {
      _start = asin(strokeWidth/ (size.width - strokeWidth));
    }

这样做的原因是因为弧形渐变在起始位置时,色彩实际上是割断的,去掉这句话之后,会发现进度条变成了这样,前面多了一块深色的地方:

我们画好背景弧形后,需要利用SweepGradient设置渐变画笔,把画笔变粗为半径大小(strokeWidth=50.0)之后可以发现,其填充的渐变色实际上是从0.0位置开始,其前面是结束时的深色,因此看起来前面一块很违和:

所以需要把绘制的位置向前调整,让起始色填满前面超出的圆角部分。但这样却又会导致进度条的起始位置偏离了12点方向:

调整startAngle是不可行的,因为其含义是渐变线的位置,而且该值不能小于0。因此,只能将整个进度条组件进行反向旋转以纠正位置:

if (strokeCapRound) {
  _offset = asin(strokeWidth / (radius * 2 - strokeWidth));
}
var _colors = colors;
if (_colors == null) {
  Color color = Theme.of(context).colorScheme.secondary;
  _colors = [color, color];
}
return Transform.rotate(
  angle: -pi / 2.0 - _offset,
  child: CustomPaint(
      size: Size.fromRadius(radius),
      painter: _GradientCircularProgressPainter(
        strokeWidth: strokeWidth,
        strokeCapRound: strokeCapRound,
        backgroundColor: backgroundColor,
        value: value,
        total: totalAngle,
        radius: radius,
        colors: _colors,
        stops: stops,
      )),
);

上式中,首先旋转了-pi / 2.0后,再添加了计算的偏移角度,是因为默认的0度实际上在x轴的正向,也就是正右方,把Transform.rotate组件和偏移计算都注释掉可以看到下面的样子:

而所调整的偏移角度,一定要保证是距离0度起始位置(图中红色虚线圆弧)最近的、没有截断线的半圆,也就是黄色虚线圆弧对应的位置:

这个图可能还是比较特殊,我们看一个更加常规的图,其偏移值就是顶头所在半圆与渐变线相切的位置:

则该角度的计算公式为: arcsin(线宽半径线宽)arcsin(\frac{线宽}{半径 - 线宽})

完整的组件实现代码如下:

import 'dart:ui';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'TEAL WORLD'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});
  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(
          widget.title,
          style: TextStyle(
              color: Colors.teal.shade800, fontWeight: FontWeight.w900),
        ),
        actions: [
          ElevatedButton(
            child: const Icon(Icons.refresh),
            onPressed: () {
              setState(() {});
            },
          )
        ],
      ),
      body: const GradientCircularProgressRoute(),
      floatingActionButton: FloatingActionButton(
        onPressed: () {},
        tooltip: 'Increment',
        child: Icon(
          Icons.add_box,
          size: 30,
          color: Colors.teal[400],
        ),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}

class GradientCircularProgressRoute extends StatefulWidget {
  const GradientCircularProgressRoute({Key? key}) : super(key: key);

  @override
  GradientCircularProgressRouteState createState() {
    return GradientCircularProgressRouteState();
  }
}

class GradientCircularProgressRouteState
    extends State<GradientCircularProgressRoute> with TickerProviderStateMixin {
  late AnimationController _animationController;

  @override
  void initState() {
    super.initState();
    _animationController = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 3),
    );
    // 使动画反复播放
    _animationController.addStatusListener((status) {
      if (status == AnimationStatus.completed) {
        _animationController.value = 0;
        _animationController.forward();
      }
    });
    _animationController.forward();
  }

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

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
        animation: _animationController,
        builder: (BuildContext context, child) {
          return Center(
            child: GradientCircularProgressIndicator(
              radius: 100.0,
              colors: const [Colors.cyanAccent, Colors.teal],
              strokeWidth: 20.0,
              value: _animationController.value,
              strokeCapRound: true,
            ),
          );
        });
  }
}

class GradientCircularProgressIndicator extends StatelessWidget {
  const GradientCircularProgressIndicator({
    Key? key,
    required this.radius,
    required this.colors,
    this.stops,
    this.strokeWidth = 2.0,
    this.strokeCapRound = false,
    this.backgroundColor = const Color(0xFFEEEEEE),
    this.totalAngle = 2 * pi,
    required this.value,
  }) : super(key: key);

  ///粗细
  final double strokeWidth;

  /// 圆的半径
  final double radius;

  ///两端是否为圆角
  final bool strokeCapRound;

  /// 当前进度,取值范围 [0.0-1.0]
  final double value;

  /// 进度条背景色
  final Color backgroundColor;

  /// 进度条的总弧度,2*PI为整圆,小于2*PI则不是整圆
  final double totalAngle;

  /// 渐变色数组
  final List<Color> colors;

  /// 渐变色的终止点,对应colors属性
  final List<double>? stops;

  @override
  Widget build(BuildContext context) {
    double _offset = .0;
    // 如果两端为圆角,则需要对起始位置进行调整,否则圆角部分会偏离起始位置
    // 下面调整的角度的计算公式是通过数学几何知识得出,读者有兴趣可以研究一下为什么是这样
    if (strokeCapRound) {
      _offset = asin(strokeWidth / (radius * 2 - strokeWidth));
    }
    var _colors = colors;
    if (_colors == null) {
      Color color = Theme.of(context).colorScheme.secondary;
      _colors = [color, color];
    }
    return Transform.rotate(
      angle: -pi / 2.0 - _offset,
      child: CustomPaint(
          size: Size.fromRadius(radius),
          painter: _GradientCircularProgressPainter(
            strokeWidth: strokeWidth,
            strokeCapRound: strokeCapRound,
            backgroundColor: backgroundColor,
            value: value,
            total: totalAngle,
            radius: radius,
            colors: _colors,
            stops: stops,
          )),
    );
  }
}

//实现画笔
class _GradientCircularProgressPainter extends CustomPainter {
  _GradientCircularProgressPainter(
      {this.strokeWidth = 10.0,
      this.strokeCapRound = false,
      this.backgroundColor = const Color(0xFFEEEEEE),
      required this.radius,
      this.total = 2 * pi,
      required this.colors,
      this.stops,
      required this.value});

  final double strokeWidth;
  final bool strokeCapRound;
  final double value;
  final Color backgroundColor;
  final List<Color> colors;
  final double total;
  final double radius;
  final List<double>? stops;

  @override
  void paint(Canvas canvas, Size size) {
    size = Size.fromRadius(radius);
      double _offset = strokeWidth / 2.0;
    double _value = value;
    _value = _value.clamp(.0, 1.0) * total;
    double _start = .0;

    // 对初始绘制位置调整避免出现渐变交界线
    if (strokeCapRound) {
      _start = asin(strokeWidth / (size.width - strokeWidth));
    }

    Rect rect = Offset(_offset, _offset) &
        Size(size.width - strokeWidth, size.height - strokeWidth);

    var paint = Paint()
      ..strokeCap = strokeCapRound ? StrokeCap.round : StrokeCap.butt
      ..style = PaintingStyle.stroke
      ..isAntiAlias = true
      ..strokeWidth = strokeWidth;

    // 先画背景
    if (backgroundColor != Colors.transparent) {
      paint.color = backgroundColor;
      canvas.drawArc(rect, _start, total, false, paint);
    }

    // 再画前景,应用渐变
    if (_value > 0) {
      paint.shader = SweepGradient(
        startAngle: 0.0,
        endAngle: _value,
        colors: colors,
        stops: stops,
      ).createShader(rect);

      canvas.drawArc(rect, _start, _value, false, paint);
    }
  }

  //简单返回true,实践中应该根据画笔属性是否变化来确定返回true还是false
  @override
  bool shouldRepaint(CustomPainter oldDelegate) => true;
}

组件最终测试效果如下: