跳至主要內容

H5 直播点赞动画

Mr.LRH动效动画大约 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 函数中自己的值。可能的值是从 01 的数值。
  • 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-directionnormalalternate 时)或 to 关键帧中的值(当 animation-directionreversealternate-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);
}

参考

上次编辑于:
贡献者: lrh21g