参考资料: 《Flutter实战·第二版》10.5 自绘实例:圆形背景渐变进度条
本节要实现一个圆形背景渐变进度条,它有以下几个需求:
- 支持多种渐变背景色;
- 支持任意弧度,即可以不为完整的圆;
- 可以自定义粗细、两端是否圆角等样式。
首先是实现GradientCircularProgressIndicator这个组件,组件主要由一个调整圆角偏移的Transform.rotate和CustomPaint自绘组件组成。自绘组件中包含以下几个属性:
| 属性名 | 含义 |
|---|---|
| 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表示一个区域的尺寸。通过&操作符将Offset和Size结合起来,可以方便地创建一个具有指定位置和尺寸的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度起始位置(图中红色虚线圆弧)最近的、没有截断线的半圆,也就是黄色虚线圆弧对应的位置:
这个图可能还是比较特殊,我们看一个更加常规的图,其偏移值就是顶头所在半圆与渐变线相切的位置:
则该角度的计算公式为:
完整的组件实现代码如下:
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;
}
组件最终测试效果如下: