抽奖动画 – 红包雨抽奖

本文介绍一个小型动画库anime.js,anime.js 是一款功能强大的Javascript 动画库插件。anime.js 可以和CSS3 属性,SVG,DOM 元素和JS 对象一起工作,制作出各种高性能,平滑过渡的动画效果。
anime.js虽然没有其他动画库功能强大,但是它包含的功完全能够满足日常活动类开发,并且它体积很小,压缩后的anime.min.js只有18kb。下面简单介绍aminie.js提供了哪些动画方法,并举例说明如何在项目中使用。

1. 基本概念

1.1 动画的目标对象

  • 可使用任意CSS选择器作为动画目标,不能用伪元素。
anime({
  targets: '.css-selector-demo .el',
  translateX: 250
})
  • 使用DOM节点或节点的集合作为动画目标。
  var elements = document.querySelectorAll('.dom-node-demo .el');
  anime({
    targets: elements,
    translateX: 270
  });
  • 以JavaScript对象作为动画目标,这个对象必须含有至少一个数字属性。这个在vue中非常有用,例如这个数据用在动态样式中,那随着这个样式变化,这样就可以看到一个动画效果。
  var battery = {
    charged: '0%',
    cycles: 120
  }
  anime({
    targets: battery,
    charged: '100%',
    cycles: 130,
    round: 1,
    easing: 'linear',
    update: function() {
      logEl.innerHTML = JSON.stringify(battery);
    }
  });

  • 以数组作为动画目标,以数组形式接受以上三种类型的对象。
  var el = document.querySelector('.mixed-array-demo .el-01');
  anime({
    targets: [el, '.mixed-array-demo .el-02', '.mixed-array-demo .el-03'],
    translateX: 250
  });

1.2 可动画的目标属性

大多数CSS属性都会导致布局更改或重新绘制,并会导致动画不稳定。 因此尽可能优先考虑opacity和CSS transforms,这两个属性不会触发重绘和重排。

  • 支持常见值是数值的css属性,例如width,top,margin等。
  • 支持相对数值,例如在原来基础上增加,减少一个数字,乘以一个数字等,举例如下
  var relativeEl = document.querySelector('.el.relative-values');
  relativeEl.style.transform = 'translateX(100px)';

  anime({
    targets: '.el.relative-values',
    translateX: {
      value: '*=2.5', // 100px * 2.5 = '250px'
      duration: 1000
    },
    width: {
      value: '-=20px', // 28 - 20 = '8px'
      duration: 1800,
      easing: 'easeInOutSine'
    },
    rotate: {
      value: '+=2turn', // 0 * 2 = '2turn'
      duration: 1800,
      easing: 'easeInOutSine'
    },
    direction: 'alternate'
  });
  • 支持颜色动画,单位可以是Haxadecimal,RGB,RGBA,HSL,HSLA

1.3 时间轴(Timeline)

时间轴可让你将多个动画同步在一起。默认情况下,添加到时间轴的每个动画都会在上一个动画结束时开始。这样就可以连续播放多个动画,在实际开发中经常会遇到多个动画先后播放的场合,用这个时间轴的功能就可以轻松解决。看下面的例子:

  // 使用默认参数创建时间轴
  var tl = anime.timeline({
    easing: 'easeOutExpo',
    duration: 750
  });

  // 增加子项
  tl
  .add({
    targets: '.basic-timeline-demo .el.square',
    translateX: 250,
  })
  .add({
    targets: '.basic-timeline-demo .el.circle',
    translateX: 250,
  })
  .add({
    targets: '.basic-timeline-demo .el.triangle',
    translateX: 250,
  });

这里只介绍几个重要的概念,anime.js提供了丰富的api,其他可以参考官方文档

2. 红包雨动画

下面我们来介绍如何使用anime.js实现一个红包雨动画,这里不仅使用到anime.js动画,还用到lottie动画。关于lottie动画这里不做详细介绍,这个动画是点击到红包的时候显示一个爆炸的效果,起到一个点缀(模拟烟花爆炸)的作用。我们先整体看看这个动画有哪些元素和交互组成。

2.1 需求分解

2.1.1 三二一倒计时

动画开始是一个倒计时,从3倒数到1时显示红包降落动画,这个倒计时也是动画的一部分,UI给到的蓝湖如下图1

图1

2.1.2 红包降落

开始动画的时候要显示另外一个倒计时,这个倒计时是限制抢红包的时间是8秒,在这个时间范围内用户可以点击降落的红包,这里产品要求8秒内
红包持续降落,后端给到一个随机数,例如3,在用户点到第3个红包的时候请求抽奖接口,获取抽奖结果。如果用户在8秒结束时点击次数小于这个随机数,或者用户根本就没有点也会请求,接口在这种情况下接口返回的结果是错过机会。UI给到的高保如下图2:

图2

从高保上看,这里涉及到的动画有:

  • 倒计时,从8变成0;

  • 进度条,从左到右填充满;

  • 红包降落;

    另外根据产品的口头描述,还有个lottery动画

  • 用户点中红包,红包爆炸,变成烟花,红包消失;

2.1.3 中奖弹窗

根据请求接口的结果,显示中奖结果,这个就相对简单,高保图如下:

图3
注意点击继续抢红包的时候,重新开始第二次抽奖,直至没有剩余抽奖机会,底部按钮会显示查看奖励。如果开始第二次抽奖,要把上次播放的动画复原到初始状态,重新开始。

2.2 实现过程

下面我们把这个动画分解成几个部分,逐步分解说明如何实现这个功能。

2.2.1 生成红包

红包
图2中背景上的图片是分开给的,UI给到6张图片的图片命名为raindrop-0.png,raindrop-1.png,等等,如下图3

图4

随机倾斜
并且按照高保上看,图片还是有写倾斜的,可以使用css中的transform: rotateZ(90deg),所以还要给红包图片一个倾斜度,但是每个红包的倾斜度不能相同,需要随机,这样看起来才像“红包雨”。这个用到了一个生成随机数函数来生成倾斜度,如下:

  //生成两个整数中间的随机数
  export function getRandomIntInclusive(min, max) {
    min = Math.ceil(min)
    max = Math.floor(max)
    return Math.floor(Math.random() * (max - min + 1)) + min //含最大值,含最小值 
  }

传入两个整数,第一个最小数,第二个最大数,返回大于等于最小数,小于等于最大数的随机数。
红包倾斜的角度需要在一个范围之间,并且有两个范围,10deg60deg和120deg160deg之间,这样每个都有倾斜。这里忽略60deg~120deg之间的随机角度,是应为这个区间倾斜的话,看上去太,例如,90deg是竖直的,如下图示:

图5
如何选择上面10deg60deb和120deg160deg呢?还是使用随机数,不过这里简单的使用Math.random()方法来控制。注意Math.random()返回值的返回是0到1,所以和0.5比较,要么左偏,要么右偏,不会你出现竖直的情况。如下:

Math.random() > 0.5 ? getRandomIntInclusive(10, 60) : getRandomIntInclusive(120, 170)

2.2.2 图片尺寸

UI给到了6张红包图片raindrop-0.png~raindrop-5.png,红包雨要降落的红包肯定是大于5张的,不然看上去太少了,也不像“雨”,这就有个问题了,这5张红包图片的尺寸不一致,我们需要设置每个图片的尺寸,这里要用到求余计算,“总红包个数 % 6”,这样得到的结果永远都是[0~5],然后我们把图片的尺寸记在一个有6个元素的数组中,如下:

  export const pSize = [
    {w: 136/7.5, h: 134/7.5},
    {w: 170/7.5, h: 202/7.5},
    {w: 170/7.5, h: 202/7.5},
    {w: 152/7.5, h: 180/7.5},
    {w: 152/7.5, h: 180/7.5},
    {w: 106/7.5, h: 144/7.5}
  ]

注意这里除以7.5使用来吧px转换成vw尺寸。

2.2.3 初始位

三二一倒计结束的时刻红包是看不见的,这样红包初始位置要在屏幕之外,这里用到relative/absolute绝对定位,这里用到top: -96。还有个问题,left就不好用一个固定数值了,这里又也需要用到随机数,让红包在x轴随机分布,这样做也是为了让动画看起来像“雨”。代码如下:

getRandomIntInclusive(0, 100 - 170 / 7.5)

注意这里除以7.5使用来吧px转换成vw尺寸。

2.2.4 红包数组

最后的生成红包数组的代码如下:

  this.envelop = Array(20).fill({}).map((a, i) => {
    let index = i % 6, {w, h} = pSize[index] //尺寸
    let obj = {left: 0, top: -96, rotateZ: 0, imgSrc: '',  w, h}  //top: -96 初始隐藏
    obj.rotateZ = Math.random() > 0.5 ? getRandomIntInclusive(10, 60) : getRandomIntInclusive(120, 170) //sui随机倾斜
    obj.left = getRandomIntInclusive(0, 100 - 170 / 7.5)  //left
    obj.imgSrc = require('./../assets/images/red-rain/raindrop-'+ index +'.png') //红包图片
    return obj
  })

2.2.5 倒计时

三二一倒计时,这里使用setInterval方法,每秒start递减直至为0,页面上用这个start作为数字图片的一部分,在倒计时结束后显示红包雨弹框并开始播放动画,代码如下:

  countDownTip() {
    //321开始
    this.intId = setInterval(() => {
      this.countDown.start--
      this.$nextTick(() => {
        if (this.countDown.start <= 0) {
          clearInterval(this.intId)
          //3秒后显示红包雨动画
          this.isShow.countDown = false
          this.playAnime() //播放动画
        }
      })
    }, 1000)
  }
<mask-slot :is-show="isShow.countDown">
  <div class="content tip">
    <img
      style="margin-top: 30%"
      :src="require('../assets/images/red-rain/count-'+ countDown.start +'.png')"
      class="number"
      alt="" />
  </div>
</mask-slot>

2.2.6 进度条&倒计时&红包降落&未点击抽奖

虽然进度条动画,倒计时动画,红包降落动画是同步进行的,这里我们为了代码方便还是用到时间轴Timeline来组织代码。进度条动画是在8秒时间内从左到右铺满,倒计时动画是数字从8逐步减少到0,红包降落动画是修改元素的top属性,从-96(隐藏)到整个屏幕的高度,就是落到屏幕最底部隐藏,注意红包降落的过程中不能所有的一起降落,要有时间上的交错,这里用到交错动画,来看下面的代码。

playAnime() {
  this.tl = anime.timeline({easing: 'linear', duration: 8000})
  let height = window.screen.height
  this.tl.add({                     //倒计时动画
    targets: this.countDown,        //动画目标countDown对象中的rob属性,从8变成0
    rob: 0,
    duration: 8000,                 //持续8秒钟
    round: 1,
    delay: 500,
    easing: 'linear',
    complete: () => {
      this.tl.pause()               //结束后动画结束
      //8秒后未点击或点击数小于随机数,去抽奖
      if (this.btnClickCount < this.chance.random) {
        this.lottery()
      }
    }
  }).add({                        //进度条动画
    targets: '#processImg',       //动画目标是标签,css选择器
    width: '100%',                //修改标签的宽度
    duration: 8000                //初始时间是8秒
  }, 0).add({                     //红包降落动画
    targets: '.envelop',          //动画目标是标签,一系列div标签
    delay: anime.stagger(300, {start: 100}),  //交错动画,延迟从100ms开始,然后每个元素增加300ms
    easing: 'linear',
    top: height,                  //修改高度
    loop: true
  }, 0)
}

来看看这个动画的效果,如下图6

图6

从界面效果上看符合需求的预期,右上角倒计时,进度条从左到右铺满,红包持续降落,而不是一起降落。Math.random()和getRandomIntInclusive()方法配合让红包随机左右倾斜并且在x轴随机分布,这样红包看起来更像是一场“雨”。

2.2.7 红包爆炸

在红包降落的过程中,8秒时间内,如果用户点击了红包,会有一个红包爆炸的效果,这里用到Lottie动画。Lottie动画是由专门的动画设计师做好之后发个前端开发人员来接入的,这里我们不做详细介绍,只说一个问题。
Lottery动画设计师输出的产物是动画资源,包含一个img文件夹,里面是图片文件,还有一个data.json数据,引入Lottie插件之后,要额外再引入这个json数据,注意这个json数据里会引入images文件夹下的图片文件,在json对象的assets节点下面。如下图7

图7

我们看到assest目录下有个图片img_0.png,如下图8

引入data.json之后要对assets节点下的图片目录特殊处理,使用require()方法引入,不然打包之后找不到图片,如下处理
引入资源数据

import animeData from './../assets/boom/data.json'

处理数据

mounted() {
  this.processData()
}
//处理json图片路径
processData() {
  shuffle(this.envelop)
  animeData.assets.forEach(item => {
    item.u = ''
    if (item.w && item.h) {
      item.p = require(`@/assets/boom/images/${item.p}`)  //require处理图片路径
    }
  })
}

还要安装并引入Lottie插件,如下:

import lottie from 'lottie-web'

点击红包之后要播放当前点击的红包的爆炸动画,并且停止红包雨,代码如下:

//点击红包
btnRob(el, data) {
  if (checkLogin()) {
    //点击次数加1
    this.btnClickCount++
    el.target.style.background = 'none'       //隐藏红包
    let lott =  lottie.loadAnimation({
      container: el.target,
      animType: 'html',
      renderer: 'svg',
      loop: false,
      autoplay: true,
      animationData: animeData,
    })
    lott.setSpeed(3.5)//修改爆炸烟花速度
    lott.addEventListener('complete', e => {
      setTimeout(() => {
        el.target.innerText = ''                //隐藏红包
      }, 500)
    })
    //点击次数大于等于随机次数
    if (this.btnClickCount >= this.chance.random) {
      //停止飘落
      this.tl.pause()
      //去抽奖
      this.lottery()
    }
  }
}

下面来看看这个爆炸的效果,如下图9

图6

从图中爆炸效果来看,Lottie动画是给这个烟花图片做了一个从小变大的效果。

2.2.8 抽奖

根据需求,在8秒内用户点击红包达到规定次数的时候,去抽奖,没有点击或者点击次数小于规定次数,也会去调抽奖接口,接口会将抽奖机会减1并告诉用户错失机会。来看下面的代码:

//抽奖
lottery() {
  this.$toast.loading({message: '加载中...', duration: 0, forbidClick: true, loadingType: 'spinner'})
  let {auth} = getLocalStorage()
  let data = {
    actId: configData.actId,
    clickNum: this.btnClickCount,
    provinceId: auth.provinceCode,
    channelId: configData.channelId
  }
  api.coc2.redEnvelope.raffle(data).then(res => {
    this.$toast.clear()
    this.prize = {}
    this.$nextTick(() => {
      if ([0, 9300001, 8000007, 9300003].includes(res.hRet)) {
        if (res.hRet == 0) {
          this.prize = res.data
        }
        this.prize.hRet = res.hRet
        this.prize.page = 'red-envelope'
        //业务推荐
        if (4 === this.prize.prizeType) {
          this.$refs.refService && this.$refs.refService.popUp()
        }
        //福卡
        else if (6 === this.prize.prizeType) {
          this.$refs.refAlipayCard && this.$refs.refAlipayCard.popUp()
        }
        //卡券奖励
        else {
          this.$refs.refWinPrize && this.$refs.refWinPrize.popUp(this.prize)
        }
      } else if (res.hRet === 303) {
        pullLogin()
      } else {
        this.$toast(res.retMsg)
        this.close(true)
        EventBus.$emit(EventKey.checkPrize)
      }
    })
  }).catch(e => {
    this.$toast.clear()
    this.prize.hRet = 8000007
    this.$nextTick(() => {
      this.$refs.refWinPrize && this.$refs.refWinPrize.popUp(this.prize)
    })
  })
}

2.2.9 动画复原

上面代码是调接口和接口处理逻辑,和动画关系不大,但是有一个要注意的地方,调接口之后弹出抽奖结果弹框,可能用户还有抽奖机会,这时又可以抽,需要将动画复原。这里有个问题,如果是通过动画修改过的data值,需要重新赋值,并且使用anime.js赋值,直接使用vue中的this.xxx = yyy不起作用,这个估计是修改动画的值的时候没有触发set导致的,来看下面的代码。

<!-- 卡券 -->
<win-prize ref="refWinPrize" :prize="prize" :chance="chance" @continueRob="continueRob"></win-prize>
<!-- 业务推荐 -->
<handle-service ref="refService" :prize="prize" @close="close"></handle-service>
<!-- 福卡 -->
<alipay-card ref="refAlipayCard" :prize="prize" :chance="chance" @continueRob="continueRob"></alipay-card>
close(closeAll) {
  this.btnClickCount = 0 //用户点击次数初始化
  this.countDown.start = 3
  this.countDown.rob = 8
  if (closeAll) {
    this.isShow.pop = false       //关闭整个红包雨弹框
  }
  this.isShow.countDown = true
  clearInterval(this.intId)
  this.tl = anime.timeline()
  this.tl.add({
    targets: '.envelop',
    top: -96,
    duration: 100,
    easing: 'linear'
  }).add({
    targets: '#processImg',
    width: '0%',
    duration: 100
  })
}

3 最终效果

图7

5.参考

  1. animejs https://www.animejs.cn/
  2. Lottie https://airbnb.design/lottie/#get-started
页面下部广告