Flutter利用Canvas模拟实现微信红包领取效果
目录
- 前言
- 效果
- 红包弹出
- 红包布局
- 上半部分
- 下半部分
- 金币绘制
- 金币文字绘制
- 头像和文字
- 金币旋转
- 红包开启
- 结果弹出
前言 前面写了一篇Flutter利用Canvas绘制精美表盘效果详解的文章,对 Flutter 中的 Canvas 使用有了进一步的理解,就想着再用 Canvas 实现一个什么样的效果来加深一下对 Canvas 使用的理解,这个时候正好看到群里有人发红包,于是就想着能不能在 Flutter 中使用 Canvas 实现微信领取红包的效果?想到就做,知行合一,经过几天空余时间的研究,最终实现了微信领取红包效果,于是有了这篇文章。
效果 最终实现的整体效果如下:
文章图片
看完效果以后,接下来就带领大家来看看是怎样一步一步实现最终效果的,在正式动手写代码之前,先对整个效果做一个简单的拆分,将其分为五个部分:
- 点击弹出红包
- 红包整体布局
- 金币点击旋转
- 红包开启动画
- 结果页面弹出
文章图片
接下来就一步一步来实现。
红包弹出 红包弹出主要分为两部分:从小到大缩放动画、半透明遮罩。很自然的想到了使用 Dialog 来实现,最终也确实使用 Dialog 实现了对应的效果,但是在最后展示结果页的时候出现问题了,因为红包开启与结果展示是同时进行的,结果页在红包下面,使用 Dialog 的话会存在结果页在 Dialog 上面遮住红包的效果,最后使用了
Overlay
在顶层添加一个 Widget 来实现。创建一个
RedPacket
的 Widget:class RedPacket extends StatelessWidget {const RedPacket({Key? key}) : super(key: key); @overrideWidget build(BuildContext context) {return Center(child: Container(width: 0.8.sw,height: 1.2.sw,color: Colors.redAccent,)); }}
内容很简单,就是一个居中的宽高分别为
0.8.sw
、1.2.sw
的 Container,颜色为红色。这里 sw
是代表屏幕宽度,即红包宽度为屏幕宽度的 0.8 倍,高度为屏幕宽度的 1.2 倍。关于 Flutter 屏幕适配,请参阅:Flutter应用框架搭建之屏幕适配详解
然后点击按钮时通过
Overlay
展示出来, 创建一个 showRedPacket
的方法:void showRedPacket(BuildContext context){OverlayEntry entry = OverlayEntry(builder: (context) => RedPacket()); Overlay.of(context)?.insert(entry); }
效果如下:
文章图片
红包是弹出来了,但因为没有缩放动画,很突兀。为了实现缩放动画,在 Container 上包裹
ScaleTransition
用于缩放动画,同时将 RedPacket
改为 StatefulWidget
,因为使用动画需要用到 AnimationController
传入 SingleTickerProviderStateMixin
,实现如下:class RedPacket extends StatefulWidget {const RedPacket({Key? key}) : super(key: key); @overrideStatecreateState() => _RedPacketState(); }class _RedPacketState extends State with SingleTickerProviderStateMixin {late AnimationController scaleController = AnimationController(vsync: this)..duration = const Duration(milliseconds: 500)..forward(); @overrideWidget build(BuildContext context) {return Container(color: Color(0x88000000), /// 半透明遮罩child: Center(child: ScaleTransition(scale: Tween (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
设置动画从 0.0 到 1.0 即从无到原本大小,动画时间为 500 毫秒;同时在外层再包裹一层 Container 并为其添加半透明颜色实现半透明遮罩,最终实现效果:文章图片
这样就实现了第一部分的功能。
红包布局 标题说了是使用 Canvas 来实现,所以红包布局主要是使用 Canvas 来实现,将前面红包的 Container 换成
CustomPaint
, 然后创建 RedPacketPainter
继承自 CustomPainter
:ScaleTransition(scale: Tween(begin: 0.0, end: 1.0).animate(CurvedAnimation(parent: scaleController, curve: Curves.fastOutSlowIn)),child: CustomPaint(size: Size(1.sw, 1.sh),painter: RedPacketPainter(),),)
考虑到后续动画,这里将画布的大小设置为全屏。红包布局的核心代码就在
RedPacketPainter
里,首先绘制红包的背景,背景分为上下两部分,上部分又由一个矩形和一个圆弧组成,下半部分同样是由一个矩形和一个圆弧组成,上半部分的圆弧是凸出来的,而下半部分的是凹进去的,示意图如下:文章图片
初始化:
/// 画笔 late final Paint _paint = Paint()..isAntiAlias = true; /// 路径final Path path = Path(); /// 红包的高度:1.2倍的屏幕宽度late double height = 1.2.sw; /// 上半部分贝塞尔曲线的结束点late double topBezierEnd = (1.sh - height)/2 + height/8*7; /// 上半部分贝塞尔曲线的起点late double topBezierStart= topBezierEnd - 0.2.sw; /// 下半部分贝塞尔曲线的起点late double bottomBezierStart = topBezierEnd - 0.4.sw; /// 金币中心点,后续通过path计算Offset goldCenter = Offset.zero; /// 横向的中心点final double centerWidth = 0.5.sw; /// 红包在整个界面的leftlate double left = 0.1.sw; /// 红包在整个界面的rightlate double right = 0.9.sw; /// 红包在整个界面的toplate double top = (1.sh - height)/2; /// 红包在整个界面的bottomlate double bottom = (1.sh - height)/2 + height;
上半部分
代码实现如下:
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
来进行绘制,首先向路径中添加一个圆角矩形,也就是示意图中的第①部分,然后通过 getTopBezierPath
获取一个贝塞尔曲线的 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 源码分为两部分,第一部分是创建贝塞尔曲线的 path ,使用的是最开始初始化的数据创建,实现示意图中的第②部分内容;然后根据创建好的贝塞尔曲线的 path 计算出路径中中间点的坐标,作为金币中心点坐标。示意图如下:
文章图片
图中红点就是贝塞尔曲线的点,中间实线就是贝塞尔曲线,也就是上面代码中创建的贝塞尔曲线路径,实线中间的点就是金币位置的中心点。
贝塞尔曲线绘制完成后调用
drawShadow
绘制阴影,作用是突出上下两部分连接处的效果,最后通过 path 绘制出整个上半部分的效果,如下:文章图片
下半部分
代码实现如下:
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); }
下半部分实现同样分为两部分,首先绘制出贝塞尔曲线,即示意图第③部分,然后再添加一个圆角矩形,即示意图第④部分;然后绘制下半部分的阴影和图形,单独展示下半部分效果如下:
文章图片
将上下两部分结合起来,就实现了红包背景的效果,如下:
文章图片
金币绘制
背景绘制完成后接下来进行金币的绘制,前面已计算出金币的中心点坐标,静态金币的绘制就相对来说比较简单了,就是一个圆形,代码如下:
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(); }
这里将画布移动到到金币的中心点,然后向 Path 中添加添加一个半径为 40.w 的圆,最后将 path 绘制出来即可。效果如下:
文章图片
金币文字绘制
金币绘制出来后,还需在金币上绘制一个繁体的 "開" 字,代码如下:
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 进行文字的绘制,同样是将画布移动到金币的中心,然后绘制文字,效果如下:
文章图片
头像和文字
经过上面的绘制,效果已经出来了,但是还差红包封面上的用户头像相关文字,使用 Canvas 同样能实现,但这里并没有使用 Canvas 来实现,而是使用
CoustomPaint
的 child 来实现: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发出的红包", style: TextStyle(fontSize: 16.sp, color: Color(0xFFF8E7CB), fontWeight: FontWeight.w500),)],),SizedBox(height: 15.w,),Text("恭喜发财", style: TextStyle(fontSize: 18.sp, color: Color(0xFFF8E7CB)),)],),); }
CoustomPaint
的 child 允许传入一个 Widget,Widget 的实现就是要显示的头像和文字,代码如上,最终效果如下:文章图片
至此红包的整个布局的实现就完成了。
金币旋转 前面完成了红包的静态显示,接下来就看看怎么让红包动起来,首先看看怎么让金币旋转起来。
说到旋转首先想到的就是以金币的中心旋转,可以通过旋转画布的旋转或者 path 的
transform
旋转来实现,但是经过实验使用这种方式能让金币旋转起来,但是做到旋转的立体效果却很复杂。所以最终采用的是使用两个圆在 x 轴上进行一定的偏移,然后压缩圆的宽度来模拟实现旋转效果,示意图如下:文章图片
如图所示,绘制两个相同的圆,开始时将两个圆重叠在一起,然后同时压缩圆的宽度并将下层的圆向左偏移一定单位,就形成了旋转的立体效果。
代码:
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(); }
修改上面绘制金币的代码,设置不同的颜色再绘制一个圆,这样就在同一个位置绘制了两个不同颜色的圆。那么怎么让它动起来呢?可以使用动画,通过动画执行宽度的缩放,是宽度系数从 1 缩放到 0 再从 0 回到 1。因为
CustomPainter
是继承自 Listenable
,而动画也是 Listenable
所以直接将动画与 CustomPainter
结合起来使用更方便。为了方便统一控制红包的动画,创建一个
RedPacketController
,并在里面创建一个控制金币旋转的动画及控制器:class RedPacketController{final SingleTickerProviderStateMixin tickerProvider; late AnimationController angleController; late AnimationangleCtrl; 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(); }}
为了看到旋转的效果,将动画执行时间设置为 3 秒,并且让其重复执行,重复执行时设置 reverse 为 true,即反向执行,然后改造 _RedPacketState 混入 SingleTickerProviderStateMixin 并创建 RedPacketController :
class _RedPacketState extends Statewith 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 的构造方法调用了 super 并传入了 repaint 参数,即创建的动画控制器。
这样就能在绘制金币的时候使用动画的值了:
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
获取当前动画的值,然后在圆的 left 和 right 参数上乘以这个值,看一下效果:文章图片
效果已经有了,但是发现在旋转到最小的时候中间是空的,这不符合我们的预期,那怎么办呢?将两个圆的边一一连接起来是不是中间就不空了,如图所示:
文章图片
代码实现:
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
使用 Path.computeMetrics 计算两个 path 的路径点,循环路径每一个点,将两个 path 的每一个点连接起来然后绘制出来,再来看一下效果:
文章图片
效果好多了,但是仔细观察发现还是有一个问题,金币看着不是旋转的而是左右摇摆的,这是因为实现的立体的效果一直在一边导致的,需要根据旋转的时机将立体效果的方向切换,从 1 到 0 时在右边,从 0 到 1 时在左边,通过动画的状态进行判断,修改代码如下:
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));
再来看一下效果:
文章图片
这样旋转效果就很完美了,金币中间还缺一个空心的矩形,实现很简单,在圆的 path 路径中叠加一个矩形即可:
path.addRect(Rect.fromLTRB(-10.w * angle , -10.w, 10.w * angle , 10.w)); path.fillType = PathFillType.evenOdd;
设置fillType
为evenOdd
,这样就形成了中心空心的效果,并且由于上面连接两个圆的路径点,这个空心也自带了立体效果,如图:
文章图片
最后为金币添加点击事件,点击时开启旋转,并隐藏金币上的文字。点击事件可以直接给 CustomPaint 包裹一个 GestureDetector ,点击时判断点击坐标是否在金币的绘制范围内,可以使用Path.contains
进行判断,所以需要保存金币的 path 用于点击判断,这里将其保存到 controller 里:
controller.goldPath = path.shift(Offset(0.5.sw, goldCenter.dy));
然后在 RedPacketController 里定义对应的变量和方法:
class RedPacketController{Path? goldPath; bool showOpenText = true; bool checkClickGold(Offset point){returngoldPath?.contains(point) == true; }void clickGold(TapUpDetails details) {if(checkClickGold(details.globalPosition)){angleController.repeat(reverse: true); tickerProvider.setState(() {showOpenText = false; }); }} /// ...}
goldPath 用于保存金币的 path,showOpenText 用于是否显示金币上的文字,点击时判断事件触发点是否在金币范围内,在金币范围内则触发动画启动,并设置金币上的文字不显示。
为 CustomPaint 添加点击事件:
Widget buildRedPacket() {return GestureDetector(onTapUp: controller.clickGold,child: CustomPaint(size: Size(1.sw, 1.sh),painter: RedPacketPainter(controller: controller),child: buildChild(),),); }
【Flutter利用Canvas模拟实现微信红包领取效果】看一下效果 :
文章图片
红包开启 红包开启其实就是将红包上下两部分分别进行向上和向下的平移,再加上背景颜色的渐变实现,示意图如下:
文章图片
加上之前金币的动画,存在多个动画控制器,所以需要将 _RedPacketState 修改为混入 TickerProviderStateMixin:
class _RedPacketState extends Statewith TickerProviderStateMixin{/// ...}
然后在 RedPacketController 添加平移动画控制器,并添加平移和颜色渐变动画,平移系数从 0 到 1, 颜色渐变从不透明到完全透明。并将平移动画与之前的金币动画合并为repaint
。
class RedPacketController{final TickerProviderStateMixin tickerProvider; late AnimationController angleController; late AnimationController translateController; late AnimationtranslateCtrl; late Animation 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 值为上半部分高度乘以动画值,即从 0 向上移动上半部分高度。
下半部分添加同样的处理,平移方向向下:
void drawBottom(ui.Canvas canvas) {canvas.save(); canvas.translate(0, topBezierStart * (controller.translateCtrl.value)); /// ...canvas.restore(); }
效果如下:
文章图片
背景的平移效果实现了,但是上面的头像和文字没动,接下来给头像和文字的 Widget 添加AnimatedBuilder
使用相同的动画让其跟着移动:
Widget buildChild() {return AnimatedBuilder(animation: controller.translateController,builder: (context, child) => Container(padding: EdgeInsets.only(top: 0.3.sh * (1 - controller.translateCtrl.value)),child: Column(...),),); }
通过动态修改 paddingTop 的值,让头像与文字也向上平移。效果如下:
文章图片
最后在金币点击事件上添加一个定时器,金币旋转 2 秒后执行红包开启动画:
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){///停止金币动画,让动画看起来更自然if(angleController.status == AnimationStatus.forward){await angleController.forward(); angleController.reverse(); }else if(angleController.status == AnimationStatus.reverse){angleController.reverse(); }tickerProvider.setState(() {showOpenBtn = false; }); translateController.forward(); }}
这样就实现了点击金币后,金币旋转 2 秒后开启红包。
结果弹出 结果页是一个新的界面,在红包开启时同步执行,并且拥有一个渐变动画,路由跳转时添加动画实现,代码如下:
void onOpen(){Navigator.push(context,PageRouteBuilder(transitionDuration: const Duration(seconds: 1),pageBuilder: (context, animation, secondaryAnimation) =>FadeTransition(opacity: animation,child: const ResultPage(),))); }
给RedPacket
添加一个红包开启时的回调和红包动画完成时的回调,前者用于跳转结果页,后者用于移除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; }
在金币旋转动画停止时调用:
void stop() async{if(angleController.isAnimating){/// ...translateController.forward(); onOpen?.call(); }}
红包动画完成时调用 onFinish 回调, 给红包最后的平移动画添加监听来实现:
translateController.addStatusListener((status) {if(status == AnimationStatus.completed){onFinish?.call(); }});
OK,大功告成,再看看最终的效果:
文章图片
以上就是Flutter利用Canvas模拟实现微信红包领取效果的详细内容,更多关于Flutter Canvas微信领红包的资料请关注脚本之家其它相关文章!
推荐阅读
- Flutter应用框架搭建之屏幕适配详解
- Flutter利用Canvas绘制精美表盘效果详解
- Flutter实现文本滚动高亮效果的示例讲解
- Unity利用XML制作一个简易的登录系统
- 数据获取|2020年山东省土地利用数据(矢量)
- 土地利用数据|辽宁省30m精度土地利用数据下载
- 前端|Flutter - GetX状态管理
- 利用简单方法解决外部系统回调测试环境,多套环境便捷切换
- linux下利用nohup后台运行jar文件包程序
- transformer|利用swin-transformer训练自己的数据集