Vue3实现转盘抽奖功能

前言

 之前的文章有写过九宫格抽奖功能【 Vue3实现九宫格抽奖功能 】, 有兴趣的可以看一看给个赞啥的,今天我们看看如何实现一个转盘抽奖功能。

 之前公司也写过转盘抽奖功能,实现流程大概是设计师出个转盘抽奖设计图,礼品固定,后台请求接口返回中奖数据,然后前端去实现动效,这么做的缺点就比较固定,比如奖品无法配置,中奖概率也无法配置。我在各大网站都逛了一圈,基本上都上这种固定的抽奖配置,很少有动态配置奖品和中奖概率的转盘抽奖,基于此我今天就抽空来实现一个可动态配置奖品的转盘抽象功能。

功能与效果图

功能:
1、礼物可后台动态配置
2、转盘奖品布局和背景动态生成(重点)
3、转动速度和时间可配置,速度先快后慢
4、记录上一次停止位置,开始转动不重置,转动位置最终停止在转盘指针中间
5、动画停止可触发中奖提示或实现其它功能
6、中奖概率可配置(这里先不管,后面有机会总结一些抽奖算法)

效果图如下:

GIF 转盘抽奖动效图

实现

第一步:转盘布局

思路:首先需要一个转盘,然后需要一个抽奖按钮定位在中间,按钮定位在中间很简单,难的是扇形背景颜色如何实现,奖品如何均匀分布在每个扇形中间,因为奖品和可配置的,所以扇形大小可变化的。

<div id="app" v-cloak>
  <div class="container">
    <!-- 背景 -->
    <div class="prize-list" ref="prizeWrap" :style="bgColor">
      <!-- 奖品列表 -->
      <div class="prize-item" v-for="(item, index) in prizeList" :style="prizeStyle(index)">
        <img :src="item.pic" alt="">
        <p>{{ item.name }}</p>
      </div>
    </div>
    <div class="btn" @click="start"></div>
  </div>
</div>

第一个难点:如何绘制扇形背景,而且扇形数量动态?扇形背景我们可以使用css的裁剪属性clip-path 或者css中的锥形渐变函数conic-gradient()来实现,这里我们使用conic-gradient()函数实现扇形背景更为简单。由于奖品可配置数量不定,所以背景的扇形我们可以动态计算生成,这里我们用到了vue3的计算属性computed实现。

bgColor 计算属性实现如下:

// 计算绘制转盘背景
const bgColor = computed(() => {
  const _len = state.prizeList.length
  const colorList = ['#5352b3', '#363589']
  let colorVal = ''
  for (let i = 0; i < _len; i++) {
    colorVal += `${colorList[i % 2]} ${rotateAngle.value * i}deg ${rotateAngle.value * (i + 1)}deg,`
  }
  return `
    background: conic-gradient(${colorVal.slice(0, -1)});
  `
})

根据奖品数组长度计算出每个扇形的角度,然后颜色可配置,rotateAngle计算属性为所有奖品平均分布在圆上的角度,这样一个转盘的背景就动态绘制成功了。

第二个难点:奖品信息如何平均分布在背景扇形里,并且居中显示呢?这里我们可以利用CSS transform 属性中的旋转函数rotate()来实现,这里我们要均匀的分布,也需要使用计算属性来实现。

prizeStyle(index) 计算属性实现如下:

// 每个奖品布局
const prizeStyle = computed(() => {
  const _degree = rotateAngle.value
  return (i) => {
    return `
      width: ${2 * 270 * Math.sin(_degree / 2 * Math.PI / 180)}px;
      height: 270px;
      transform: rotate(${_degree * i + _degree / 2}deg);
      transform-origin: 50% 100%;
    `
  }
})

动态计算每个奖品的布局,通过rotate()函数和奖品索引计算每个div的旋转角度,这里注意旋转的源点,我们所有奖品的div布局是定位再圆心靠上,所以这里要改变旋转原点为底部居中,值为transform-origin: 50% 100%,这里才会均匀分布在背景圆上。

第三个难点:因为扇形大小是动态的,每个奖品div盒子长宽不能太大,要大概限制在扇形里面,这样礼物图片和礼物名字的布局才好限制不会超出扇形,我们怎么计算奖品布局的容器div大小呢?其实这里我们只需要计算出div的宽即可,因为高为圆的半径,那宽度如何确定?

扇形.png

如上图,我们要计算大概宽度,即计算BC的长度,A点为圆心,∠CAB和边AC、AB我们已知,实际上这个问题就变得简单了,知道角度和两条邻边长度,我们可以利用我们高中所学的正弦余弦函数来求BC的值了,不会还有人不会吧?然后利用Math对象中的Math.sin(x) 函数求出BC长度即可,需要注意的是JavaScript中的正弦函数参数x是一个数值(以弧度为单位),而数据中是角度值,这里我们只要稍加转化求出∠CAB所对应的弧度长即可,这里不会有人不会计算弧长吧?不会吧?

宽度计算如下:

width: ${2 * 270 * Math.sin(_degree / 2 * Math.PI / 180)}px;

_degree为扇形角度,因为正弦函数只能在直角三角形中使用,所以我们作辅助线构造一个直角三角形来计算即可,不清楚的可以重温下高中知识,正弦余弦函数,高考必考点哦。

这样布局就基本完成了。

第二步:转动效果

思路:点击抽奖按钮请求中奖数据,然后开始转动转盘,这里抽奖后台实现,前端请求接口即可,暂不讨论。

第一个问题:转动效果实现方式有很多,我们可以使用js实现,也可以使用css方式实现,众所周知 ,能使用css实现的绝不使用js,优势不用我多说吧,懂得都懂。那动效如何实现呢?这里我们利用过渡属性transition和旋转函数rotate()即可轻松的实现转动特效,而且动画时长和速度可以很方便的控制。

第二个问题:css实现的话动效停止提示和后续操作如何实现?我们使用了过渡属性transition,而恰好在js中有个监听过渡属性结束的事件transitionend,当转动停止时就会触发改事件,我们就可以做一些后续操作包括提示中奖信息等。

部分代码如下:

const startRun = () => {
  console.log(state.isRunning, totalRunAngle.value)
  // 设置动效
  prizeWrap.value.style = `
    ${bgColor.value}
    transform: rotate(${totalRunAngle.value}deg);
    transition: all 4s ease;
  `
  // 监听transition动效停止事件
  prizeWrap.value.addEventListener('transitionend', stopRun)
}

const stopRun = (e) => {
  console.log(e)
  state.isRunning = false
  prizeWrap.value.style = `
    ${bgColor.value}
    transform: rotate(${totalRunAngle.value - state.baseRunAngle}deg);
  `
}

点击开始转动后给转动的div设置旋转度数和过渡效果,停止时移除过渡效果,然后旋转角度重置到上一次中奖的角度初始值。

完整Demo代码

有不理解的地方可以copy代码到自己本地调试,完整代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    * {  margin: 0; padding: 0; box-sizing: border-box; }
    [v-cloak] {
      display: none;
    }
    .container {
      width: 540px;
      height: 540px;
      /*background: #98d3fc url('https://www.jq22.com/demo/vue-luck-draw-pdmm202010260015/img/bg.a4b976d5.png') no-repeat center / 100% 100%;*/
      /*background: conic-gradient( 
       red 6deg, orange 6deg 18deg, yellow 18deg 45deg, 
       green 45deg 110deg, blue 110deg 200deg, purple 200deg);*/
      margin: 100px auto;
      position: relative;
    }
    .prize-list {
      width: 100%;
      height: 100%;
      border-radius: 50%;
      border: 10px solid #98d3fc;
      overflow: hidden;
    }
    .prize-item {
      /*border: 2px solid red;*/
      position: absolute;
      left: 0;
      right: 0;
      top: -10px;
      margin: auto;
    }
    .prize-item img {
      width: 30%;
      height: 20%;
      margin: 40px auto 10px;
      display: block;
    }
    .prize-item p {
      color: #fff;
      font-size: 12px;
      text-align: center;
      line-height: 20px;
    }
    .btn {
      width: 160px;
      height: 160px;
      background: url('https://www.jq22.com/demo/jquerylocal201912122316/img/btn_lottery.png') no-repeat center / 100% 100%;
      position: absolute;
      left: 0;
      right: 0;
      top: 0;
      bottom: 0;
      margin: auto;
      cursor: pointer;
    }
    .btn::before {
      content: "";
      width: 41px;
      height: 39px;
      background: url('https://www.jq22.com/demo/jquerylocal201912122316/img/icon_point.png') no-repeat center / 100% 100%;
      position: absolute;
      left: 0;
      right: 0;
      top: -33px;
      margin: auto;
    }
  </style>
</head>
<body>
  <div id="app" v-cloak>
    <div class="container">
      <div class="prize-list" ref="prizeWrap" :style="bgColor">
        <div class="prize-item" v-for="(item, index) in prizeList" :style="prizeStyle(index)">
          <img :src="item.pic" alt="">
          <p>{{ item.name }}</p>
        </div>
      </div>
      <div class="btn" @click="start"></div>
    </div>
  </div>

  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  <script>
    const { createApp, onMounted, onUnmounted, ref, reactive, toRefs, computed, nextTick } = Vue
    
    createApp({
      setup () {
        const state = reactive({
          prizeList: [
            { name: '手机', pic: 'https://bkimg.cdn.bcebos.com/pic/3801213fb80e7bec54e7d237ad7eae389b504ec23d9e' },
            { name: '手表', pic: 'https://img1.baidu.com/it/u=2631716577,1296460670&fm=253&fmt=auto&app=120&f=JPEG' },
            { name: '苹果', pic: 'https://img2.baidu.com/it/u=2611478896,137965957&fm=253&fmt=auto&app=138&f=JPEG' },
            { name: '棒棒糖', pic: 'https://img2.baidu.com/it/u=576980037,1655121105&fm=253&fmt=auto&app=138&f=PNG' },
            { name: '娃娃', pic: 'https://img2.baidu.com/it/u=4075390137,3967712457&fm=253&fmt=auto&app=138&f=PNG' },
            { name: '木马', pic: 'https://img1.baidu.com/it/u=2434318933,2727681086&fm=253&fmt=auto&app=120&f=JPEG' },
            { name: '德芙', pic: 'https://img0.baidu.com/it/u=1378564582,2397555841&fm=253&fmt=auto&app=120&f=JPEG' },
            { name: '玫瑰', pic: 'https://img1.baidu.com/it/u=1125656938,422247900&fm=253&fmt=auto&app=120&f=JPEG' }
          ], // 后台配置的奖品数据
          isRunning: false, // 是否正在抽奖
          baseRunAngle: 360 * 5, // 总共转动角度 至少5圈
          prizeId: 0, // 中奖id
        })
        const prizeWrap = ref(null)


        // 平均每个奖品角度
        const rotateAngle = computed(() => {
          const _degree = 360 / state.prizeList.length
          return _degree
        })

        // 要执行总角度数
        const totalRunAngle = computed(() => {
          return state.baseRunAngle + 360 - state.prizeId * rotateAngle.value - rotateAngle.value / 2
        })

        // 计算绘制转盘背景
        const bgColor = computed(() => {
          const _len = state.prizeList.length
          const colorList = ['#5352b3', '#363589']
          let colorVal = ''
          for (let i = 0; i < _len; i++) {
            colorVal += `${colorList[i % 2]} ${rotateAngle.value * i}deg ${rotateAngle.value * (i + 1)}deg,`
          }
          return `
            background: conic-gradient(${colorVal.slice(0, -1)});
          `
        })
        // 每个奖品布局
        const prizeStyle = computed(() => {
          const _degree = rotateAngle.value
          return (i) => {
            return `
              width: ${2 * 270 * Math.sin(_degree / 2 * Math.PI / 180)}px;
              height: 270px;
              transform: rotate(${_degree * i + _degree / 2}deg);
              transform-origin: 50% 100%;
            `
          }
        })

        onMounted(() => {
          prizeWrap.value.style = `${bgColor.value} transform: rotate(-${rotateAngle.value / 2}deg)`
        })

        onUnmounted(() => {
          prizeWrap.value.removeEventListener('transitionend', stopRun)
        })

        // 获取随机数
        const getRandomNum = () => {
          const num = Math.floor(Math.random() * state.prizeList.length)
          return num        
        }

        const start = () => {
          if (!state.isRunning) {
            state.isRunning = true

            console.log('开始抽奖,后台请求中奖奖品')
            // 请求返回的奖品编号 这里使用随机数
            const prizeId = getRandomNum()
            console.log('中奖ID>>>', prizeId, state.prizeList[prizeId])
            state.prizeId = prizeId
            startRun()
          }
        }

        const startRun = () => {
          console.log(state.isRunning, totalRunAngle.value)
          // 设置动效
          prizeWrap.value.style = `
            ${bgColor.value}
            transform: rotate(${totalRunAngle.value}deg);
            transition: all 4s ease;
          `
          // 监听transition动效停止事件
          prizeWrap.value.addEventListener('transitionend', stopRun)
        }

        const stopRun = (e) => {
          console.log(e)
          state.isRunning = false
          prizeWrap.value.style = `
            ${bgColor.value}
            transform: rotate(${totalRunAngle.value - state.baseRunAngle}deg);
          `
        }

        return {
          ...toRefs(state),
          bgColor,
          prizeStyle,
          prizeWrap,
          start
        }
      }
    }).mount('#app')
  </script>
</body>
</html>

结尾

上面就是奖品可配置的转盘抽奖功能的基本实现,Demo代码是使用Vue3编写的,也没有封装成组件,但是具有一些参考意义,用于生产可以自己去封装成组件使用,完整的代码在我的GitHub仓库


本文是笔者总结编撰,如有偏颇,欢迎留言指正,若您觉得本文对你有用,不妨点个赞~

关于作者:
GitHub
简书
掘金

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

推荐阅读更多精彩内容