背景
最近团队正在开发一个小程序一番赏平台的项目,在开发过程中,需要很多动画效果的场景,综合考虑了动画渲染的性能效果,因此采用 CSS3+transform
动画和 canvas
来实现
1、canvas
启动按钮
需求
按下充能 按钮后,表盘上的能量值开始自动叠加充能 ,并且中间切换为开启 按钮,当充能到一定比例显示对应的颜色 ,然后充满后就反向释放能量(反向绘制)
效果如下
思路分解
我们看到这个启动按钮拥有进度、色块,底图 这三部分组成,首先色块和进度是动态的,那必然得用 canvas
来绘制;底图也是动态的,可以用canvas
的createImage
api 动态插入图片来实现。
1.1 绘制圆图和进度
这一步是最核心也是相对复杂的技术点
具体过程我们拆分成下面几个步骤:
1、初始化画布 2、绘制图片 3、绘制进度 4、绘制充能
对应的代码如下:
1、初始化画布
js
/**
* 初始化画布
* @param res
*/
initCanvas(res: any) {
const { width, height, node: canvas } = res[0];
const ctx: WechatMiniprogram.CanvasContext = canvas.getContext('2d');
// 必须加此段代码否则canvas会拉伸
const dpr = wx.getSystemInfoSync().pixelRatio;
canvas.width = width * dpr;
canvas.height = height * dpr;
ctx.scale(dpr, dpr);
// 初始化是充能状态,中间显示充能按钮
this.renderImage(ctx, canvas, OpenBtnBgUrl.Charge);
}
init() {
this.createSelectorQuery()
.select('#start-button')
.fields({
node: true,
size: true,
})
.exec((res) => {
this.initCanvas(res);
});
}
在小程序的在件实例进入页面节点树(attached
)时,执行初始化画布;在组件实例被从页面节点树移除时(detached
)执行取消动画帧
js
lifetimes: {
attached() {
this.init();
},
detached() {
this.data.canvas.cancelAnimationFrame?.(this.data.canvasId);
this.data.isRunning = false;
},
}
2、绘制底图
js
/**
* 绘制图片
* @param ctx canvas上下文对象
* @param canvas canvas对象
* @param imgSrc 要绘制的图片地址
*/
renderImage(
ctx: WechatMiniprogram.CanvasContext,
canvas: WechatMiniprogram.Canvas,
imgSrc = ''
) {
const img = canvas.createImage();
img.src = imgSrc;
img.onload = () => {
// 必须在图片加载完成进行渲染
this.render(ctx, 0);
};
this.data.ctx = ctx;
this.data.canvas = canvas;
this.data.img = img;
}
3、绘制进度
js
/**
* 画布绘制进度
* @param ctx 画布上下文对象
* @param currentStep 当前绘制的刻度值
*/
render(ctx: WechatMiniprogram.CanvasContext, currentStep: number) {
const { width, height } = this.data;
ctx.clearRect(0, 0, width, height);
this.drawBackground(ctx);
// 将坐标原点移动到画布中间
ctx.translate(width / 2, height / 2);
// 绘制进度
this.drawSteps(ctx, currentStep);
// 将坐标原点还原
ctx.translate(-width / 2, -height / 2);
}
/**
* 绘制背景
*/
drawBackground(ctx: WechatMiniprogram.CanvasContext) {
const { width, height } = this.data;
ctx.drawImage(this.data.img as any, 0, 0, width, height);
}
/**
* 绘制进度
*/
drawSteps(ctx: WechatMiniprogram.CanvasContext, currentStep = 0) {
const { defaultColor, radius, activeColor, stepWidth, stepMargin, stepHeight, stepCount } = this.data;
ctx.save();
for (let i = 0; i < stepCount * 2; i++) {
ctx.beginPath();
ctx.lineWidth = stepWidth / 2;
if (i < currentStep) {
// 绿:[0, 1 / 2];黄:(1 / 2, 2 / 3];橙:(2 / 3, 5 / 6];红:(5 / 6, 1]
ctx.strokeStyle = this.getDrawColor(i, activeColor as StrokeColor);
} else {
ctx.strokeStyle = defaultColor;
}
if (i % 2 === 0) {
ctx.moveTo(-(radius - stepMargin), 0);
ctx.lineTo(-(radius - stepMargin - stepHeight), 0);
ctx.rotate(((360 / stepCount) * Math.PI) / 180);
} else {
ctx.moveTo(-(radius - stepMargin), stepWidth / 2);
ctx.lineTo(-(radius - stepMargin - stepHeight), stepWidth / 2);
}
ctx.stroke();
}
},
4、绘制充能
绘制过程使用小程序提供的Canvas.requestAnimationFrame(function callback)
进行帧渲染, 在下次进行重绘时执行。
4.1 绘制圆
js
/**
* 启动按钮绘制方向
*/
enum DIRECTION {
/** 顺时针 */
Positive = 1,
/** 逆时针 */
Opposite = -1,
}
/**
* 启动按钮背景图片
*/
enum OpenBtnBgUrl {
/** 充能按钮图片 */
Charge = 'https:xxx/open-btn-bg-charge.png',
/** 开启按钮图片 */
Start = 'https:xxx/open-btn-bg-start.png',
}
/**
* 递归绘制
*/
renderLoop() {
const { ctx, stepCount, isRunning } = this.data;
let { lastStep } = this.data;
// 方向 1: Positive(顺时针) -1:Opposite(逆时针)
let direction =
(this.data.renderLoopCount & 1) === 0 ? DIRECTION.Positive : DIRECTION.Opposite;
// 是否已经画满了
const isRenderFulled = direction > 0 ? lastStep >= stepCount * 2 : lastStep <= 0;
if (isRenderFulled) {
this.data.renderLoopCount += 1;
direction = (this.data.renderLoopCount & 1) === 0 ? DIRECTION.Positive : DIRECTION.Opposite;
lastStep = direction > 0 ? 0 : stepCount * 2;
}
this.data.canvasId = this.data.canvas.requestAnimationFrame(() => {
this.renderLoop();
});
const nextLastStep = direction > 0 ? lastStep + 1 : lastStep - 1;
this.render(ctx, nextLastStep);
this.data.lastStep = nextLastStep;
},
4.2 绘制色块
当前的进度在不同的比例区间,绘制的颜色有所区别:
[0, 1 / 2]
:绿色(1 / 2, 2 / 3]
: 黄色(2 / 3, 5 / 6]
: 橙色(5 / 6, 1]
: 红色
js
/**
* 绘制颜色
*/
getDrawColor(value: number, defaultColor = StrokeColor.Color2) {
let drawColor: StrokeColor = defaultColor;
if (0 <= value && value <= 30) drawColor = StrokeColor.Color1;
else if (30 < value && value <= 40) drawColor = StrokeColor.Color2;
else if (40 < value && value <= 50) drawColor = StrokeColor.Color3;
else if (50 < value && value <= 60) drawColor = StrokeColor.Color4;
return drawColor;
}
/**
* 绘制齿轮
*/
drawSteps(ctx: WechatMiniprogram.CanvasContext, currentStep = 0) {
const { defaultColor, radius, activeColor, stepWidth, stepMargin, stepHeight, stepCount } =
this.data;
ctx.save();
for (let i = 0; i < stepCount * 2; i++) {
ctx.beginPath();
ctx.lineWidth = stepWidth / 2;
if (i < currentStep) {
// 绿:[0, 1 / 2];黄:(1 / 2, 2 / 3];橙:(2 / 3, 5 / 6];红:(5 / 6, 1]
ctx.strokeStyle = this.getDrawColor(i, activeColor as StrokeColor);
} else {
ctx.strokeStyle = defaultColor;
}
if (i % 2 === 0) {
ctx.moveTo(-(radius - stepMargin), 0);
ctx.lineTo(-(radius - stepMargin - stepHeight), 0);
ctx.rotate(((360 / stepCount) * Math.PI) / 180);
} else {
ctx.moveTo(-(radius - stepMargin), stepWidth / 2);
ctx.lineTo(-(radius - stepMargin - stepHeight), stepWidth / 2);
}
ctx.stroke();
}
},
4.3 触发绘制
js
/**
* 触摸开始方法
* @param {WechatMiniprogram.TouchEvent} event 触摸事件对象
*/
handleTouchStart(event: WechatMiniprogram.TouchEvent) {
this.data.isRunning = !this.data.isRunning;
if (!this.data.isRunning) return;
this.triggerEvent('start');
const { width, height, radius } = this.data;
const { x } = event.touches[0] as any;
const { y } = event.touches[0] as any;
const translateX = x - width / 2;
const translateY = y - height / 2;
const touchCircleX = Math.sqrt(translateX * translateX + translateY * translateY);
if (touchCircleX <= radius) {
this.data.lastStep = 0;
this.renderLoop();
}
}
1.4 所有代码
模板 wxml
html
<canvas
type="2d"
id="start-button"
style="width: {{ width }}px; height: {{ height }}px"
bindtouchend="handleTouchEnd"
bindtouchstart="handleTouchStart"
></canvas>
逻辑js
js
import { DIRECTION, OpenBtnBgUrl, StrokeColor } from './model';
Component({
data: {
isRunning: false,
canvasId: 0,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvas: {} as WechatMiniprogram.Canvas,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
ctx: {} as WechatMiniprogram.CanvasContext,
img: {},
lastStep: 0,
renderLoopCount: 0, // 绘制了几圈
},
properties: {
width: {
type: Number,
value: 140,
},
height: {
type: Number,
value: 140,
},
radius: {
type: Number,
value: 70,
},
startAngle: {
type: Number,
value: -Math.PI / 2,
},
stepCount: {
type: Number,
value: 30,
},
stepWidth: {
type: Number,
value: 8,
},
stepHeight: {
type: Number,
value: 4,
},
stepMargin: {
type: Number,
value: 10,
},
defaultColor: {
type: String,
value: '#658491',
},
activeColor: {
type: String,
value: StrokeColor.Color2,
},
},
lifetimes: {
attached() {
this.init();
},
detached() {
this.data.canvas.cancelAnimationFrame?.(this.data.canvasId);
this.data.isRunning = false;
},
},
methods: {
init() {
this.createSelectorQuery()
.select('#start-button')
.fields({
node: true,
size: true,
})
.exec((res) => {
this.initCanvas(res);
});
},
/**
* 绘制图片
* @param ctx canvas上下文对象
* @param canvas canvas对象
* @param imgSrc 要绘制的图片地址
*/
renderImage(ctx: WechatMiniprogram.CanvasContext, canvas: WechatMiniprogram.Canvas, imgSrc = '') {
const img = canvas.createImage();
img.src = imgSrc;
img.onload = () => {
// 必须在图片加载完成进行渲染
this.render(ctx, 0);
};
this.data.ctx = ctx;
this.data.canvas = canvas;
this.data.img = img;
},
/**
* 初始化画布
* @param res
*/
initCanvas(res: any) {
const { width, height, node: canvas } = res[0];
const ctx: WechatMiniprogram.CanvasContext = canvas.getContext('2d');
// 必须加此段代码否则canvas会拉伸
const dpr = wx.getSystemInfoSync().pixelRatio;
canvas.width = width * dpr;
canvas.height = height * dpr;
ctx.scale(dpr, dpr);
// 初始化是充能状态,中间显示充能按钮
this.renderImage(ctx, canvas, OpenBtnBgUrl.Charge);
},
/**
* 画布绘制步骤条
* @param ctx 画布上下文对象
* @param currentStep 当前绘制的刻度值
*/
render(ctx: WechatMiniprogram.CanvasContext, currentStep: number) {
const { width, height } = this.data;
ctx.clearRect(0, 0, width, height);
this.drawBackground(ctx);
// 将坐标原点移动到画布中间
ctx.translate(width / 2, height / 2);
// 绘制进度
this.drawSteps(ctx, currentStep);
// 将坐标原点还原
ctx.translate(-width / 2, -height / 2);
},
/**
* 递归绘制
*/
renderLoop() {
const { ctx, stepCount, isRunning } = this.data;
let { lastStep } = this.data;
// 方向 1: Positive(顺时针) -1:Opposite(逆时针)
let direction =
(this.data.renderLoopCount & 1) === 0 ? DIRECTION.Positive : DIRECTION.Opposite;
// 是否已经画满了
const isRenderFulled = direction > 0 ? lastStep >= stepCount * 2 : lastStep <= 0;
if (isRenderFulled) {
this.data.renderLoopCount += 1;
direction = (this.data.renderLoopCount & 1) === 0 ? DIRECTION.Positive : DIRECTION.Opposite;
lastStep = direction > 0 ? 0 : stepCount * 2;
}
this.data.canvasId = this.data.canvas.requestAnimationFrame(() => {
this.renderLoop();
});
const nextLastStep = direction > 0 ? lastStep + 1 : lastStep - 1;
this.render(ctx, nextLastStep);
this.data.lastStep = nextLastStep;
this.triggerEvent('change', { lastStep: nextLastStep * 5, isRunning });
},
/**
* 绘制背景
*/
drawBackground(ctx: WechatMiniprogram.CanvasContext) {
const { width, height } = this.data;
ctx.drawImage(this.data.img as any, 0, 0, width, height);
},
/**
* 绘制颜色
*/
getDrawColor(value: number, defaultColor = StrokeColor.Color2) {
let drawColor: StrokeColor = defaultColor;
if (0 <= value && value <= 30) drawColor = StrokeColor.Color1;
else if (30 < value && value <= 40) drawColor = StrokeColor.Color2;
else if (40 < value && value <= 50) drawColor = StrokeColor.Color3;
else if (50 < value && value <= 60) drawColor = StrokeColor.Color4;
return drawColor;
},
/**
* 绘制齿轮
*/
drawSteps(ctx: WechatMiniprogram.CanvasContext, currentStep = 0) {
const { defaultColor, radius, activeColor, stepWidth, stepMargin, stepHeight, stepCount } =
this.data;
ctx.save();
for (let i = 0; i < stepCount * 2; i++) {
ctx.beginPath();
ctx.lineWidth = stepWidth / 2;
if (i < currentStep) {
// 绿:[0, 1 / 2];黄:(1 / 2, 2 / 3];橙:(2 / 3, 5 / 6];红:(5 / 6, 1]
ctx.strokeStyle = this.getDrawColor(i, activeColor as StrokeColor);
} else {
ctx.strokeStyle = defaultColor;
}
if (i % 2 === 0) {
ctx.moveTo(-(radius - stepMargin), 0);
ctx.lineTo(-(radius - stepMargin - stepHeight), 0);
ctx.rotate(((360 / stepCount) * Math.PI) / 180);
} else {
ctx.moveTo(-(radius - stepMargin), stepWidth / 2);
ctx.lineTo(-(radius - stepMargin - stepHeight), stepWidth / 2);
}
ctx.stroke();
}
},
/**
* 触摸开始方法
* @param {WechatMiniprogram.TouchEvent} event 触摸事件对象
*/
handleTouchStart(event: WechatMiniprogram.TouchEvent) {
this.data.isRunning = !this.data.isRunning;
if (!this.data.isRunning) return;
this.triggerEvent('start');
const { width, height, radius } = this.data;
const { x } = event.touches[0] as any;
const { y } = event.touches[0] as any;
const translateX = x - width / 2;
const translateY = y - height / 2;
const touchCircleX = Math.sqrt(translateX * translateX + translateY * translateY);
if (touchCircleX <= radius) {
this.data.lastStep = 0;
this.renderLoop();
}
},
/**
* 触摸结束方法
*/
handleTouchEnd() {
const { stepCount, lastStep, ctx, canvas, isRunning = false } = this.data;
if (!isRunning) {
this.triggerEvent('end', { lastStep: lastStep * 5, isRunning });
return;
};
// 点击后变为开启状态,中间显示开启功能的按钮
this.renderImage(ctx, canvas, OpenBtnBgUrl.Start);
if (lastStep < stepCount * 2) {
this.render(ctx, 0);
} else {
this.render(ctx, stepCount);
}
},
},
});
1.5 使用方式
html
<start-button
id="start"
bind:end="onTouchEnd"
bind:change="onTouchChange"
></start-button>
2、舞台灯光效果
Css3 Animation + 图片序列帧动画实现
主要保留包括以下三部分:
- 光片上飘(css3 动画,下面详细说明)
- 星石漂浮(css3 动画)
- 光效旋转(图片序列帧动画)
2.1 效果如下
2.2 模板 wxml
html
<!-- 光筒圆柱 -->
<view class="cylinder">
<!-- 光片上飘区域 -->
<view class="cylinder-bubble">
<block wx:if="{{ isRunning }}">
<block wx:for="{{ lightPieceTotal }}" wx:key="index">
<view class="cylinder-bubble-item"></view>
</block>
</block>
</view>
<!-- 中间星石 -->
<view class="cylinder-diamond {{ lightImgLoaded ? 'cylinder-diamond-animate' : ''}}"></view>
<!-- 光效旋转区域 -->
<view class="cylinder-light">
<view class="cylinder-light-animate {{ lightImgLoaded ? 'cylinder-light-show' : ''}}"></view>
</view>
</view>
2.3 样式 css
scss
// 开启硬件加速
@mixin hardwareAcceleration {
opacity: 1;
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
perspective: 1000;
transform-style: preserve-3d;
}
.cylinder {
position: relative;
top: 0;
width: 100%;
height: 70%;
max-height: 1150rpx;
// 开启硬件加速
@include hardwareAcceleration;
// 光片上飘区域
&-bubble {
position: absolute;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
&-item {
position: absolute;
top: 110%;
left: 0;
z-index: 100;
background-color: $lightColor;
animation-timing-function: linear;
animation-iteration-count: infinite;
transform-style: preserve-3d;
@for $i from 1 through $count {
@include bubblePieceStyle(16rpx, 16rpx, $i, 150, 550, 4000, 2000, 85%, 26%);
}
}
}
// 中间星石
&-diamond {
position: absolute;
top: 50%;
left: 50%;
z-index: 12;
width: 364rpx;
height: 371rpx;
background: transparent
url(https://ip-sph-static-1256936115.file.myqcloud.com/kuji-animation/kuji-cylinder-diamond.png?imageView2/2/q/60)
no-repeat center / cover;
border-radius: 50%;
opacity: 0;
transform: translate3d(-50%, -36%, 0);
&-animate {
opacity: 1;
transform: translate3d(-50%, -36%, 0);
animation: move-frames 5s linear infinite;
animation-delay: 1.5s;
}
}
// 光效旋转区域
&-light {
$imgCount: 65;
position: absolute;
z-index: 10;
width: 100%;
height: 100%;
background-image: url(https://ip-sph-static-1256936115.file.myqcloud.com/kuji-animation/light-cylinder/light-cylinder-default.png?imageView2/2/q/60);
background-repeat: no-repeat;
background-position: top center;
background-size: cover;
&-animate {
width: 100%;
height: 100%;
visibility: hidden;
background: inherit;
background-image: url(https://ip-sph-static-1256936115.file.myqcloud.com/kuji-animation/light-cylinder/light-cylinder-long-65.png);
background-position: -750rpx top;
background-size: #{750 * $imgCount}rpx 1094rpx;
transform: translate(0, -7%);
animation: lightAnimation 4s infinite;
animation-timing-function: steps(1, end);
will-change: background-position;
@keyframes lightAnimation {
@for $i from 1 through $imgCount {
#{math.div($i * 100%, $imgCount)} {
background-position: -#{$i * 750}rpx top;
}
}
}
}
&-show {
visibility: visible;
}
}
}
3、光片上飘效果
css3 动画实现, 复杂的点在于一些动态值的计算,比如随机位置,随机的动画持续时间和延迟时间,随机的倾斜度和旋转度,并且限制光片上升的区域
3.1 效果如下
3.2 模板 wxml
wxml
<view class="cylinder-bubble">
<block wx:if="{{ isRunning }}">
<block wx:for="{{ lightPieceTotal }}" wx:key="index">
<view class="cylinder-bubble-item"></view>
</block>
</block>
</view>
3.3 样式 css
scss
@mixin bubblePieceStyle(
$w: 20rpx,
$h: 20rpx,
$index: 1,
// 光片距离左边最小值
$minLeft: 150,
// 光片距离左边最大值
$maxLeft: 550,
// 持续时间基数
$baseDuration: 4000,
// 持续时间系数
$ratioDuration: 2000
) {
$left: #{max($minLeft, min(100 + $index * 100 * random(), $maxLeft))}rpx;
$minDelay: 100; // 最小延迟时间
$maxDelay: 1000; // 最大延迟时间
$ratioDelay: 300; // 延迟时间基数
&:nth-child(#{$index}) {
left: $left;
z-index: if(random() > 0.5, 11, 9);
width: $w;
height: $h;
box-shadow: 0 0 5rpx 3rpx $lightColor;
// 随机偏转
transform: scale3d(min(0.8, max(0.3, $index * random())), 0.8, 1) skew(
#{max(-70, -20 * $index * random())}deg,
#{min(70, 20 * $index * random())}deg
)
rotateY(#{min(180, 20 * $index * random())}deg) translate(50%, 50%);
animation-name: move-frames-#{$index};
animation-duration: #{$baseDuration + $ratioDuration * random()}ms;
animation-delay: #{floor(
random() * ($maxDelay - $minDelay + 1) + $minDelay
)}ms;
.bubble-item {
animation-delay: #{$ratioDelay * random()}ms;
}
}
@keyframes move-frames-#{$index} {
0% {
top: 95%;
opacity: 1;
transform: scale3d(0.5, 0.5, 0.5) skew(
#{max(-70, -20 * $index * random())}deg,
#{min(70, 20 * $index * random())}deg
)
rotateY(#{min(180, 20 * $index * random())}deg) translate(50%, 50%);
}
50% {
opacity: 0.8;
transform: scale3d(0.3, 0.3, 0.3) skew(
#{max(-70, -20 * $index * random())}deg,
#{min(70, 20 * $index * random())}deg
)
rotateY(#{min(180, 20 * $index * random())}deg) translate(50%, 50%);
}
100% {
top: 30%;
opacity: 0.1;
transform: scale3d(0.1, 0.1, 0.1) skew(
#{max(-70, -20 * $index * random())}deg,
#{min(70, 20 * $index * random())}deg
)
rotateY(#{min(180, 20 * $index * random())}deg) translate(50%, 50%);
}
}
}
.cylinder-bubble {
position: absolute;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: transparent
url("https://ip-sph-static-1256936115.file.myqcloud.com/kuji-control-yuanzhu.webp?imageMogr2/format/png/interlace/1")
no-repeat center / cover;
&-item {
position: absolute;
top: -50rpx;
left: 0;
z-index: 100;
background-color: $lightColor;
animation-timing-function: linear;
animation-iteration-count: infinite;
transform-style: preserve-3d;
@for $i from 1 through $count {
@include bubblePieceStyle(18rpx, 18rpx, $i);
}
}
}
4、光束上升效果
css3 动画实现, 复杂的点在于一些动态值的计算,比如随机位置,随机的动画持续时间和延迟时间,并且限制气泡上飘的区域,会有点限制
4.1 效果如下
4.2 模板 wxml
html
<view class="cylinder-light">
<block wx:for="{{ lightLineTotal }}" wx:key="index">
<view class="cylinder-light-line"></view>
</block>
</view>
4.3 样式 scss
scss
.cylinder-light {
position: absolute;
z-index: 10;
width: 100%;
height: 100%;
transform: rotateZ(180deg);
&-line {
position: absolute;
top: 50%;
right: 0%;
width: 6rpx;
background: linear-gradient(-45deg, $lightColor, rgba(0, 0, 255, 0));
filter: drop-shadow(0 0 10px rgb(142, 233, 233));
border-radius: 999rpx;
box-shadow: 0 1px 4px rgba($lightColor, 0.3), 0 0 40px rgba(
$lightColor,
0.1
) inset;
animation: tail 4000ms ease-in-out infinite, shooting 4000ms ease-in-out
infinite;
transform-style: preserve-3d;
&::before,
&::after {
position: absolute;
right: -1rpx;
bottom: -2rpx;
width: 10rpx;
height: 10rpx;
content: "";
background: linear-gradient(
-45deg,
rgba(0, 0, 255, 0),
$lightColor,
rgba(0, 0, 255, 0)
);
border-radius: 50%;
border-radius: 100%;
transform: translateY(50%) rotateZ(45deg);
animation: glitter 3000ms ease-in-out infinite;
}
&::after {
transform: translateY(50%) rotateZ(-45deg);
}
@for $index from 1 through $count {
@include lightLineStyle($index, 100, 600);
}
}
}
// 光束顶部闪烁效果
@keyframes glitter {
0% {
box-shadow: 0 0 18rpx 5rpx $lightColor;
opacity: 1;
transform: scale(1);
}
25% {
opacity: 0;
transform: scale(0.5);
}
50% {
box-shadow: 0 0 18rpx 5rpx $lightColor;
opacity: 1;
transform: scale(1);
}
75% {
opacity: 0;
transform: scale(0.5);
}
100% {
box-shadow: 0 0 14rpx 5rpx $lightColor;
opacity: 1;
transform: scale(1);
}
}
// 光束若隐若现缩放效果
@keyframes tail {
0% {
height: 0;
opacity: 1;
}
30% {
height: 100px;
opacity: 0.3;
}
100% {
height: 10px;
opacity: 0;
}
}
// 光束上升效果
@keyframes shooting {
0% {
opacity: 1;
transform: translateY(-50rpx);
}
100% {
transform: translateY(400rpx);
}
}
4.4 逻辑 js
js
data: {
lightLineTotal: 10,
},
5、充能纹路动画效果
css3 动画 + 图片序列帧实现
5.1 效果如下
5.2 模板 wxml
html
<view class="start-button start-button-bg-animate"> </view>
5.3 样式 css
scss
.start-button {
$imgCount: 31;
$bgWidth: 750;
position: relative;
display: flex;
flex: 1;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background: transparent
url("https://ip-sph-static-1256936115.file.myqcloud.com/kuji-animation/start-button/start-button-bg-default.png?imageView2/2/q/60")
no-repeat center center / contain;
&-bg-animate,
&-bg-end {
background-image: url(https://ip-sph-static-1256936115.file.myqcloud.com/kuji-animation/start-button/start-button-bg-long-compression.png);
background-repeat: no-repeat;
background-position-x: 0;
background-position-y: center;
background-size: #{$bgWidth * $imgCount}rpx 459rpx;
opacity: 1;
}
&-bg-animate {
animation: bgAnimation 1s 1;
animation-timing-function: steps(1);
@keyframes bgAnimation {
@for $i from 1 through $imgCount {
#{($i * 100%/$imgCount)} {
background-position-x: -#{$i * $bgWidth}rpx;
}
}
}
}
&-bg-end {
background-position-x: -#{($imgCount - 1) * $bgWidth}rpx;
}
&-container {
display: flex;
align-items: center;
justify-content: center;
}
&-bg-hidden {
width: 100%;
height: 100%;
visibility: hidden;
background: transparent
url("https://ip-sph-static-1256936115.file.myqcloud.com/kuji-animation/start-button/start-button-bg-long-compression.png")
no-repeat center center / contain;
}
}
6、类故障风格效果
本质上就是通过一张图片的 before
和 after
两个伪元素占位,结合两侧两张相同的图,叠加而成的效果
6.1 效果如下
6.2 模板 wxml
html
<view
class="{{ computed.getResultScoreClass(rewLevel) }}"
data-img="{{ rewLevel }}"
>
</view>
6.3 样式 css
scss
.kuji-result-quality {
// 入场动画
@keyframes enter-x-animation {
from {
left: -50%;
opacity: 1;
}
to {
left: 50%;
opacity: 1;
}
}
// 左边合成
@keyframes shake-before {
9% {
left: -20rpx;
}
14% {
left: -18rpx;
}
18% {
left: -16rpx;
}
22% {
left: -14rpx;
}
32% {
left: -12rpx;
}
34% {
left: -10rpx;
}
40% {
left: -8rpx;
}
43% {
left: -6rpx;
}
99% {
left: 1rpx;
}
}
//右边合成
@keyframes shake-after {
9% {
left: 20rpx;
}
14% {
left: 18rpx;
}
18% {
left: 16rpx;
}
22% {
left: 14rpx;
}
32% {
left: 12rpx;
}
34% {
left: 10rpx;
}
40% {
left: 8rpx;
}
43% {
left: 6rpx;
}
99% {
left: 1rpx;
}
}
// 最后小抖动
@keyframes debounce {
10% {
top: -1.4rpx;
left: -2.1rpx;
}
20% {
top: 1.4rpx;
left: -1.2rpx;
}
30% {
left: 1.5rpx;
}
40% {
top: -1.3rpx;
left: -1.7rpx;
}
50% {
left: 1.2rpx;
}
60% {
top: 2.8rpx;
left: -2.2rpx;
}
70% {
top: -2px;
left: 2.1rpx;
}
80% {
top: -1.4rpx;
left: -1.9rpx;
}
90% {
left: 1.2rpx;
}
100% {
left: -1.2rpx;
}
}
@mixin qualityImg {
&.kuji-result-quality__R {
&::before,
&::after {
background-image: url("https://ip-sph-static-1256936115.file.myqcloud.com/kuji-result/kuji_result_level_R.png");
}
}
&.kuji-result-quality__SR {
&::before,
&::after {
background-image: url("https://ip-sph-static-1256936115.file.myqcloud.com/kuji-result/kuji_result_level_SR.png");
}
}
&.kuji-result-quality__SSR {
&::before,
&::after {
background-image: url("https://ip-sph-static-1256936115.file.myqcloud.com/kuji-result/kuji_result_level_SSR.png");
}
}
// 全收
&.kuji-result-quality__ALL {
&::before,
&::after {
background-image: url("https://ip-sph-static-1256936115.file.myqcloud.com/kuji-result/kuji_result_level_ALL_IN.png");
}
}
}
position: absolute;
top: 60%;
left: 50%;
width: 349rpx;
height: 177rpx;
font-family: Raleway, Verdana, Arial;
color: transparent;
transform: translate(-50%, -50%) scale(1.2);
@include qualityImg;
&::before,
&::after {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
content: "";
background: url("https://ip-sph-static-1256936115.file.myqcloud.com/kuji-result/kuji_result_level_R.png")
no-repeat center / cover;
filter: contrast(130%);
}
&::before {
z-index: 2;
animation: shake-before 1.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) 1s 1, debounce
0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) 2s 1.5;
}
&::after {
z-index: 3;
animation: shake-after 1.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) 1s 1, debounce
0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) 2s 1.5;
}
&.enter-x-animation {
opacity: 0;
animation: enter-x-animation 1s cubic-bezier(1, -0.03, 1, 0.04) 0.1s;
animation-fill-mode: forwards;
}
7、卡片的炫光效果
本质上就是在每个边框画一条线,一共四条(上下左右),然后每条线添加动画和渐变色,并使用 before
伪元素来添加滤镜效果,最后整张卡片的边框再加上模糊呼吸灯效果
7.1 效果如下
7.2 模板 wxml
html
<view class="kuji-result-card">
<block wx:for="{{ [1, 2, 3, 4] }}" wx:key="index" wx:item="item">
<view
class="kuji-result-animate-line {{ 'kuji-result-animate-line' + item}}"
></view>
</block>
</view>
7.3 样式 scss
7.3.1 外部样式
scss
.kuji-result {
$cardWidth: 300rpx;
$cardHeight: 414rpx;
$cardImageWidth: 209rpx;
$cardImageHeight: 209rpx;
&-card {
@include hardwareAcceleration;
position: relative;
width: $cardWidth;
height: $cardHeight;
margin: 25rpx 15rpx;
font-size: 32rpx;
text-align: center;
background: url("https://ip-sph-static-1256936115.file.myqcloud.com/kuji-result/kuji-reward-card-bg.png")
no-repeat center / contain;
transform-style: preserve-3d;
perspective: 1000;
// 进入动画
&__ani {
// 结果卡片样式
@include cardLevelStyle;
.kuji-result-card-animate-line {
background-image: none;
}
}
// 四周炫光
&-animate {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
clip-path: polygon(10% 0, 100% 0, 100% 100%, 0 100%, 0 9%);
@include lightLineAnimateStyle;
}
}
&-image {
display: flex;
align-items: center;
justify-content: center;
width: $cardImageWidth;
height: $cardImageHeight;
margin: 20rpx auto 0;
view,
.free-image {
width: $cardImageWidth;
height: $cardImageHeight;
}
}
}
7.3.2 主要的动画效果封装成mixin
scss
// 开启硬件加速
@mixin hardwareAcceleration {
opacity: 1;
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
perspective: 1000;
transform-style: preserve-3d;
}
// 卡片显示动画效果
@mixin showAnimationStyle($index: 0) {
&.fadeInBottomRight {
animation: fadeInBottomRight 0.3s ease-in-out both, breathe-#{$index} 1.5s
ease-in-out infinite alternate;
}
&.fadeInDown {
animation: fadeInDown, breathe-#{$index} 1.5s ease-in-out infinite alternate;
}
&.bounceInUp {
animation: bounceInUp 1s ease-in-out both, breathe-#{$index} 1.5s
ease-in-out infinite alternate;
}
&.bounceInRight {
animation: bounceInRight 1s ease-in-out both, breathe-#{$index} 1.5s
ease-in-out infinite alternate;
}
&.bounceInLeft {
animation: bounceInLeft 1s ease-in-out both, breathe-#{$index} 1.5s
ease-in-out infinite alternate;
}
}
// 四周呼吸灯效果
@mixin breatheStyle(
$shadowColor: rgba(59, 235, 235, 1),
$shadowWidth: 20rpx,
$index: 0,
$bgImageUrl:
"https://ip-sph-static-1256936115.file.myqcloud.com/kuji-result/kuji-result-tab-bg-level1.png"
) {
text-align: center;
cursor: pointer;
background-image: url($bgImageUrl);
border-radius: 10rpx;
box-shadow: 0 1rpx 4rpx rgba(0, 0, 0, 0.3);
// animation: breathe-#{$index} 1.5s ease-in-out infinite alternate;
@keyframes breathe-#{$index} {
0% {
box-shadow: 0 1rpx 2rpx rgba(0, 147, 223, 0.4), 0 1rpx 1rpx rgba(
0,
147,
223,
0.1
) inset;
}
100% {
box-shadow: 0 2rpx $shadowWidth $shadowColor, 0 2rpx $shadowWidth
$shadowColor inset;
}
}
}
// 四周炫光动画
@mixin lightLineAnimateStyle() {
@include hardwareAcceleration;
&-line {
position: absolute;
display: block;
background-image: linear-gradient(
to right,
#03a9f4,
#f441a5,
#ffeb3d,
#09a8f4
);
background-size: 400%;
border-radius: 50px;
}
&-line::before {
position: absolute;
top: -5px;
right: -5px;
bottom: -5px;
left: -5px;
z-index: -1;
content: "";
filter: blur(20px);
background-size: 400%;
border-radius: 50px;
}
&-line1 {
top: 0;
left: 0;
width: 100%;
height: 4rpx;
animation: animate1 2.1s linear infinite, sun 8s infinite;
animation-delay: 0.25s;
}
@keyframes sun {
100% {
background-position: -400% 0;
}
}
@keyframes animate1 {
0% {
left: -100%;
}
50%,
100% {
left: 100%;
}
}
&-line2 {
top: -100%;
right: 0;
width: 4rpx;
height: 100%;
animation: animate2 2.1s linear infinite, sun 8s infinite;
animation-delay: 0.75s;
}
@keyframes animate2 {
0% {
top: -100%;
}
50%,
100% {
top: 100%;
}
}
&-line3 {
right: 0;
bottom: 0;
width: 100%;
height: 4rpx;
animation: animate3 2.1s linear infinite, sun 8s infinite;
animation-delay: 1.25s;
}
@keyframes animate3 {
0% {
right: -100%;
}
50%,
100% {
right: 100%;
}
}
&-line4 {
bottom: -100%;
left: 0;
width: 4rpx;
height: 100%;
animation: animate4 2.1s linear infinite, sun 8s infinite;
animation-delay: 1.75s;
}
@keyframes animate4 {
0% {
bottom: -100%;
}
50%,
100% {
bottom: 100%;
}
}
}
// 结果卡片样式
@mixin cardLevelStyle() {
$prefix: "kuji-result-card__ani";
$firstShadowColor: #ffcd00; // 金色(1级)
$secondShadowColor: #e22eef; // 紫色(2级)
$thirdShadowColor: #12e1de; // 蓝色(3级)
$fourthShadowColor: rgba(193, 202, 207, 0.5); // 灰色(4级)
$cardLevelStyleMap: (
/* 一级样式*/ level1:
(
shadowColor: $firstShadowColor,
shadowWidth: 30rpx,
bgImageUrl:
"https://ip-sph-static-1256936115.file.myqcloud.com/kuji-result/kuji-result-tab-bg-level1.png",
bgBorderStartColor: #ffeb3d,
bgBorderEndColor: $firstShadowColor,
),
/* 二级样式*/ level2:
(
shadowColor: $secondShadowColor,
shadowWidth: 20rpx,
bgImageUrl:
"https://ip-sph-static-1256936115.file.myqcloud.com/kuji-result/kuji-result-tab-bg-level2.png",
bgBorderStartColor: #f441a5,
bgBorderEndColor: $secondShadowColor,
),
/* 三级样式*/ level3:
(
shadowColor: $thirdShadowColor,
shadowWidth: 15rpx,
bgImageUrl:
"https://ip-sph-static-1256936115.file.myqcloud.com/kuji-result/kuji-result-tab-bg-level3.png",
bgBorderStartColor: #03e9f4,
bgBorderEndColor: $thirdShadowColor,
),
/* 四级样式*/ level4:
(
shadowColor: $fourthShadowColor,
shadowWidth: 15rpx,
bgImageUrl:
"https://ip-sph-static-1256936115.file.myqcloud.com/kuji-result/kuji-result-tab-bg-level4.png",
bgBorderStartColor: #a2a9ad,
bgBorderEndColor: $fourthShadowColor,
)
);
// 结果卡片样式
@each $key, $value in $cardLevelStyleMap {
&.#{$prefix}__#{$key} {
@include breatheStyle(
map-get($value, "shadowColor"),
map-get($value, "shadowWidth"),
$key,
map-get($value, "bgImageUrl")
);
@include showAnimationStyle($key);
.kuji-result-card-animate-line {
background-image: linear-gradient(
to right,
map-get($value, "bgBorderStartColor"),
map-get($value, "bgBorderEndColor")
);
}
}
}
}
8、旋转 loading 效果
8.1 效果如下
8.2 模板 wxml
html
<view class="kuji-result-loading">
<view class="kuji-result-loading-circle"> </view>
</view>
8.3 样式 css
scss
.kuji-result {
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
$innerSpacing: 10rpx;
$outerSpacing: 30rpx;
&-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100vh;
&-circle {
display: flex;
align-items: center;
justify-content: center;
width: 300rpx;
height: 300rpx;
border: 3rpx solid transparent;
border-top-color: #9370db;
border-radius: 50%;
animation: spin 2s linear infinite;
&::before,
&::after {
position: absolute;
content: "";
border-radius: 50%;
}
&::before {
top: $innerSpacing;
right: $innerSpacing;
bottom: $innerSpacing;
left: $innerSpacing;
border: 3px solid transparent;
border-top-color: #a800a8;
animation: spin 3s linear infinite;
}
&::after {
top: $outerSpacing;
right: $outerSpacing;
bottom: $outerSpacing;
left: $outerSpacing;
border: 3px solid transparent;
border-top-color: #f0f;
animation: spin 1.5s linear infinite;
}
}
}
}
9、clip-path 绘制替代图片
在这里分享一个很好用的clip-path
生成网站,可以直接在线调整形状,然后一键复制,愉快的摸鱼🐟~
9.1 效果如下
9.2 模板 wxml
html
<view class="reward-arrow-left-btn"></view>
<view class="reward-arrow-right-btn"></view>
9.3 样式 css
scss
.reward-arrow-left,
.reward-arrow-right {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
width: 40rpx;
height: 40rpx;
&-btn {
width: 23rpx;
height: 28rpx;
background-color: #09fffc;
}
}
.reward-arrow-left {
left: 200rpx;
&-btn {
clip-path: polygon(100% 0, 80% 50%, 100% 100%, 0 50%);
}
}
.reward-arrow-right {
right: 200rpx;
&-btn {
clip-path: polygon(0 0, 100% 50%, 0 100%, 20% 50%);
}
}
10、mask-image
图片遮罩
css mask
属性在使用时类似 background
属性,是多个属性合在一起的简写,可以参见下面的列表:
- mask-image
- mask-mode
- mask-repeat
- mask-position
- mask-clip
- mask-origin
- mask-size
- mask-composite
这个案例的效果是 banner
图片滑动到两边后,逐渐模糊隐藏的效果
10.1 效果如下
为了更好的查看效果,特意放慢了交互速度,真机小程序上体验好点
10.2 模板 wxml
html
<view class="reward-main">
<!-- 背景 -->
<free-image
class="reward-flash-bg"
width="366"
height="330"
src="https://ip-sph-static-1256936115.file.myqcloud.com//kuji-reward/reward-flash-bg.png"
/>
<!-- swiper -->
<swiper
wx:if="{{ show }}"
class="swiper-wrapper"
circular
current="{{ current }}"
>
<block wx:for="{{ data }}" wx:key="index">
<swiper-item>
<view class="swiper-item" data-index="{{ index }}">
<free-image class="swiper-item-image" src="{{ item.detail_image }}" />
</view>
</swiper-item>
</block>
</swiper>
</view>
10.3 样式 css
本质上就是通过伪元素,在两边滤镜遮罩效果
scss
&::before {
left: 170rpx;
mask-image: linear-gradient(to right, black 10%, transparent);
}
&::after {
right: 170rpx;
background-position: -520rpx -110rpx;
mask-image: linear-gradient(to left, black 10%, transparent);
}
所有代码如下:
scss
.swiper-class {
&::before,
&::after {
position: absolute;
bottom: 100rpx;
z-index: 1;
display: block;
width: 60rpx;
height: 400rpx;
content: "";
background-image: url("https://ip-sph-static-1256936115.file.myqcloud.com//kuji-reward/reward-theme-bg.png");
filter: blur(2px);
background-position: -170rpx -110rpx;
background-size: 750rpx 640rpx;
}
&::before {
left: 170rpx;
mask-image: linear-gradient(to right, black 10%, transparent);
}
&::after {
right: 170rpx;
background-position: -520rpx -110rpx;
mask-image: linear-gradient(to left, black 10%, transparent);
}
}
总结
本文总结了小程序动画技术在业务中的应用。为了实现高性能的动画渲染效果,项目采用了 CSS3+transform
动画和 canvas
技术。以下是各种案例的实现思路:
- 舞台灯光效果:通过
CSS3 Animation
和图片序列帧动画实现光片上飘、星石漂浮和光效旋转效果。 - 光片上飘效果:使用
CSS3
动画实现,主要难点在于计算随机位置、动画持续时间、延迟时间、倾斜度和旋转度,以及限制光片上升区域。 - 光束上升效果:使用
CSS3
动画实现,主要难点在于计算随机位置、动画持续时间、延迟时间,以及限制气泡上飘区域。 - 充能纹路动画效果:结合
CSS3
动画和图片序列帧实现。 - 类故障风格效果:通过
before
和after
两个伪元素占位,结合两侧相同的图像叠加而成的效果。 - 卡片炫光效果:在每个边框画一条线(上下左右四条),为每条线添加动画和渐变色,使用
before
伪元素添加滤镜效果,最后为整张卡片的边框加上模糊呼吸灯效果。 clip-path
绘制替代图片:利用clip-path
生成网站在线调整形状,一键复制实现。mask-image
图片遮罩:使用css mask
属性实现类似于background
属性的效果,包括mask-image、mask-mode
等多个子属性。本案例展示了如何实现banner
图片在滑动过程中逐渐模糊隐藏的效果。
通过灵活运用这些技巧,可以实现丰富多样的动画效果,提高小程序动画性能,提升用户体验。