效果
最终实现的整体效果如下:
实现
看完效果以后,接下来就带领大家来看看是怎样一步一步实现最终效果的,在正式动手写代码之前,先对整个效果做一个简单的拆分,将其分为五个部分:
- 点击弹出红包
- 红包整体布局
- 金币点击旋转
- 红包开启动画
- 结果页面弹出
拆分后如下图所示:
接下来就一步一步来实现。
红包弹出
红包弹出主要分为两部分:从小到大缩放动画、半透明遮罩。很自然的想到了使用 Dialog 来实现,最终也确实使用 Dialog 实现了对应的效果,但是在最后展示结果页的时候出现问题了,因为红包开启与结果展示是同时进行的,结果页在红包下面,使用 Dialog 的话会存在结果页在 Dialog 上面遮住红包的效果,最后使用了 Overlay
在顶层添加一个 Widget 来实现。
创建一个 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,
)
);
}
}
内容很简单,就是一个居中的宽高分别为 0.8.sw
、1.2.sw
的 Container,颜色为红色。这里 sw
是代表屏幕宽度,即红包宽度为屏幕宽度的 0.8 倍,高度为屏幕宽度的 1.2 倍。
然后点击按钮时通过 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);
@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
设置动画从 0.0 到 1.0 即从无到原本大小,动画时间为 500 毫秒;同时在外层再包裹一层 Container 并为其添加半透明颜色实现半透明遮罩,最终实现效果:
这样就实现了第一部分的功能。
红包布局
标题说了是使用 Canvas 来实现,所以红包布局主要是使用 Canvas 来实现,将前面红包的 Container 换成 CustomPaint
, 然后创建 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(),
),
)
考虑到后续动画,这里将画布的大小设置为全屏。红包布局的核心代码就在 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;
/// 红包在整个界面的left
late double left = 0.1.sw;
/// 红包在整个界面的right
late double right = 0.9.sw;
/// 红包在整个界面的top
late double top = (1.sh - height)/2;
/// 红包在整个界面的bottom
late 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