Flutter利用Canvas模擬實(shí)現(xiàn)微信紅包領(lǐng)取效果
前言
前面寫了一篇Flutter利用Canvas繪制精美表盤效果詳解的文章,對(duì) Flutter 中的 Canvas 使用有了進(jìn)一步的理解,就想著再用 Canvas 實(shí)現(xiàn)一個(gè)什么樣的效果來加深一下對(duì) Canvas 使用的理解,這個(gè)時(shí)候正好看到群里有人發(fā)紅包,于是就想著能不能在 Flutter 中使用 Canvas 實(shí)現(xiàn)微信領(lǐng)取紅包的效果?想到就做,知行合一,經(jīng)過幾天空余時(shí)間的研究,最終實(shí)現(xiàn)了微信領(lǐng)取紅包效果,于是有了這篇文章。
效果
最終實(shí)現(xiàn)的整體效果如下:

看完效果以后,接下來就帶領(lǐng)大家來看看是怎樣一步一步實(shí)現(xiàn)最終效果的,在正式動(dòng)手寫代碼之前,先對(duì)整個(gè)效果做一個(gè)簡(jiǎn)單的拆分,將其分為五個(gè)部分:
- 點(diǎn)擊彈出紅包
- 紅包整體布局
- 金幣點(diǎn)擊旋轉(zhuǎn)
- 紅包開啟動(dòng)畫
- 結(jié)果頁(yè)面彈出
拆分后如下圖所示:

接下來就一步一步來實(shí)現(xiàn)。
紅包彈出
紅包彈出主要分為兩部分:從小到大縮放動(dòng)畫、半透明遮罩。很自然的想到了使用 Dialog 來實(shí)現(xiàn),最終也確實(shí)使用 Dialog 實(shí)現(xiàn)了對(duì)應(yīng)的效果,但是在最后展示結(jié)果頁(yè)的時(shí)候出現(xiàn)問題了,因?yàn)榧t包開啟與結(jié)果展示是同時(shí)進(jìn)行的,結(jié)果頁(yè)在紅包下面,使用 Dialog 的話會(huì)存在結(jié)果頁(yè)在 Dialog 上面遮住紅包的效果,最后使用了 Overlay 在頂層添加一個(gè) Widget 來實(shí)現(xiàn)。
創(chuàng)建一個(gè) RedPacket 的 Widget:
class RedPacket extends StatelessWidget {
const RedPacket({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: Container(
width: 0.8.sw,
height: 1.2.sw,
color: Colors.redAccent,
)
);
}
}內(nèi)容很簡(jiǎn)單,就是一個(gè)居中的寬高分別為 0.8.sw 、1.2.sw 的 Container,顏色為紅色。這里 sw 是代表屏幕寬度,即紅包寬度為屏幕寬度的 0.8 倍,高度為屏幕寬度的 1.2 倍。
關(guān)于 Flutter 屏幕適配,請(qǐng)參閱:Flutter應(yīng)用框架搭建之屏幕適配詳解
然后點(diǎn)擊按鈕時(shí)通過 Overlay 展示出來, 創(chuàng)建一個(gè) showRedPacket 的方法:
void showRedPacket(BuildContext context){
OverlayEntry entry = OverlayEntry(builder: (context) => RedPacket());
Overlay.of(context)?.insert(entry);
}效果如下:

紅包是彈出來了,但因?yàn)闆]有縮放動(dòng)畫,很突兀。為了實(shí)現(xiàn)縮放動(dòng)畫,在 Container 上包裹 ScaleTransition 用于縮放動(dòng)畫,同時(shí)將 RedPacket 改為 StatefulWidget ,因?yàn)槭褂脛?dòng)畫需要用到 AnimationController 傳入 SingleTickerProviderStateMixin ,實(shí)現(xiàn)如下:
class RedPacket extends StatefulWidget {
const RedPacket({Key? key}) : super(key: key);
@override
State<RedPacket> createState() => _RedPacketState();
}
class _RedPacketState extends State<RedPacket> with SingleTickerProviderStateMixin {
late AnimationController scaleController = AnimationController(vsync: this)
..duration = const Duration(milliseconds: 500)
..forward();
@override
Widget build(BuildContext context) {
return Container(
color: Color(0x88000000), /// 半透明遮罩
child: Center(
child: ScaleTransition(
scale: Tween<double>(begin: 0.0, end: 1.0).animate(CurvedAnimation(parent: scaleController, curve: Curves.fastOutSlowIn)),
child: Container(
width: 0.8.sw,
height: 1.2.sw,
color: Colors.redAccent,
),
)
),
);
}
}ScaleTransition 設(shè)置動(dòng)畫從 0.0 到 1.0 即從無到原本大小,動(dòng)畫時(shí)間為 500 毫秒;同時(shí)在外層再包裹一層 Container 并為其添加半透明顏色實(shí)現(xiàn)半透明遮罩,最終實(shí)現(xiàn)效果:

這樣就實(shí)現(xiàn)了第一部分的功能。
紅包布局
標(biāo)題說了是使用 Canvas 來實(shí)現(xiàn),所以紅包布局主要是使用 Canvas 來實(shí)現(xiàn),將前面紅包的 Container 換成 CustomPaint, 然后創(chuàng)建 RedPacketPainter 繼承自 CustomPainter :
ScaleTransition(
scale: Tween<double>(begin: 0.0, end: 1.0).animate(CurvedAnimation(parent: scaleController, curve: Curves.fastOutSlowIn)),
child: CustomPaint(
size: Size(1.sw, 1.sh),
painter: RedPacketPainter(),
),
)考慮到后續(xù)動(dòng)畫,這里將畫布的大小設(shè)置為全屏。紅包布局的核心代碼就在 RedPacketPainter 里,首先繪制紅包的背景,背景分為上下兩部分,上部分又由一個(gè)矩形和一個(gè)圓弧組成,下半部分同樣是由一個(gè)矩形和一個(gè)圓弧組成,上半部分的圓弧是凸出來的,而下半部分的是凹進(jìn)去的,示意圖如下:

初始化:
/// 畫筆 late final Paint _paint = Paint()..isAntiAlias = true; /// 路徑 final Path path = Path(); /// 紅包的高度:1.2倍的屏幕寬度 late double height = 1.2.sw; /// 上半部分貝塞爾曲線的結(jié)束點(diǎn) late double topBezierEnd = (1.sh - height)/2 + height/8*7; /// 上半部分貝塞爾曲線的起點(diǎn) late double topBezierStart= topBezierEnd - 0.2.sw; /// 下半部分貝塞爾曲線的起點(diǎn) late double bottomBezierStart = topBezierEnd - 0.4.sw; /// 金幣中心點(diǎn),后續(xù)通過path計(jì)算 Offset goldCenter = Offset.zero; /// 橫向的中心點(diǎn) final double centerWidth = 0.5.sw; /// 紅包在整個(gè)界面的left late double left = 0.1.sw; /// 紅包在整個(gè)界面的right late double right = 0.9.sw; /// 紅包在整個(gè)界面的top late double top = (1.sh - height)/2; /// 紅包在整個(gè)界面的bottom late double bottom = (1.sh - height)/2 + height;
上半部分
代碼實(shí)現(xiàn)如下:
void drawTop(ui.Canvas canvas) {
path.reset();
path.addRRect(RRect.fromLTRBAndCorners(left, top, right, topBezierStart, topLeft: const Radius.circular(5), topRight: const Radius.circular(5)));
var bezierPath = getTopBezierPath();
path.addPath(bezierPath, Offset.zero);
path.close();
canvas.drawShadow(path, Colors.redAccent, 2, true);
canvas.drawPath(path, _paint);
}這里使用 Path 來進(jìn)行繪制,首先向路徑中添加一個(gè)圓角矩形,也就是示意圖中的第①部分,然后通過 getTopBezierPath 獲取一個(gè)貝塞爾曲線的 bezierPath 并將其添加到 path 路徑中,getTopBezierPath 源碼如下:
Path getTopBezierPath() {
Path bezierPath = Path();
bezierPath.moveTo(left, topBezierStart);
bezierPath.quadraticBezierTo(centerWidth, topBezierEnd, right , topBezierStart);
var pms = bezierPath.computeMetrics();
var pm = pms.first;
goldCenter = pm.getTangentForOffset(pm.length / 2)?.position ?? Offset.zero;
return bezierPath;
}getTopBezierPath 源碼分為兩部分,第一部分是創(chuàng)建貝塞爾曲線的 path ,使用的是最開始初始化的數(shù)據(jù)創(chuàng)建,實(shí)現(xiàn)示意圖中的第②部分內(nèi)容;然后根據(jù)創(chuàng)建好的貝塞爾曲線的 path 計(jì)算出路徑中中間點(diǎn)的坐標(biāo),作為金幣中心點(diǎn)坐標(biāo)。示意圖如下:

圖中紅點(diǎn)就是貝塞爾曲線的點(diǎn),中間實(shí)線就是貝塞爾曲線,也就是上面代碼中創(chuàng)建的貝塞爾曲線路徑,實(shí)線中間的點(diǎn)就是金幣位置的中心點(diǎn)。
貝塞爾曲線繪制完成后調(diào)用 drawShadow 繪制陰影,作用是突出上下兩部分連接處的效果,最后通過 path 繪制出整個(gè)上半部分的效果,如下:

下半部分
代碼實(shí)現(xiàn)如下:
void drawBottom(ui.Canvas canvas) {
path.reset();
path.moveTo(left, bottomBezierStart );
path.quadraticBezierTo(centerWidth, topBezierEnd, right , bottomBezierStart);
path.lineTo(right, topBezierEnd);
path.lineTo(left, topBezierEnd);
path.addRRect(RRect.fromLTRBAndCorners(left, topBezierEnd, right, bottom, bottomLeft: const Radius.circular(5), bottomRight: const Radius.circular(5)));
path.close();
canvas.drawShadow(path, Colors.redAccent, 2, true);
canvas.drawPath(path, _paint);
}下半部分實(shí)現(xiàn)同樣分為兩部分,首先繪制出貝塞爾曲線,即示意圖第③部分,然后再添加一個(gè)圓角矩形,即示意圖第④部分;然后繪制下半部分的陰影和圖形,單獨(dú)展示下半部分效果如下:

將上下兩部分結(jié)合起來,就實(shí)現(xiàn)了紅包背景的效果,如下:

金幣繪制
背景繪制完成后接下來進(jìn)行金幣的繪制,前面已計(jì)算出金幣的中心點(diǎn)坐標(biāo),靜態(tài)金幣的繪制就相對(duì)來說比較簡(jiǎn)單了,就是一個(gè)圓形,代碼如下:
void drawGold(ui.Canvas canvas){
Path path = Path();
canvas.save();
canvas.translate(0.5.sw, goldCenter.dy);
_paint.style = PaintingStyle.fill;
path.addOval(Rect.fromLTRB(-40.w , -40.w, 40.w , 40.w));
_paint.color = const Color(0xFFFCE5BF);
canvas.drawPath(path, _paint);
canvas.restore();
}這里將畫布移動(dòng)到到金幣的中心點(diǎn),然后向 Path 中添加添加一個(gè)半徑為 40.w 的圓,最后將 path 繪制出來即可。效果如下:

金幣文字繪制
金幣繪制出來后,還需在金幣上繪制一個(gè)繁體的 "開" 字,代碼如下:
void drawOpenText(ui.Canvas canvas) {
if(controller.showOpenText){
TextPainter textPainter = TextPainter(
text: TextSpan(
text: "開",
style: TextStyle(fontSize: 34.sp, color: Colors.black87, height: 1.0, fontWeight: FontWeight.w400)
),
textDirection: TextDirection.ltr,
maxLines: 1,
textWidthBasis: TextWidthBasis.longestLine,
textHeightBehavior: const TextHeightBehavior(applyHeightToFirstAscent: false, applyHeightToLastDescent: false)
)..layout();
canvas.save();
canvas.translate(0.5.sw, goldCenter.dy);
textPainter.paint(canvas, Offset(- textPainter.width / 2, -textPainter.height/2));
canvas.restore();
}
}使用 TextPainter 進(jìn)行文字的繪制,同樣是將畫布移動(dòng)到金幣的中心,然后繪制文字,效果如下:

頭像和文字
經(jīng)過上面的繪制,效果已經(jīng)出來了,但是還差紅包封面上的用戶頭像相關(guān)文字,使用 Canvas 同樣能實(shí)現(xiàn),但這里并沒有使用 Canvas 來實(shí)現(xiàn),而是使用 CoustomPaint 的 child 來實(shí)現(xiàn):
CustomPaint(
size: Size(1.sw, 1.sh),
painter: RedPacketPainter(controller: controller),
child: buildChild(),
)
Container buildChild() {
return Container(
padding: EdgeInsets.only(top: 0.3.sh),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(3.w),
child: Image.network("https://p26-passport.byteacctimg.com/img/user-avatar/32f1f514b874554f69fe265644ca84e4~300x300.image", width: 24.w,)),
SizedBox(width: 5.w,),
Text("loongwind發(fā)出的紅包", style: TextStyle(fontSize: 16.sp, color: Color(
0xFFF8E7CB), fontWeight: FontWeight.w500),)
],
),
SizedBox(height: 15.w,),
Text("恭喜發(fā)財(cái)", style: TextStyle(fontSize: 18.sp, color: Color(
0xFFF8E7CB)),)
],
),
);
}CoustomPaint 的 child 允許傳入一個(gè) Widget,Widget 的實(shí)現(xiàn)就是要顯示的頭像和文字,代碼如上,最終效果如下:

至此紅包的整個(gè)布局的實(shí)現(xiàn)就完成了。
金幣旋轉(zhuǎn)
前面完成了紅包的靜態(tài)顯示,接下來就看看怎么讓紅包動(dòng)起來,首先看看怎么讓金幣旋轉(zhuǎn)起來。
說到旋轉(zhuǎn)首先想到的就是以金幣的中心旋轉(zhuǎn),可以通過旋轉(zhuǎn)畫布的旋轉(zhuǎn)或者 path 的 transform 旋轉(zhuǎn)來實(shí)現(xiàn),但是經(jīng)過實(shí)驗(yàn)使用這種方式能讓金幣旋轉(zhuǎn)起來,但是做到旋轉(zhuǎn)的立體效果卻很復(fù)雜。所以最終采用的是使用兩個(gè)圓在 x 軸上進(jìn)行一定的偏移,然后壓縮圓的寬度來模擬實(shí)現(xiàn)旋轉(zhuǎn)效果,示意圖如下:

如圖所示,繪制兩個(gè)相同的圓,開始時(shí)將兩個(gè)圓重疊在一起,然后同時(shí)壓縮圓的寬度并將下層的圓向左偏移一定單位,就形成了旋轉(zhuǎn)的立體效果。
代碼:
void drawGold(ui.Canvas canvas){
Path path = Path();
canvas.save();
canvas.translate(0.5.sw, goldCenter.dy);
_paint.style = PaintingStyle.fill;
path.addOval(Rect.fromLTRB(-40.w , -40.w, 40.w , 40.w));
_paint.color = const Color(0xFFE5CDA8);
canvas.drawPath(path, _paint);
_paint.color = const Color(0xFFFCE5BF);
canvas.drawPath(path, _paint);
canvas.restore();
}修改上面繪制金幣的代碼,設(shè)置不同的顏色再繪制一個(gè)圓,這樣就在同一個(gè)位置繪制了兩個(gè)不同顏色的圓。那么怎么讓它動(dòng)起來呢?可以使用動(dòng)畫,通過動(dòng)畫執(zhí)行寬度的縮放,是寬度系數(shù)從 1 縮放到 0 再?gòu)?0 回到 1。因?yàn)?CustomPainter 是繼承自 Listenable ,而動(dòng)畫也是 Listenable 所以直接將動(dòng)畫與 CustomPainter 結(jié)合起來使用更方便。
為了方便統(tǒng)一控制紅包的動(dòng)畫,創(chuàng)建一個(gè) RedPacketController,并在里面創(chuàng)建一個(gè)控制金幣旋轉(zhuǎn)的動(dòng)畫及控制器:
class RedPacketController{
final SingleTickerProviderStateMixin tickerProvider;
late AnimationController angleController;
late Animation<double> angleCtrl;
RedPacketController({required this.tickerProvider}){
initAnimation();
}
void initAnimation() {
angleController = AnimationController(
duration: const Duration(seconds: 3),
vsync: tickerProvider
)..repeat(reverse: true);
angleCtrl = angleController.drive(Tween(begin: 1.0, end: 0.0));
}
void dispose(){
angleController.dispose();
timer?.cancel();
}
}為了看到旋轉(zhuǎn)的效果,將動(dòng)畫執(zhí)行時(shí)間設(shè)置為 3 秒,并且讓其重復(fù)執(zhí)行,重復(fù)執(zhí)行時(shí)設(shè)置 reverse 為 true,即反向執(zhí)行,然后改造 _RedPacketState 混入 SingleTickerProviderStateMixin 并創(chuàng)建 RedPacketController :
class _RedPacketState extends State<RedPacket> with SingleTickerProviderStateMixin{
late RedPacketController controller = RedPacketController(tickerProvider: this);
Widget buildRedPacket() {
return GestureDetector(
onTapUp: controller.clickGold,
child: CustomPaint(
size: Size(1.sw, 1.sh),
painter: RedPacketPainter(controller: controller),
child: buildChild(),
),
);
}
/// ...
}
class RedPacketPainter extends CustomPainter{
RedPacketController controller;
RedPacketPainter({required this.controller}) : super(repaint:controller.angleController);
/// ...
}RedPacketPainter 的構(gòu)造方法調(diào)用了 super 并傳入了 repaint 參數(shù),即創(chuàng)建的動(dòng)畫控制器。
這樣就能在繪制金幣的時(shí)候使用動(dòng)畫的值了:
void drawGold(ui.Canvas canvas){
Path path = Path();
double angle = controller.angleCtrl.value;
canvas.save();
canvas.translate(0.5.sw, goldCenter.dy);
_paint.style = PaintingStyle.fill;
path.addOval(Rect.fromLTRB(-40.w * angle , -40.w, 40.w * angle, 40.w));
_paint.color = const Color(0xFFE5CDA8);
canvas.drawPath(path, _paint);
_paint.color = const Color(0xFFFCE5BF);
canvas.drawPath(path, _paint);
canvas.restore();
}通過 controller.angleCtrl.value 獲取當(dāng)前動(dòng)畫的值,然后在圓的 left 和 right 參數(shù)上乘以這個(gè)值,看一下效果:

效果已經(jīng)有了,但是發(fā)現(xiàn)在旋轉(zhuǎn)到最小的時(shí)候中間是空的,這不符合我們的預(yù)期,那怎么辦呢?將兩個(gè)圓的邊一一連接起來是不是中間就不空了,如圖所示:

代碼實(shí)現(xiàn):
void drawGoldCenterRect(ui.Path path, ui.Path path2, ui.Canvas canvas) {
var pms1 = path.computeMetrics();
var pms2 = path2.computeMetrics();
var pathMetric1 = pms1.first;
var pathMetric2 = pms2.first;
var length = pathMetric1.length;
Path centerPath = Path();
for(int i = 0; i <length; i++){
var position1 = pathMetric1.getTangentForOffset(i.toDouble())?.position;
var position2 = pathMetric2.getTangentForOffset(i.toDouble())?.position;
if(position1 == null || position2 == null){
continue;
}
centerPath.moveTo(position1.dx, position1.dy);
centerPath.lineTo(position2.dx, position2.dy);
}
Paint centerPaint = Paint()..color = const Color(0xFFE5CDA8)
..style = PaintingStyle.stroke..strokeWidth=1;
canvas.drawPath(centerPath, centerPaint);
}使用 Path.computeMetrics 計(jì)算兩個(gè) path 的路徑點(diǎn),循環(huán)路徑每一個(gè)點(diǎn),將兩個(gè) path 的每一個(gè)點(diǎn)連接起來然后繪制出來,再來看一下效果:

效果好多了,但是仔細(xì)觀察發(fā)現(xiàn)還是有一個(gè)問題,金幣看著不是旋轉(zhuǎn)的而是左右搖擺的,這是因?yàn)閷?shí)現(xiàn)的立體的效果一直在一邊導(dǎo)致的,需要根據(jù)旋轉(zhuǎn)的時(shí)機(jī)將立體效果的方向切換,從 1 到 0 時(shí)在右邊,從 0 到 1 時(shí)在左邊,通過動(dòng)畫的狀態(tài)進(jìn)行判斷,修改代碼如下:
var frontOffset = 0.0;
var backOffset = 0.0;
if(controller.angleCtrl.status == AnimationStatus.reverse){
frontOffset = 4.w;
backOffset = -4.w;
}else if(controller.angleCtrl.status == AnimationStatus.forward){
frontOffset = -4.w;
backOffset = 4.w;
}
var path2 = path.shift(Offset(backOffset * (1 - angle), 0));
path = path.shift(Offset(frontOffset * (1 - angle), 0));再來看一下效果:

這樣旋轉(zhuǎn)效果就很完美了,金幣中間還缺一個(gè)空心的矩形,實(shí)現(xiàn)很簡(jiǎn)單,在圓的 path 路徑中疊加一個(gè)矩形即可:
path.addRect(Rect.fromLTRB(-10.w * angle , -10.w, 10.w * angle , 10.w)); path.fillType = PathFillType.evenOdd;
設(shè)置fillType 為 evenOdd ,這樣就形成了中心空心的效果,并且由于上面連接兩個(gè)圓的路徑點(diǎn),這個(gè)空心也自帶了立體效果,如圖:

最后為金幣添加點(diǎn)擊事件,點(diǎn)擊時(shí)開啟旋轉(zhuǎn),并隱藏金幣上的文字。點(diǎn)擊事件可以直接給 CustomPaint 包裹一個(gè) GestureDetector ,點(diǎn)擊時(shí)判斷點(diǎn)擊坐標(biāo)是否在金幣的繪制范圍內(nèi),可以使用 Path.contains 進(jìn)行判斷,所以需要保存金幣的 path 用于點(diǎn)擊判斷,這里將其保存到 controller 里:
controller.goldPath = path.shift(Offset(0.5.sw, goldCenter.dy));
然后在 RedPacketController 里定義對(duì)應(yīng)的變量和方法:
class RedPacketController{
Path? goldPath;
bool showOpenText = true;
bool checkClickGold(Offset point){
return goldPath?.contains(point) == true;
}
void clickGold(TapUpDetails details) {
if(checkClickGold(details.globalPosition)){
angleController.repeat(reverse: true);
tickerProvider.setState(() {
showOpenText = false;
});
}
}
/// ...
}goldPath 用于保存金幣的 path,showOpenText 用于是否顯示金幣上的文字,點(diǎn)擊時(shí)判斷事件觸發(fā)點(diǎn)是否在金幣范圍內(nèi),在金幣范圍內(nèi)則觸發(fā)動(dòng)畫啟動(dòng),并設(shè)置金幣上的文字不顯示。
為 CustomPaint 添加點(diǎn)擊事件:
Widget buildRedPacket() {
return GestureDetector(
onTapUp: controller.clickGold,
child: CustomPaint(
size: Size(1.sw, 1.sh),
painter: RedPacketPainter(controller: controller),
child: buildChild(),
),
);
}看一下效果 :

紅包開啟
紅包開啟其實(shí)就是將紅包上下兩部分分別進(jìn)行向上和向下的平移,再加上背景顏色的漸變實(shí)現(xiàn),示意圖如下:

加上之前金幣的動(dòng)畫,存在多個(gè)動(dòng)畫控制器,所以需要將 _RedPacketState 修改為混入 TickerProviderStateMixin:
class _RedPacketState extends State<RedPacket> with TickerProviderStateMixin{
/// ...
}然后在 RedPacketController 添加平移動(dòng)畫控制器,并添加平移和顏色漸變動(dòng)畫,平移系數(shù)從 0 到 1, 顏色漸變從不透明到完全透明。并將平移動(dòng)畫與之前的金幣動(dòng)畫合并為 repaint。
class RedPacketController{
final TickerProviderStateMixin tickerProvider;
late AnimationController angleController;
late AnimationController translateController;
late Animation<double> translateCtrl;
late Animation<Color?> colorCtrl;
void initAnimation() {
/// ...
translateController = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: tickerProvider
);
translateCtrl = translateController.drive(Tween(begin: 0.0, end: 1.0));
colorCtrl = translateController.drive(ColorTween(
begin: Colors.redAccent,
end: const Color(0x00FF5252))
);
repaint = Listenable.merge([angleController, translateController]);
}
}再在 RedPacketPainter 的 super 里傳入 repaint:
RedPacketPainter({required this.controller}) : super(repaint:controller.repaint);改造繪制紅包上半部分代碼:
void drawTop(ui.Canvas canvas) {
canvas.save();
canvas.translate(0, topBezierEnd * ( - controller.translateCtrl.value));
/// ...
canvas.restore();
}添加畫布平移操作,平移的 Y 值為上半部分高度乘以動(dòng)畫值,即從 0 向上移動(dòng)上半部分高度。
下半部分添加同樣的處理,平移方向向下:
void drawBottom(ui.Canvas canvas) {
canvas.save();
canvas.translate(0, topBezierStart * (controller.translateCtrl.value));
/// ...
canvas.restore();
}效果如下:

背景的平移效果實(shí)現(xiàn)了,但是上面的頭像和文字沒動(dòng),接下來給頭像和文字的 Widget 添加 AnimatedBuilder 使用相同的動(dòng)畫讓其跟著移動(dòng):
Widget buildChild() {
return AnimatedBuilder(
animation: controller.translateController,
builder: (context, child) => Container(
padding: EdgeInsets.only(top: 0.3.sh * (1 - controller.translateCtrl.value)),
child: Column(...),
),
);
}通過動(dòng)態(tài)修改 paddingTop 的值,讓頭像與文字也向上平移。效果如下:

最后在金幣點(diǎn)擊事件上添加一個(gè)定時(shí)器,金幣旋轉(zhuǎn) 2 秒后執(zhí)行紅包開啟動(dòng)畫:
void clickGold(TapUpDetails details) {
if(checkClickGold(details.globalPosition)){
if(angleController.isAnimating){
stop();
}else{
angleController.repeat(reverse: true);
tickerProvider.setState(() {
showOpenText = false;
});
timer = Timer(const Duration(seconds: 2), (){
stop();
});
}
}
}
void stop() async{
if(angleController.isAnimating){
///停止金幣動(dòng)畫,讓動(dòng)畫看起來更自然
if(angleController.status == AnimationStatus.forward){
await angleController.forward();
angleController.reverse();
}else if(angleController.status == AnimationStatus.reverse){
angleController.reverse();
}
tickerProvider.setState(() {
showOpenBtn = false;
});
translateController.forward();
}
}這樣就實(shí)現(xiàn)了點(diǎn)擊金幣后,金幣旋轉(zhuǎn) 2 秒后開啟紅包。
結(jié)果彈出
結(jié)果頁(yè)是一個(gè)新的界面,在紅包開啟時(shí)同步執(zhí)行,并且擁有一個(gè)漸變動(dòng)畫,路由跳轉(zhuǎn)時(shí)添加動(dòng)畫實(shí)現(xiàn),代碼如下:
void onOpen(){
Navigator.push(
context,
PageRouteBuilder(
transitionDuration: const Duration(seconds: 1),
pageBuilder: (context, animation, secondaryAnimation) =>
FadeTransition(
opacity: animation,
child: const ResultPage(),
)
)
);
}給 RedPacket 添加一個(gè)紅包開啟時(shí)的回調(diào)和紅包動(dòng)畫完成時(shí)的回調(diào),前者用于跳轉(zhuǎn)結(jié)果頁(yè),后者用于移除 Overlay:
OverlayEntry? entry;
void showRedPacket(BuildContext context, Function? onOpen){
entry = OverlayEntry(builder: (context) => RedPacket(onFinish: _removeRedPacket, onOpen: onOpen,));
Overlay.of(context)?.insert(entry!);
}
void _removeRedPacket(){
entry?.remove();
entry = null;
}在金幣旋轉(zhuǎn)動(dòng)畫停止時(shí)調(diào)用:
void stop() async{
if(angleController.isAnimating){
/// ...
translateController.forward();
onOpen?.call();
}
}紅包動(dòng)畫完成時(shí)調(diào)用 onFinish 回調(diào), 給紅包最后的平移動(dòng)畫添加監(jiān)聽來實(shí)現(xiàn):
translateController.addStatusListener((status) {
if(status == AnimationStatus.completed){
onFinish?.call();
}
});OK,大功告成,再看看最終的效果:

以上就是Flutter利用Canvas模擬實(shí)現(xiàn)微信紅包領(lǐng)取效果的詳細(xì)內(nèi)容,更多關(guān)于Flutter Canvas微信領(lǐng)紅包的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Android組件Activity的啟動(dòng)過程深入分析
這篇文章主要介紹了Android組件Activity的啟動(dòng)過程,Activity作為Android四大組件之一,他的啟動(dòng)沒有那么簡(jiǎn)單。這里涉及到了系統(tǒng)服務(wù)進(jìn)程,啟動(dòng)過程細(xì)節(jié)很多,這里我只展示主體流程。activity的啟動(dòng)流程隨著版本的更替,代碼細(xì)節(jié)一直在進(jìn)行更改,每次都會(huì)有很大的修改2023-04-04
Android動(dòng)態(tài)修改ToolBar的Menu菜單示例
本篇文章主要介紹了Android動(dòng)態(tài)修改ToolBar的Menu菜單示例,小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2017-02-02
Kotlin中空判斷與問號(hào)和感嘆號(hào)標(biāo)識(shí)符使用方法
最近使用kotlin重構(gòu)項(xiàng)目,遇到了一個(gè)小問題,在Java中,可能會(huì)遇到判斷某個(gè)對(duì)象是否為空,為空?qǐng)?zhí)行一段邏輯,不為空?qǐng)?zhí)行另外一段邏輯,下面這篇文章主要給大家介紹了關(guān)于Kotlin中空判斷與問號(hào)和感嘆號(hào)標(biāo)識(shí)符處理操作的相關(guān)資料,需要的朋友可以參考下2022-12-12
Android 幾種屏幕間跳轉(zhuǎn)的跳轉(zhuǎn)Intent Bundle
這篇文章主要介紹了Android 幾種屏幕間跳轉(zhuǎn)的跳轉(zhuǎn)Intent Bundle,有需要的朋友可以參考一下2013-12-12
Android開發(fā)之刪除項(xiàng)目緩存的方法
詳細(xì)介紹Android-Room數(shù)據(jù)庫(kù)的使用

