vue3如何实现一个拼多多红包大转盘?

1,731 阅读7分钟

105466007_p0_master1200.jpg

PID: 105466007

前言

最近在开发中需要开发活动页面,其中便涉及了制作一个大转盘,在看了几篇文章和类似项目后,有了此demo。

vercel在线演示:laster-demos.vercel.app

码上掘金在线演示:

ps: 码上掘金不太会整,转盘可能转不了😭😢😭

实现大转盘样式

方案比选

最基本的,在页面中创建一个如下正方形:

ps: 以下样式的盒模型都为border-box

image.png

<div class="turntable">
  <div class="turntable__item" style="--color: red"></div>
</div>
<style lang="scss">
.turntable {
  margin-left: 20vw;
  margin-top: 20vh;
  border: solid 1px black;

  --size: 400px;

  height: var(--size);
  aspect-ratio: 1/1;

  display: flex;
  justify-content: center;
  align-items: flex-start;
  position: relative;
}

.turntable__item {
  height: 50%;
  aspect-ratio: 1/1;

  border: solid 1px var(--color);
  position: absolute;
}
</style>

css中有一个属性为transform,将此属性设置为rotate(xdeg), 则可以将元素顺时针选择x个角度。若将上图中的小正方形复制多个并旋转不同的角度则可以形成一个圈。

而设置不同的旋转中心,则可以形成不同的圈。

transform-origin: center bottom;

image.png

transform-origin: right bottom;

image.png

观察前图,对于文字也就是轮盘的每一项,发现是符合轮盘的视觉,最终无需对文字进行旋转等操作;在填充颜色之后并没有形成扇形结构,而是显得杂乱无章。

观察后图,对于文字,发现并不符合轮盘的视觉,因为它是以右下角为旋转中心的;在填充颜色后形成了扇形结构,但是最后一项扇形面积覆盖了部分第一项的扇形面积。

对于前图的缺陷,可以设置一个背景图片来形成所需要的扇形结构:

image.png

对于后图的缺陷可以参考此篇文章:仿拼多多现金大转盘,H5 抽奖转盘如何实现? - 掘金 (juejin.cn)

总结来说就是:对于文字部分可以对轮盘的文字(每一项)都修正一定的角度;对于扇形覆盖部分可以将轮盘分为左右两部分并设置overflow:hidden后拼接成一个完整的轮盘。image.png

两种方案最终都有各自的缺点:

  • 前者当轮盘的奖项是通过后端api获取且项数是可变的时,轮盘的背景图也需要通过后端获取,这需要UI同学设计不同的图片,增加UI同学的工作量。
  • 后者需要分别修正轮盘中每一项的角度,增加代码的复杂度(这在项数是可变的时将会更加复杂);不可以设置奇数数目的项数。

最终我选择了前者作为demo方案, 毕竟谁都不会想给自己增加工作量😋。

样式以及html结构

<template>
  <div ref="turntableRef" class="turntable" :style="sdl">
    <div class="turntable-wrapper">
      <div class="turntable__bg1"></div>
      <div class="turntable__bg2"></div>
      <div class="turntable__container">
        <template v-for="(item, idx) of config.items" :key="item.id">
          <div class="turntable__item" :style="`--idx: ${idx}`">
            {{ item.description }}
            <img :src="item.imgUrl" alt="" />
          </div>
        </template>
      </div>
    </div>

    <div class="turntable-button">
      <div class="turntable-button__inner" @click="handleClick">
        <span>抽奖</span>
        <span>剩余X次</span>
      </div>
    </div>
  </div>
</template>
<style lang="scss">
.turntable {
  position: relative;
  margin-left: 30vw;
  margin-top: 20vh;

  width: 400px;
  height: 400px;

   //这里自是为了idea可以代码提示,最终会被html中绑定的sdl取代
  --size: 400px;
  --current-angle: 0deg;
  --item-angle-size: 60deg;
  --item-correct-angle: 30deg;
  --rotate-duration: 700ms;
  --rotate-cuber: "ease-in-out";
}

.turntable-wrapper {
  transition: all var(--rotate-duration) var(--rotate-cuber);
  transform: rotate(var(--current-angle));
  width: 100%;
  height: 100%;
}

.turntable__bg1,
.turntable__bg2 {
  width: 100%;
  aspect-ratio: 1/1;

  background: url("https://funimg.pddpic.com/spi_main/turntable_arc_bg1.png.slim.png")
    center center no-repeat;
  background-size: cover;

  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  z-index: 9;
}

.turntable__bg2 {
  width: 118%;
  border-radius: 50%;
  background: url("https://funimg.pddpic.com/spi_main/turntable_wrapper_bg.png.slim.png")
    center center no-repeat;
  background-size: cover;

  z-index: 1;
}

.turntable__container {
  width: 100%;
  height: 100%;
  border-radius: 50%;
  overflow: hidden;

  position: absolute;
  top: 0;
  left: 0;
  z-index: 99;

  display: flex;
  align-items: flex-start;
  justify-content: center;
}

.turntable__item {
  height: 50%;
  width: 50%;
  //border: solid 1px #e3d1d1;
  transform: rotate(
    calc(var(--item-angle-size) * var(--idx) + var(--item-correct-angle) + 0deg)
  );
  transform-origin: center bottom;
  position: absolute;

  display: flex;
  flex-direction: column;
  justify-content: flex-start;
  align-items: center;
  padding-top: 10px;
  font-size: 20px;
  font-weight: bold;
  color: #f10f31;

  img {
    width: 50%;
  }
}

.turntable-button {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 35%;
  aspect-ratio: 1/1;
  font-size: 20px;
  font-weight: bold;
  color: #e9f5f1;

  .turntable-button__inner {
    height: 100%;
    width: 100%;
    background: url("https://funimg.pddpic.com/spi_main/turntable_cursor_v4.png.slim.png")
      center center no-repeat;
    background-size: contain;

    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
  }
}
</style>

实现大转盘的逻辑

轮盘配置项TurntableConfig

我们先思考一个大转盘需要哪些变量来控制。

  • 大转盘奖项列表items。为了代码的通用性应该把奖项的个数设置为可变的,奖项的基本类型为:(仅供参考,可以自由配置)
    {
      /* 奖项ID */
      id: number;
      /* 奖项描述文字 */
      description: string;
      /* 奖项描述图片 */
      imgUrl?: string;
    }
    
  • 大转盘的修正角度correctAngle。由于扇形区域是由背景形成的,因此可能和奖项的位置不匹配,需要将奖项适当进行修正。
  • 轮盘大小size。如其字面意思。
  • 轮盘的旋转角度currentAngle。众所周知轮盘是会转的,因此需要一个变量来控制轮盘的旋转角度。
  • 轮盘的旋转时间duration。当我们进行抽奖时通常不是立即旋转到目标奖项,而是旋转一定的时间后才会到达,因此设置一个时间来控制此过程。
  • 轮盘的旋转贝赛尔曲线cuber。当我们进行抽奖时,或者轮盘自转时需要特定的运动曲线,比如抽奖时先缓后快再缓,在自转时匀速。
  • 轮盘抽奖时额外旋转的圈数laps假设奖项一共6个由奖项1旋转到奖项2,需要旋转的角度为60deg, 这么一丢丢的角度做动画显然是不够的,因此需要额外旋转几圈。
  • 标志轮盘是否在抽奖中的isRotating。如其字面意思。
  • 轮盘在旋转到目标奖项时是否左右随机摆动一些角度allowRandom。当旋转到目标奖项时如果不指向目标奖项的正中间,而会左右摆动一些(不超出目标奖项扇形区域)可以增加真实性。
  • 轮盘是否允许自转allowSelfRotate。如其字面意思。
  • 轮盘自转的旋转速度selfRotateSpeed。标志自转时每秒旋转的角度。

最终为:

interface TurntableConfig {
  /* 修正角度,由于轮盘背景由图片决定, 需要适当修正奖项的默认角度 */
  correctAngle: number;
  /* 轮盘大小 */
  size?: string;
  /* 轮盘当前旋转的角度 */
  currentAngle: number;
  /* 轮盘进行旋转时的持续时间 */
  duration: number;
  /* 缓动函数 */
  cuber: string;
  /* 在进行抽奖时额外选择的圈数 */
  laps: number;
  /* 是否正在进行旋转 */
  isRotating: boolean;
  /* 是否允许轮盘在滑动到指定目标时不是指向正中间,增加随机性 */
  allowRandom: boolean;
  /* 是否允许自传 */
  allowSelfRotate: boolean;
  /* 自转的速度, 一秒多少个deg */
  selfRotateSpeed: number;
  /* 轮盘的奖项 */
  items: Array<{
    /* 奖项ID */
    id: number;
    /* 奖项描述文字 */
    description: string;
    /* 奖项描述图片 */
    imgUrl?: string;
  }>;
}

const config: Ref<TurntableConfig> = ref({
  correctAngle: 30,
  size: "400px",
  currentAngle: 0,
  laps: 10,
  allowRandom: true,
  duration: 5000,
  cuber: "ease-in-out",
  isRotating: false,
  allowSelfRotate: true,
  selfRotateSpeed: 15,
  items: [
    {
      id: 0,
      description: "高级红包",
      imgUrl:
        "https://funimg.pddpic.com/common/spi_main/turtable_main_packet/yellow.png.slim.png",
    },
    {
      id: 1,
      description: "提现红包",
      imgUrl:
        "https://funimg.pddpic.com/common/spi_main/turtable_main_packet/green.png.slim.png",
    },
    {
      id: 2,
      description: "0.01元~0.5元",
      imgUrl:
        "https://funimg.pddpic.com/spi_main/fc3f93e0-34b8-43aa-a14b-b7e0c8a65f22.png.slim.png",
    },
    {
      id: 3,
      description: "提现红包*3",
      imgUrl:
        "https://funimg.pddpic.com/spi_main/three_icon_green_packet.png.slim.png",
    },
    {
      id: 4,
      description: "提现红包",
      imgUrl:
        "https://funimg.pddpic.com/common/spi_main/turtable_main_packet/green.png.slim.png",
    },
    {
      id: 5,
      description: "提现红包",
      imgUrl:
        "https://funimg.pddpic.com/common/spi_main/turtable_main_packet/green.png.slim.png",
    },
  ],
});

函数-滑动到指定ID奖项

首先来看最终实现:

async function slideTo(id: number) {
  // 非法情况
  if (config.value.isRotating) return;

  // 设置标志"正在旋转"为真;设置标志"允许自转"为假
  config.value.isRotating = true;
  config.value.allowSelfRotate = false;

  // 旧角度
  const oldAngle = config.value.currentAngle;
  // 每项所占角度
  const itemAngleSize = 360 / config.value.items.length;
  // 需移动角度
  const moveAngle =
    aimAngle -
    (oldAngle % 360) -
    config.value.correctAngle +
    config.value.laps * 360;
  // 随机偏移角度
  let randomAngle = 0;
  // 是否允许随机摆动一定的角度
  if (config.value.allowRandom) {
    let random = Math.random();
    random = random * 2 - 1;
    randomAngle = (random * itemAngleSize) / 2;
  }
  // 移动到目标角度
  config.value.currentAngle += moveAngle + randomAngle;

  // 等待旋转动画执行完成并添加一定的防抖时间
  await delay(config.value.duration + 300);

  // 设置标志"正在旋转"为假;
  config.value.isRotating = false;
}

其中核心便是计算最终需要移动的角度moveAngle

  • 假设奖项的项数是n(config.value.items.length),那么每一项所占角度itemAngleSize 便是一圈的角度360/项数n
  • 假设最终需要到达的奖项索引(从0开始)为id, 则最终需要旋转到的角度itemAngleSize相对于0deg)为itemAngleSize*(n-id) - 修正角度config.value.correctAngle。这里因为转盘是顺时针旋转,因此为每项所占角度乘(n-id)。
  • 最终需要移动的角度又需要减去原始偏移量后加上圈数, 原始偏移量为(旧角度对360deg取模+原始修正角度)。
  • 当转盘不会随机摆动时,最终指针会停留在任一奖项的正中间, 因此随机摆动可以是(-itemAngleSize/2, itemAngleSize/2)的开区间。

函数-自转函数

直接看代码吧,没啥好说的。

async function selfRotate() {
  if (!config.value.allowSelfRotate) return;
  config.value.cuber = "linear";

  config.value.currentAngle +=
    (config.value.duration / 1000) * config.value.selfRotateSpeed;
  await delay(config.value.duration);
  selfRotate().then();
}

写在最后

代码仓库链接:Demos/pages/TurntableDemo.vue at master · lastertd/Demos (github.com)

感谢看到最后💟💟💟