抽獎動畫 - 紅包雨抽獎

nd發表於2022-03-15

本文介紹一個小型動畫庫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

相關文章