前言:筆者近期實在太忙,加上想不到很好的可以編寫的內容(其實外掛開發系列只寫了一篇?),確實鴿了大家好久。接下來我的重心會偏向酷炫UI的實現,把canvas、動畫、Render層玩透先。
##靈感來源 作為一個前端開發者,遇到動畫特效總是想讓美工輸入json檔案,再Lottie載入下,效率效能兩不誤。?直到那天我看到一個波浪進度球,頓時實在想不出什麼理由可以糊弄成GIF圖或者json檔案,畢竟載入進度完全是需要程式碼精準控制的。der~還是自己實現玩一玩吧。 ##效果 筆者花了週日一整個下午的時間,配著祖傳工夫茶,終於擼出個像樣的傢伙,效能debug環境下都能穩穩保持在60幀左右。檢視原始碼點這裡,預覽效果見下圖:
##實現步驟 我將這個動效分為3層canvas:圓形背景、圓弧進度條、兩層波浪;3個動畫:圓弧前進動畫、兩層波浪移動的動畫。
- 首先繪製圓形背景,很簡單,畫圓即可,這一層canvas是不需要重新繪製的;
import 'package:flutter/material.dart';
class RoundBasePainter extends CustomPainter {
final Color color;
RoundBasePainter(this.color);
@override
void paint(Canvas canvas, Size size) {
Paint paint = Paint()
..isAntiAlias = true
..style = PaintingStyle.stroke
..strokeWidth = 7.0
..color = color;
//畫進度條圓框背景
canvas.drawCircle(size.center(Offset.zero), size.width / 2, paint);
//儲存畫布狀態
canvas.save();
//恢復畫布狀態
canvas.restore();
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
複製程式碼
- 繪製圓弧進度條,這裡需要理解下我們的起點是在-90°開始的,同時需要將進度轉化為角度進行圓弧的繪製;
import 'dart:math';
import 'package:flutter/material.dart';
class RoundProgressPainter extends CustomPainter {
final Color color;
final double progress;
RoundProgressPainter(this.color, this.progress);
@override
void paint(Canvas canvas, Size size) {
Paint paint = Paint()
..isAntiAlias = true
..style = PaintingStyle.stroke
..strokeWidth = 7.0
..color = color;
// 畫圓弧
canvas.drawArc(
Rect.fromCircle(
center: size.center(Offset.zero), radius: size.width / 2),
-pi / 2, // 起點是-90°
pi * 2 * progress, // 進度*360°
false,
paint);
canvas.save();
canvas.restore();
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true;
}
}
複製程式碼
- 繪製波浪,這裡的原理是:通過貝塞爾曲線實現曲線的,再通過path連線成完整的區域(見圖),然後把這塊區域通過clip裁剪成圓形即可;
程式碼一dart睹為快:
import 'package:flutter/material.dart';
class WavyPainter extends CustomPainter {
// 波浪的曲度
final double waveHeight;
// 進度 [0-1]
final double progress;
// 對波浪區域進行X軸方向的偏移,實現滾動效果
final double offsetX;
final Color color;
WavyPainter(this.progress, this.offsetX, this.color, {this.waveHeight = 24});
@override
void paint(Canvas canvas, Size size) {
Paint paint = Paint()
..isAntiAlias = true
..style = PaintingStyle.fill
..strokeWidth = 1.5
..color = color;
drawWave(canvas, size.center(Offset(0, 0)), size.width / 2, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true;
}
void drawWave(Canvas canvas, Offset center, double radius, Paint paint) {
// 圓形裁剪
canvas.save();
Path clipPath = Path()
..addOval(Rect.fromCircle(center: center, radius: radius));
canvas.clipPath(clipPath);
// 反向計算點的縱座標
double wavePointY = (1 - progress) * radius * 2;
// point3為中心點,波浪的直徑為圓的半徑,一共5個點,加上兩個閉環點(p6、p7)
Offset point1 = Offset(center.dx - radius * 3 + offsetX, wavePointY);
Offset point2 = Offset(center.dx - radius * 2 + offsetX, wavePointY);
Offset point3 = Offset(center.dx - radius + offsetX, wavePointY);
Offset point4 = Offset(center.dx + offsetX, wavePointY);
Offset point5 = Offset(center.dx + radius + offsetX, wavePointY);
Offset point6 = Offset(point5.dx, center.dy + radius + waveHeight);
Offset point7 = Offset(point1.dx, center.dy + radius + waveHeight);
// 貝塞爾曲線控制點
Offset c1 =
Offset(center.dx - radius * 2.5 + offsetX, wavePointY + waveHeight);
Offset c2 =
Offset(center.dx - radius * 1.5 + offsetX, wavePointY - waveHeight);
Offset c3 =
Offset(center.dx - radius * 0.5 + offsetX, wavePointY + waveHeight);
Offset c4 =
Offset(center.dx + radius * 0.5 + offsetX, wavePointY - waveHeight);
// 連線貝塞爾曲線
Path wavePath = Path()
..moveTo(point1.dx, point1.dy)
..quadraticBezierTo(c1.dx, c1.dy, point2.dx, point2.dy)
..quadraticBezierTo(c2.dx, c2.dy, point3.dx, point3.dy)
..quadraticBezierTo(c3.dx, c3.dy, point4.dx, point4.dy)
..quadraticBezierTo(c4.dx, c4.dy, point5.dx, point5.dy)
..lineTo(point6.dx, point6.dy)
..lineTo(point7.dx, point7.dy)
..close();
// 繪製
canvas.drawPath(wavePath, paint);
canvas.restore();
}
}
複製程式碼
- 新增動畫。重點講解下波浪的動畫效果,其實就是上面的貝塞爾曲線區域,進行X軸方向的重複勻速移動,加上貝塞爾曲線的效果,就可以產生上下起伏的波浪效果。且通過下圖,可以確定平移的距離是圓形的直徑。
筆者建立的是package,下面的程式碼是真正暴露給呼叫方使用的控制元件的實現,同時也實現了所有的動畫。
library round_wavy_progress;
import 'package:flutter/material.dart';
import 'package:round_wavy_progress/painter/round_base_painter.dart';
import 'package:round_wavy_progress/painter/round_progress_painter.dart';
import 'package:round_wavy_progress/painter/wavy_painter.dart';
import 'package:round_wavy_progress/progress_controller.dart';
class RoundWavyProgress extends StatefulWidget {
RoundWavyProgress(this.progress, this.radius, this.controller,
{Key? key,
this.mainColor,
this.secondaryColor,
this.roundSideColor = Colors.grey,
this.roundProgressColor = Colors.white})
: super(key: key);
final double progress;
final double radius;
final ProgressController controller;
final Color? mainColor;
final Color? secondaryColor;
final Color roundSideColor;
final Color roundProgressColor;
@override
_RoundWavyProgressState createState() => _RoundWavyProgressState();
}
class _RoundWavyProgressState extends State<RoundWavyProgress>
with TickerProviderStateMixin {
late AnimationController wareController;
late AnimationController mainController;
late AnimationController secondController;
late Animation<double> waveAnimation;
late Animation<double> mainAnimation;
late Animation<double> secondAnimation;
double currentProgress = 0.0;
@override
void initState() {
super.initState();
widget.controller.stream.listen((event) {
print(event);
wareController.reset();
waveAnimation = Tween(begin: currentProgress, end: event as double)
.animate(wareController);
currentProgress = event;
wareController.forward();
});
wareController = AnimationController(
vsync: this,
duration: Duration(milliseconds: 1200),
);
mainController = AnimationController(
vsync: this,
duration: Duration(milliseconds: 3200),
);
secondController = AnimationController(
vsync: this,
duration: Duration(milliseconds: 1800),
);
waveAnimation = Tween(begin: currentProgress, end: widget.progress)
.animate(wareController);
mainAnimation =
Tween(begin: 0.0, end: widget.radius * 2).animate(mainController);
secondAnimation =
Tween(begin: widget.radius * 2, end: 0.0).animate(secondController);
wareController.forward();
mainController.repeat();
secondController.repeat();
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
final viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
return AnimatedBuilder(
animation: mainAnimation,
builder: (BuildContext ctx, Widget? child) {
return AnimatedBuilder(
animation: secondAnimation,
builder: (BuildContext ctx, Widget? child) {
return AnimatedBuilder(
animation: waveAnimation,
builder: (BuildContext ctx, Widget? child) {
return Stack(
children: [
RepaintBoundary(
child: CustomPaint(
size: viewportSize,
painter: WavyPainter(
waveAnimation.value,
mainAnimation.value,
widget.mainColor ??
Theme.of(context).primaryColor),
child: RepaintBoundary(
child: CustomPaint(
size: viewportSize,
painter: WavyPainter(
waveAnimation.value,
secondAnimation.value,
widget.secondaryColor ??
Theme.of(context)
.primaryColor
.withOpacity(0.5)),
child: RepaintBoundary(
child: CustomPaint(
size: viewportSize,
painter: RoundBasePainter(
widget.roundSideColor),
child: RepaintBoundary(
child: CustomPaint(
size: viewportSize,
painter: RoundProgressPainter(
widget.roundProgressColor,
waveAnimation.value),
),
),
),
),
),
),
),
),
Align(
alignment: Alignment.center,
child: Text(
'${(waveAnimation.value * 100).toStringAsFixed(2)}%',
style: TextStyle(
fontSize: 18,
color: widget.roundProgressColor,
fontWeight: FontWeight.bold),
),
),
],
);
});
});
});
});
}
}
複製程式碼
這裡程式碼也不細講,沒什麼難度。主要跟大家說一下RepaintBoundary的好處,使用這個控制元件包裹下,可以控制其較小顆粒度的重繪。(具體不擴充套件,有需要請檢視:pub.flutter-io.cn/documentati… 同時,這裡AnimatedBuilder 的巢狀是真的噁心,由於時間比較急,我沒有去檢視是否有更好的實現方式,但至少這個實現方法在效果、效能都非常不錯。
##寫在最後 感謝小夥伴看到了最後,這個進度球已經上傳到個人GitHub,歡迎fork和star;我本週會抽時間繼續優化,抽象出更簡潔的api,並且釋出到pub上; 同時也有更加酷炫的UI正在編寫中,我也會盡力抽出空閒時間去編寫更好的文章與大家交流,加油??~~~
小弟班門弄斧,希望能一起學習進步!!!