H5 直播点赞动画
大约 3 分钟
H5 直播点赞动画
CSS3 方式
CSS3 animation
animation: name duration timing-function delay iteration-count direction fill-mode play-state;
name
: animation-name , 规定需要绑定到选择器的@keyframe
(规定动画) 名称。duration
: animation-duration , 规定完成动画所花费的时间,以秒或毫秒计。timing-function
: animation-timing-function , 规定动画的速度曲线。linear
: 动画从头到尾的速度是相同的。ease
: 默认。动画以低速开始,然后加快,在结束前变慢。ease-in
: 动画以低速开始。ease-out
: 动画以低速结束。ease-in-out
: 动画以低速开始和结束。cubic-bezier(n,n,n,n)
: 在cubic-bezier
函数中自己的值。可能的值是从0
到1
的数值。
delay
: animation-delay , 规定在动画开始之前的延迟。单位可以是秒(s)或毫秒(ms)。iteration-count
: animation-iteration-count , 规定动画应该播放的次数。n
: 一个数字,定义应该播放多少次动画。infinite
: 指定动画应该播放无限次(永远)。
direction
: animation-direction , 规定是否应该轮流反向播放动画。normal
: 默认值。动画按正常播放。reverse
: 动画反向播放。alternate
: 动画在奇数次(1、3、5...)正向播放,在偶数次(2、4、6...)反向播放。alternate-reverse
: 动画在奇数次(1、3、5...)反向播放,在偶数次(2、4、6...)正向播放。initial
: 设置该属性为它的默认值。inherit
: 从父元素继承该属性。
fill-mode
: animation-fill-mode , 规定当动画不播放时(当动画完成时,或当动画有一个延迟未开始播放时),要应用到元素的样式。none
: 默认值。动画在动画执行之前和之后不会应用任何样式到目标元素。forwards
: 在动画结束后(由animation-iteration-count
决定),动画将应用该属性值。backwards
: 动画将应用在animation-delay
定义期间启动动画的第一次迭代的关键帧中定义的属性值。这些都是 from 关键帧中的值(当animation-direction
为normal
或alternate
时)或 to 关键帧中的值(当animation-direction
为reverse
或alternate-reverse
时)。both
: 动画遵循 forwards 和 backwards 的规则。也就是说,动画会在两个方向上扩展动画属性。initial
: 设置该属性为它的默认值。请参阅 initial。inherit
: 从父元素继承该属性。请参阅 inherit。
play-state
: animation-play-state , 指定动画是否正在运行或已暂停。paused
: 指定暂停动画running
: 指定正在运行的动画
CSS3 实现
/docs/.vuepress/components/live-praise-bubble
| --- css3-render.vue
| --- css3-render-style.scss
live-praise-bubble/css3-render.vue
<template>
<div class="like-container">
<div class="praise-bubble" ref="praiseBubbleRef">
<div
v-for="item in praiseBubbleList"
:class="`bubble b${item.bubbleBgIndex} bl${item.bubbleAnimationIndex}`"
:data-current-time="item.currentTime"
></div>
</div>
<div class="like-thumb" @click="clickLikeThumb"></div>
</div>
</template>
<script>
export default {
name: 'LivePraiseBubbleCSS3Render',
data() {
return {
praiseBubbleList: [],
};
},
mounted() {
// setInterval(() => {
// this.addPraise();
// }, 300);
},
methods: {
clickLikeThumb() {
this.addPraise();
},
addPraise() {
const bubbleBgIndex = Math.floor(Math.random() * 6) + 1;
const bubbleAnimationIndex = Math.floor(Math.random() * 11) + 1; // bl1 - bl11
this.praiseBubbleList.push({
bubbleBgIndex,
bubbleAnimationIndex,
currentTime: String(Date.now()),
});
},
},
};
</script>
<style lang="scss">
@import './css3-render-style.scss';
</style>
live-praise-bubble/css3-render-style.scss
$bubble_time: 3s;
$bubble_scale: 0.8s;
.like-container {
width: 100px;
padding: 10px 0;
margin: auto;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: #f4f4f4;
}
.like-thumb {
width: 30px;
height: 30px;
padding: 10px;
border-radius: 30px;
background-color: rgba($color: #000000, $alpha: 0.5);
background-image: url(./images/like.png);
background-repeat: no-repeat;
background-position: center center;
background-size: 30px 30px;
}
.praise-bubble {
position: relative;
width: 100px;
height: 200px;
}
.bubble {
position: absolute;
width: 40px;
height: 40px;
left: 30px;
bottom: 0px;
background-repeat: no-repeat;
background-size: 100%;
transform-origin: bottom;
}
.b1 {
background-image: url(./images/bg1.png);
// 可以使用雪碧图
// background-position: -42px -107px;
// background-size: 188.5px 147px;
}
.b2 {
background-image: url(./images/bg2.png);
// background-position: -84px -107px;
// background-size: 188.5px 147px;
}
.b3 {
background-image: url(./images/bg3.png);
// background-position: 0 -107px;
// background-size: 188.5px 147px;
}
.b4 {
background-image: url(./images/bg4.png);
// background-position: -45px -62px;
// background-size: 188.5px 147px;
}
.b5 {
background-image: url(./images/bg5.png);
// background-position: -107px -42px;
// background-size: 188.5px 147px;
}
.b6 {
background-image: url(./images/bg6.png);
// background-position: -107px 0;
// background-size: 188.5px 147px;
}
.bl1 {
animation: bubble_1 $bubble_time linear 1 forwards,
bubble_big_1 $bubble_scale linear 1 forwards,
bubble_y $bubble_time linear 1 forwards;
}
.bl2 {
animation: bubble_2 $bubble_time linear 1 forwards,
bubble_big_2 $bubble_scale linear 1 forwards,
bubble_y $bubble_time linear 1 forwards;
}
.bl3 {
animation: bubble_3 $bubble_time linear 1 forwards,
bubble_big_1 $bubble_scale linear 1 forwards,
bubble_y $bubble_time linear 1 forwards;
}
.bl4 {
animation: bubble_4 $bubble_time linear 1 forwards,
bubble_big_2 $bubble_scale linear 1 forwards,
bubble_y $bubble_time linear 1 forwards;
}
.bl5 {
animation: bubble_5 $bubble_time linear 1 forwards,
bubble_big_1 $bubble_scale linear 1 forwards,
bubble_y $bubble_time linear 1 forwards;
}
.bl6 {
animation: bubble_6 $bubble_time linear 1 forwards,
bubble_big_3 $bubble_scale linear 1 forwards,
bubble_y $bubble_time linear 1 forwards;
}
.bl7 {
animation: bubble_7 $bubble_time linear 1 forwards,
bubble_big_1 $bubble_scale linear 1 forwards,
bubble_y $bubble_time linear 1 forwards;
}
.bl8 {
animation: bubble_8 $bubble_time linear 1 forwards,
bubble_big_3 $bubble_scale linear 1 forwards,
bubble_y $bubble_time linear 1 forwards;
}
.bl9 {
animation: bubble_9 $bubble_time linear 1 forwards,
bubble_big_2 $bubble_scale linear 1 forwards,
bubble_y $bubble_time linear 1 forwards;
}
.bl10 {
animation: bubble_10 $bubble_time linear 1 forwards,
bubble_big_1 $bubble_scale linear 1 forwards,
bubble_y $bubble_time linear 1 forwards;
}
.bl11 {
animation: bubble_11 $bubble_time linear 1 forwards,
bubble_big_2 $bubble_scale linear 1 forwards,
bubble_y $bubble_time linear 1 forwards;
}
@keyframes bubble_11 {
0% {
}
25% {
margin-left: -10px;
}
50% {
margin-left: -10px;
}
100% {
margin-left: -18px;
}
}
@keyframes bubble_10 {
0% {
}
25% {
margin-left: -20px;
}
50% {
margin-left: -20px;
}
100% {
margin-left: -20px;
}
}
@keyframes bubble_9 {
0% {
}
25% {
margin-left: 10px;
}
50% {
margin-left: 10px;
}
100% {
margin-left: 10px;
}
}
@keyframes bubble_8 {
0% {
}
25% {
margin-left: 20px;
}
50% {
margin-left: 20px;
}
100% {
margin-left: 20px;
}
}
@keyframes bubble_7 {
0% {
}
25% {
margin-left: 3px;
}
50% {
margin-left: 1px;
}
75% {
margin-left: 2px;
}
100% {
margin-left: 3px;
}
}
@keyframes bubble_6 {
0% {
}
25% {
margin-left: -3px;
}
50% {
margin-left: -1px;
}
75% {
margin-left: -2px;
}
100% {
margin-left: -3px;
}
}
@keyframes bubble_5 {
0% {
}
25% {
margin-left: 5px;
}
50% {
margin-left: -5px;
}
75% {
margin-left: -10px;
}
100% {
margin-left: -20px;
}
}
@keyframes bubble_4 {
0% {
}
25% {
margin-left: -5px;
}
50% {
margin-left: -5px;
}
75% {
margin-left: 20px;
}
100% {
margin-left: 10px;
}
}
@keyframes bubble_3 {
0% {
}
25% {
margin-left: -20px;
}
50% {
margin-left: 10px;
}
75% {
margin-left: 20px;
}
100% {
margin-left: -10px;
}
}
@keyframes bubble_2 {
0% {
}
25% {
margin-left: 20px;
}
50% {
margin-left: 25px;
}
75% {
margin-left: 10px;
}
100% {
margin-left: 5px;
}
}
@keyframes bubble_1 {
0% {
}
25% {
margin-left: -8px;
}
50% {
margin-left: 8px;
}
75% {
margin-left: -15px;
}
100% {
margin-left: 15px;
}
}
@keyframes bubble_big_1 {
0% {
transform: scale(0.3);
}
100% {
transform: scale(1.2);
}
}
@keyframes bubble_big_2 {
0% {
transform: scale(0.3);
}
100% {
transform: scale(0.9);
}
}
@keyframes bubble_big_3 {
0% {
transform: scale(0.3);
}
100% {
transform: scale(0.6);
}
}
@keyframes bubble_y {
0% {
margin-bottom: 0;
}
10% {
margin-bottom: 0;
}
75% {
opacity: 1;
}
100% {
margin-bottom: 200px;
opacity: 0;
}
}
使用 animation 添加运动渐隐的效果
.bl1{ animation:bubble_y 4s linear 1 forwards ; } @keyframes bubble_y { 0% { margin-bottom: 0; } 75%{ opacity: 1; } 100% { margin-bottom: 200px; opacity: 0; } }
增加动画放大效果
.bl1{ animation:bubble_big 0.5s linear 1 forwards; } @keyframes bubble_big_1 { 0% { transform: scale(.3); } 100% { transform: scale(1); } }
设置偏移
@keyframes bubble_1 { 0% {} 25% { margin-left: -8px; } 50% { margin-left: 8px } 75% { margin-left: -15px } 100% { margin-left: 15px } }
补齐动画样式。通过调整缩放、偏移值,预设更多中的曲线,达到随机轨迹的目的。
通过 JavaScript 操作,随机组合点赞的样式,然后渲染到节点上。同时注意设置 bubble (气泡) 的随机延迟,保证不扎堆出现。
注意:
当用户同时下发了点赞40个,业务需要这40个点赞一次出现,制造持续点赞的氛围,则需要分批打散点赞数量。比如一次点赞的时间是 4s,那么 4s 内,需要同时出现多少个点赞。如果是 10 个,那么 40 个,需要分批 4 次渲染。
window.requestAnimationFrame(() => { render(); // 继续循环处理批次 });
需要手动清楚节点,以防节点过多带来的性能问题。
Canvas 方式
/docs/.vuepress/components/live-praise-bubble
| --- canvas-render.vue
| --- canvas-praise-bubble.js
live-praise-bubble/canvas-render.vue
<template>
<div class="like-container">
<canvas
id="thumsCanvas"
width="200"
height="400"
style="width:100px;height:200px"
></canvas>
<div class="like-thumb" @click="clickLikeThumb"></div>
</div>
</template>
<script>
import ThumbsUpAni from './canvas-praise-bubble.js';
export default {
name: 'LivePraiseBubbleCanvasRender',
methods: {
clickLikeThumb() {
const thumbsUpAni = new ThumbsUpAni();
setInterval(() => {
thumbsUpAni.start();
}, 300);
},
},
};
</script>
<style lang="scss">
.like-container {
width: 100px;
padding: 10px 0;
margin: auto;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: #f4f4f4;
}
.like-thumb {
width: 30px;
height: 30px;
padding: 10px;
border-radius: 30px;
background-color: rgba($color: #000000, $alpha: 0.5);
background-image: url(./images/like.png);
background-repeat: no-repeat;
background-position: center center;
background-size: 30px 30px;
}
</style>
live-praise-bubble/canvas-praise-bubble.js
function getRandom(min, max) {
return min + Math.floor(Math.random() * (max - min + 1));
}
export default class ThumbsUpAni {
constructor() {
this.loadImages(); // 预加载图片
// 读取 canvas
const canvas = document.getElementById('thumsCanvas');
this.context = canvas.getContext('2d');
this.width = canvas.width;
this.height = canvas.height;
this.imgsList = []; // 点赞图像列表
this.renderList = []; // 渲染对象雷彪
// scaleTime - 百分比。图片从开始放大到最终大小,所用时长。
// 设置为 0.1 ,表示总共运行时间前面的 10% 的时间,点赞图片逐步放大
this.scaleTime = 0.1;
this.scanning = false; // 扫描器扫描标识,防止开启多个扫描器
}
// 预加载图片,获取图片宽高,如果某一图片加载失败,则不显示该图片
loadImages() {
const images = [
'jfs/t1/93992/8/9049/4680/5e0aea04Ec9dd2be8/608efd890fd61486.png',
'jfs/t1/108305/14/2849/4908/5e0aea04Efb54912c/bfa59f27e654e29c.png',
'jfs/t1/98805/29/8975/5106/5e0aea05Ed970e2b4/98803f8ad07147b9.png',
'jfs/t1/94291/26/9105/4344/5e0aea05Ed64b9187/5165fdf5621d5bbf.png',
'jfs/t1/102753/34/8504/5522/5e0aea05E0b9ef0b4/74a73178e31bd021.png',
'jfs/t1/102954/26/9241/5069/5e0aea05E7dde8bda/720fcec8bc5be9d4.png',
];
const promiseAll = [];
images.forEach((src) => {
const p = new Promise(function(resolve) {
const img = new Image();
img.onerror = img.onload = resolve.bind(null, img);
img.src = 'https://img12.360buyimg.com/img/' + src;
});
promiseAll.push(p);
});
Promise.all(promiseAll).then((imgsList) => {
this.imgsList = imgsList.filter((d) => {
if (d && d.width > 0) return true;
return false;
});
if (this.imgsList.length == 0) {
dLog('error', 'imgsList load all error');
return;
}
});
}
createRender() {
if (this.imgsList.length == 0) return null;
// 当运行时间 diffTime 小于设置的 scaleTime 的时候,按比例随着时间增大,scale 变大。超过设置的时间阈值,则返回最终大小。
const basicScale = [0.6, 0.9, 1.2][getRandom(0, 2)];
const getScale = (diffTime) => {
// diffTime - 百分比。表示从动画开始运行到当前时间过了多长时间。实际值是从 0 --> 1 逐步增大。
// scaleTime - 百分比。图片从开始放大到最终大小,所用时长。
if (diffTime < this.scaleTime) {
return +(diffTime / this.scaleTime).toFixed(2) * basicScale;
} else {
return basicScale;
}
};
const context = this.context;
// 随机读取一个图片,进行渲染
const image = this.imgsList[getRandom(0, this.imgsList.length - 1)];
const offset = 20; // x轴偏移量
const basicX = this.width / 2 + getRandom(-offset, offset);
const angle = getRandom(2, 10); // 角度系数
let ratio = getRandom(10, 30) * (getRandom(0, 1) ? 1 : -1);
// 随机平滑 X 轴偏移 - 通过正弦( Math.sin )函数来实现均匀曲线
const getTranslateX = (diffTime) => {
if (diffTime < this.scaleTime) {
// 放大期间,不进行摇摆位移
return basicX;
} else {
return basicX + ratio * Math.sin(angle * (diffTime - this.scaleTime));
}
};
// Y 轴偏移 - 运行偏移从 this.height --> image.height / 2 ,即从最底部,运行到顶部留下。
const getTranslateY = (diffTime) => {
return (
image.height / 2 + (this.height - image.height / 2) * (1 - diffTime)
);
};
// 淡出
const fadeOutStage = getRandom(14, 18) / 100;
const getAlpha = (diffTime) => {
let left = 1 - +diffTime;
if (left > fadeOutStage) {
return 1;
} else {
return 1 - +((fadeOutStage - left) / fadeOutStage).toFixed(2);
}
};
return (diffTime) => {
// diffTime : 百分比。表示从动画开始运行到当前时间过了多长时间。实际值是从 0 --> 1 逐步增大。
// diffTime 为 0.4 的时候,说明是已经运行了 40% 的时间
// 时间差值满了,即:动画结束了(0 --> 1)
if (diffTime >= 1) return true;
context.save();
const scale = getScale(diffTime);
// const rotate = getRotate();
const translateX = getTranslateX(diffTime);
const translateY = getTranslateY(diffTime);
context.translate(translateX, translateY); // 偏移
context.scale(scale, scale); // 缩放
// context.rotate(rotate * Math.PI / 180);
context.globalAlpha = getAlpha(diffTime); // 淡出
// 绘制
context.drawImage(
image,
-image.width / 2,
-image.height / 2,
image.width,
image.height
);
context.restore(); // 恢复画布(canvas)状态。
};
}
// 实时绘制扫描器
// 开启实时绘制扫描器,将创建的渲染对象放入 renderList 数组,数组不为空,说明 canvas 上还有动画,就需要不停的去执行 scan,直到 canvas 上没有动画结束为止。
scan() {
this.context.clearRect(0, 0, this.width, this.height);
this.context.fillStyle = '#f4f4f4';
this.context.fillRect(0, 0, 200, 400);
let index = 0;
let length = this.renderList.length;
if (length > 0) {
requestFrame(this.scan.bind(this));
this.scanning = true;
} else {
this.scanning = false;
}
// diffTime = (Date.now() - render.timestamp) / render.duration
// 如果开始的时间戳是 10000,当前是100100,则说明已经运行了 100 毫秒了,如果动画本来需要执行 1000 毫秒,那么 diffTime = 0.1,代表动画已经运行了 10%。
while (index < length) {
const child = this.renderList[index];
if (
!child ||
!child.render ||
child.render.call(null, (Date.now() - child.timestamp) / child.duration)
) {
// 动画结束,则删除该动画
this.renderList.splice(index, 1);
length--;
} else {
// 继续执行动画
index++;
}
}
}
// 开始/增加动画
// 调用一次 start 方法来生成渲染实例,放进渲染实例数组。
// 如果当前扫描器未开启,则需要启动扫描器,使用了 scanning 变量,防止开启多个扫描器。
start() {
const render = this.createRender();
const duration = getRandom(1500, 3000);
this.renderList.push({
render,
duration,
timestamp: Date.now(),
});
if (!this.scanning) {
this.scanning = true;
requestFrame(this.scan.bind(this));
}
return this;
}
}
function requestFrame(cb) {
return (
window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
function(callback) {
window.setTimeout(callback, 1000 / 60);
}
)(cb);
}