基于Vue的红包雨效果实现

最近遇到了一个红包雨的需求,就大概这个样子:


image.png

具体效果可以进入拼多多查看。

在实现这个效果之前,我先安利一个我们老大之前写的基于requestAnimationFrame实现的小动画框架 《chito》。https://redmed.github.io/chito/

本文所述的需求均依赖此框架完成。

它可以根据你传入的相关动画参数来为你创建流畅的补间动画。


OK 回归正题。

首先思考几个问题。

1、红包要如何插入进来并展示掉落动画?
思考结果:平常的方式就是创建一个以单个红包为纬度的组件,我需要创建多少个红包,进页面就用v-for来创建多少个dom。但是我觉得以我司运营的套路,这个红包以后肯定会各种复用,我总不能谁复用这个功能都copy一遍我的代码过去吧 那简直太灾难了。
所以我准备将红包的插入方式改为函数调用的方式,就像在日常的后台管理系统中调用Element-UI中的 this.$message.success()一样,调用一次函数,红包就从上往下掉落一次,再根据不同的参数来单独控制每个红包的掉落速度、点击红包后对应的得分,甚至红包的颜色等等各项自定义属性。

2、红包的掉落需要哪些参数?
思考结果:以最最简单的交互来讲,可能只需要掉落速度,从何处掉落(红包掉落动画起始点的X轴),红包数量,红包雨持续的时间,点击红包后的反馈动画。
现在我有两个组件,1、父组件来负责红包的展示,以及红包的掉落的属性(上述那些)。2、子组件就是红包组件。

3、通过函数调用的组件如何编写和调用?
这个貌似官方没有什么对应的文档,我也是从Element-UI的源码中抄过来的。

4、未经允许直接复制本文的同学,我谢谢你。


OK进入开发,首先就是红包组件

首先我要定义一个红包容器,代码十分简单, 再给它一个简简单单的样式

<template>
  <div class="box" ref="packet" @touchstart.once="handleClick" v-show="hidden">
  </div>
</template>
<style scoped lang="stylus">
* {
  user-select: none
  outline none
}

.box {
  width 0.592rem
  height 0.86rem
  position absolute
  top -1rem
  background url("https://coolcdn.igetcool.com/p/2021/1/f611d435ffb8c8c43c6983d807e27a65.png?_296x430.png")
  background-repeat no-repeat
  background-size contain
  transform rotate(0deg)
}
</style>

这样,屏幕上就会出现一个红包,图是我随便找的一个。


image.png

红包应该是竖着的,我随便截了个图而已。

红包组件的详细代码,每一行都有注释,我不相信你看不懂😄

<template>
  <div class="box" ref="packet" @touchstart.once="handleClick" v-show="hidden">
  </div>
</template>

<script>
const {Animation, Clip} = require('chito')
export default {
  name: "bonusRain",
  data() {
    return {
      // 代表红包该显示还是不显示
      hidden: true,
      // 用于存放chito生成的动画实例
      animation: null,
      // 用于存放从父组件传进来的配置项
      options: null,
      // 用于记录红包是不是已经掉落
      isDropped: false,
      // 用于判断红包在掉落的时候是顺时针旋转还是逆时针旋转
      rotateComputed: 1
    }
  },
  methods: {
    // 点击事件
    handleClick(e) {
      this.$nextTick(() => {
        // 因为每个红包都只能点击一次,所以点击红包后,就让动画停止
        this.animation.stop()
        // 因为红包点击后也代表了销毁,所以在这里也要调用dropped事件
        if (this.isDropped === false) {
          this.options.onDropped()
          this.isDropped = true
        }

        // 自定义的click事件,让事件能够分发出去,因为不是直接通过dom的方式向
        // 父组件插入的组件,所以不能用$.emit分发事件
        if (this.hidden === true) {
          this.options.onClick(e)
        }

        // 点击红包后修改红包样式
        this.$refs['packet'].style.transform = 'rotate(0deg)'
        this.$refs['packet'].style.background = `url(https://coolcdn.igetcool.com/p/2021/1/c1a3568325f4fa97f843850aaa9713a7.jpg?_500x511.jpg)`
        this.$refs['packet'].style.backgroundSize = 'contain'
        this.$refs['packet'].style.backgroundRepeat = 'no-repeat'
        this.$refs['packet'].style.animation = 'unset'

        // 修改完样式总不能直接消失,所以适当给一个延时
        setTimeout(() => {
          this.hidden = false
        }, 500)
      })


    },
    show(obj) {
      // 把传进来的配置项赋值给options
      this.options = obj

      // 自定义了一个beforShow的钩子,这样动画在初始化的时候想执行什么方法也方便
      if(this.options.beforeShow){
        this.options.beforeShow()
      }

      // 如果穿进来了一个封面图片,就替换掉当前的红包封面
      if(obj.cover){
        this.$refs['packet'].style.background = `url(${obj.cover})`
        this.$refs['packet'].style.backgroundSize = 'contain'
        this.$refs['packet'].style.backgroundRepeat = 'no-repeat'
      }

      // 创建一个动画剪辑
      let clip = new Clip({
        // 剪辑持续的时间,如果掉落距离是固定的,那么掉落时间就决定了红包掉落的速度
        duration: obj.speed || 2000,
        // 剪辑重复一次,因为每个红包都是独立的
        repeat: 1
      }, {
        // 掉落的路线,从 -100开始到屏幕高度
        y: [-100, document.documentElement.clientHeight]
      })

      // 红包在掉落的过程中,每一次运动都会触发clip的update事件,
      // 如果你需要一些花里胡哨的效果,可以在这里定义
      clip.on('update', (ev) => {
        var keyframe = ev.keyframe;
        // 因为要操作dom 所以需要用nextTick
        this.$nextTick(() => {
          // 掉落的过程中动态改变红包的y轴位置
          this.$refs['packet'].style.top = keyframe.y + 'px';
          // 根据配置项的x轴位置来设置红包的x轴位置
          this.$refs['packet'].style.left = obj.xAxis + 'px'
          // 红包掉落的时候让它旋转起来
          this.$refs['packet'].style.transform = `rotate(` + (ev.progress * 180 * this.rotateComputed) +`deg)`
        })
      });
      // 创建Animation实例
      this.animation = new Animation();
      // 把创建的剪辑添加到Animation实例中
      this.animation.addClip([clip]);
    },
    start(){
      // 还在奇怪为什么我rotateComputed给了个数字1么?
      // 这里就告诉你,如果是1它就顺时针旋转,-1就是逆时针旋转
      this.rotateComputed = (Math.random() * 10) > 5 ? 1:-1

      // 让动画开始播放
      this.animation.start()

      // 动画完成播放的事件
      this.animation.on('complete', () => {

        // 动画结束之后回调onDropped事件
        if (this.isDropped === false) {
          this.options.onDropped()

          // 动画完成后,把isDropped的值修改为是
          // 代表红包已经掉落,理论上没什么用,但是玩意需要父组件做判断呢?
          this.isDropped = true
        }
        // 动画播放完成后,隐藏红包
        this.hidden = false
      })
    }
  }
}
</script>

<style scoped lang="stylus">
* {
  user-select: none
  outline none
}

.box {
  width 0.592rem
  height 0.86rem
  position absolute
  top -1rem
  background url("https://coolcdn.igetcool.com/p/2021/1/f611d435ffb8c8c43c6983d807e27a65.png?_296x430.png")
  background-repeat no-repeat
  background-size contain
  transform rotate(0deg)
}

</style>

因为要通过函数调用的方式调用组件,所以还需要写一个插件。

源码如下:

// 引入Vue
import Vue from 'vue'
// 引入红包组件
import bonusItem from './bonusItem.vue';

// 红包实例
let packet;

// 组件挂载
function createItem(args) {

  // 用vue渲染红包组件并挂载
  const vnode = new Vue({
    render: h => h(bonusItem)
  }).$mount()

  // 将组件添加到body上
  document.body.appendChild(vnode.$el)

  // 返回当前组件的实例
  return vnode.$children[0]
}

export function showPacket(args) {
  // 创建组件
  packet = createItem(args)

  // 将组件实例暴露出去
  return packet
}
export default showPacket

这样,我们就可以通过调用函数的方式动态插入组件了

OK,接下来就是父组件的调用,完整代码如下:

<template>
  <div class="container">
    分数{{point}}
    <br>
    <p v-if="game">游戏结束</p>
    <br>
    <button @click="start">开始游戏</button>
  </div>
</template>

<script>
// 引入红包组件
import showPacket from './bonusItem.js'
export default {
  name: "test",
  data(){
    return {
      // 倒计时
      time: 10,
      // 红包数量
      itemCount: 1,
      // 红包实例存储栈
      dropStack: [],
      // 分数记录
      point: 0,
      // 已经销毁的红包数量
      dropped: 0,
      // 游戏状态
      game:false,
    }
  },
  // 进页面的时候,让页面高度固定为100vh并且不能滚动
  beforeCreate() {
    document.body.style.maxHeight = '100vh'
    document.body.style.overflow = 'hidden'
  },
  // 离开页面之前 恢复,避免不影响其它页面
  beforeDestroy() {
    document.body.style.maxHeight = 'unset'
    document.body.style.overflow = 'unset'
  },
  mounted() {
    // 创建红包DOM,并把每次函数执行返回的dom实例放到存储栈中
    let arr = []
    for(let i in this.itemStack){
      let instance = showPacket()
      arr.push(instance)
      instance.show({
        // 遍历x轴,因为x轴是随机生成的,所以红包掉落的起始位置也是随机的
        xAxis:  this.itemStack[i],
        // 红包掉落的速度
        speed:  3000,
        // 红包点击事件
        onClick: () =>{
          // 每次点击,分数+1
          this.point++
        },
        // 红包销毁事件
        onDropped: () => {
          // 销毁数量加1
          ++this.dropped
          // 如果销毁数量等于红包数量,那么游戏停止
          if(this.dropped === this.itemCount){
            this.game = true
          }
        }
      })
    }
    // 将创建的红包实例存入栈中
    this.dropStack = arr

  },
  methods: {
    // 红包雨开始
    start(){
      this.game = false;
      // 遍历红包栈,并根据事件平均分配掉落的时机
      for(let i in this.dropStack){
        setTimeout(() => {
          this.dropStack[i].start()

          // 假设红包雨持续的事件是10秒,红包数量为20个,那么每个红包掉落的时机就是
          // 当前遍历的索引 * (10秒 * 1000毫秒 / 红包数量)
          // 等于每 i * 10000毫秒 / 20 掉落一个。
          // 这里不懂的可以问我
        }, i * (this.time * 1000 / this.itemCount))
      }
    },
  },
  computed: {
    // 按照红包数量生成对应的X轴随机数
    itemStack(){
      let arr = []
      for(let i = 0; i < this.itemCount; i++){
        // 保证红包的x轴在固定的范围内,根据实际需求控制
        arr.push(Math.floor(Math.random() * ( 300 - 20) + 20))
      }
      return arr
    }
  }
}

</script>

<style scoped lang="stylus">
.container {
  background-size contain
  background-repeat no-repeat
  height 100vh
  overflow hidden
  color #000
  font-size 0.2rem
}
</style>

最终的效果如下:


image.png

因为没有工具录制gif 所以截了个示例图,快去自己试试吧。

搬运本文,请注明原文地址,谢谢。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349

推荐阅读更多精彩内容