Vue3 + GSAP 文章推荐: 前端动效天花板?GSAP带你突破极限!
大家好!今天给大家分享的是利用Vue3 + GSAP 设计和开发一个极致用户体验的转盘抽奖系统
一、需求分析
一个转盘抽奖功能应该具备哪些要素呢?
- 首先肯定要有个转盘
- 转盘中平均分配着奖品
- 有个指针指向抽中的奖品
- 有个点击开始抽奖的按钮,点击之后转盘开始转动,停下来的时候,指针指向的奖品就是中奖的奖品
- 抽奖次数限制(最多可以抽几次)
- 中奖概率计算
二、技术点分析
从上面的需求分析可以得到以下技术实现方案:
- 转盘是个原型,可以用border-radius 实现
- 平均分配的扇形实现:根据奖品的个数计算出每个扇形角度的大小,利用css 变量进行对每个扇形旋转角度进行赋值
- 每个扇形如何实现: 给外层的圆形div 增加overflow: hidden, 再利用clip-path 属性设置及可以画出每一个扇形
- 指针可以利用border 画出一个三角形来代表指针
- 点击开始抽奖之后的旋转效果可以利用gsap,来实现,gsap 是一个非常强大的动画库,gsap可以很方便的控制动画效果。
三、代码实现
1. 初始化一个Vue3项目
pnpm create vite lottery --template
cd lottery
pnpm install
2. 页面布局
<template>
<div class="lottery-container">
<h1>幸运大转盘</h1>
<div class="prize-wheel-container">
<div class="wheel" ref="wheelRef">
<div
v-for="(prize, index) in prizes"
:key="index"
class="prize-segment"
:style="getSegmentStyle(index)"
>
<span class="prize-text">{{ prize.name }}</span>
<div class="prize-icon" v-if="prize.icon">
<img :src="requireImg(prize.icon)" />
</div>
</div>
</div>
<div class="pointer">
<div class="pointer-arrow"></div>
<div class="pointer-text" @click="startLottery">开始抽奖</div>
</div>
<div class="light-effect"></div>
</div>
<div class="lottery-info">
<p>剩余抽奖次数: {{ lotteryTimes }}</p>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
// 奖品配置
const prizes = ref([
{
name: '一等奖',
value: 'iPhone 15',
icon: 'phone-15',
color: '#FF5252',
},
{ name: '谢谢参与', value: 'none', icon: 'xiexie', color: '#FFEB3B' },
{
name: '二等奖',
value: '华为 mate40',
icon: 'huawei-mate40',
color: '#4CAF50',
},
{
name: '优惠券',
value: 'coupon',
icon: 'coupon',
color: '#2196F3',
},
{
name: '三等奖',
value: '小米10',
icon: 'xiaomi-10',
color: '#9C27B0',
},
{
name: '四等奖',
value: '华为平板',
icon: 'huawei-pingban',
color: '#795548',
},
{
name: '五等奖',
value: '现金200元',
icon: 'xianjin',
color: '#607D8B',
},
])
const lotteryTimes = ref(3)
const segmentAngle = computed(() => 360 / prizes.value.length)
const getSegmentStyle = index => {
const angle = index * segmentAngle.value
return {
'--rotate': `${angle}deg`,
background: prizes.value[index].color,
'--text-rotate': `${-segmentAngle.value / 2}deg`,
}
}
// vue3+vite 实现动态加载图片
const requireImg = name => {
return new URL(`./assets/${name}.svg`, import.meta.url).href
}
const startLottery = () => {}
</script>
<style scoped>
.lottery-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 30px;
background: linear-gradient(15deg, #f5f7fa 0%, #c3cfe2 100%);
min-height: 100vh;
}
.title {
color: #333;
margin-bottom: 30px;
font-size: 28px;
text-shadow: 1px 1px 2px rgba(0, 0, 0 0.1);
}
.prize-wheel-container {
position: relative;
width: 300px;
height: 300px;
margin: 60px auto 30px;
}
.particle {
z-index: 99;
}
.wheel {
width: 100%;
height: 100%;
border-radius: 50%;
position: relative;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
border: 8px solid #fff;
background: #fff;
}
.prize-segment {
position: absolute;
width: 50%;
height: 50%;
left: 0;
top: 0;
transform-origin: bottom right;
transform: rotate(var(--rotate));
display: flex;
flex-direction: column;
align-content: center;
justify-content: flex-start;
padding-top: 20px;
padding-left: 64px;
box-sizing: border-box;
clip-path: polygon(-26% 0, 100% 0, 100% 100%);
}
.prize-text {
transform: rotate(var(--text-rotate));
width: 60px;
text-align: center;
font-weight: bold;
color: #fff;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3);
font-size: 14px;
margin-bottom: 5px;
}
.prize-icon {
transform: rotate(var(--text-rotate));
color: white;
font-size: 24px;
text-align: center;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3);
}
.prize-icon img {
width: 40px;
}
.pointer {
position: absolute;
top: -30px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
cursor: pointer;
transition: all 0.3s;
}
.pointer:hover {
transform: translateX(-50%) scale(1.1);
}
.pointer-arrow {
width: 0;
height: 0;
border-left: 25px solid transparent;
border-right: 25px solid transparent;
border-top: 50px solid #e91e63;
filter: drop-shadow(0 2px 5px rgba(0, 0, 0, 0.3));
}
.pointer-text {
position: absolute;
top: -40px;
left: 50%;
transform: translateX(-50%);
background: #e91e63;
color: #fff;
padding: 5px 15px;
border-radius: 20px;
font-weight: bold;
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16);
white-space: nowrap;
}
.light-effect {
position: absolute;
top: -20px;
left: -20px;
right: -20px;
bottom: -20px;
background: radial-gradient(
circle,
rgba(255, 255, 255, 0.8) 0%,
rgba(255, 255, 255, 0) 70%
);
}
.lottery-info {
background: white;
padding: 10px 20px;
border-radius: 20px;
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.1);
font-size: 16px;
color: #666;
}
</style>
以上代码中:
- segmentAngle 计算属性 根据奖品个数计算出一个扇形所站的圆心角
- getSegmentStyle 方法 通过segmentAngle 和index 计算出每个扇形需要旋转的角度,在计算文案需要旋转的角度,在模板中调用该方法赋值给style,这样每个扇形就拥有了对应的css 变量
- 在css 中 通过rotate(var(--rotate)) 和得到扇形旋转角度和文案旋转角度
- 在css 中使用clip-path 和overflow 属性设置得到想要的扇形
3. 添加点击开始抽抽奖的功能
前面说过点击开始抽奖功能之后转盘的旋转特效使用GSAP来实现,因为GSAP 动画效果丰富,且非常方便控制。所以先来安装下GSAP
执行 pnpm install gsap
代码实现:
<template>
<div class="lottery-container">
<h1>幸运大转盘</h1>
<div class="prize-wheel-container">
<div class="wheel" ref="wheelRef">
<div
v-for="(prize, index) in prizes"
:key="index"
class="prize-segment"
:style="getSegmentStyle(index)"
>
<span class="prize-text">{{ prize.name }}</span>
<div class="prize-icon" v-if="prize.icon">
<img :src="requireImg(prize.icon)" />
</div>
</div>
</div>
<div class="pointer">
<div class="pointer-arrow"></div>
<div class="pointer-text" @click="startLottery">开始抽奖</div>
</div>
<div class="light-effect"></div>
</div>
<div class="lottery-info">
<p>剩余抽奖次数: {{ lotteryTimes }}</p>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { gsap } from 'gsap'
// 奖品配置
const prizes = ref([
{
name: '一等奖',
value: 'iPhone 15',
icon: 'phone-15',
color: '#FF5252',
},
{ name: '谢谢参与', value: 'none', icon: 'xiexie', color: '#FFEB3B' },
{
name: '二等奖',
value: '华为 mate40',
icon: 'huawei-mate40',
color: '#4CAF50',
},
{
name: '优惠券',
value: 'coupon',
icon: 'coupon',
color: '#2196F3',
},
{
name: '三等奖',
value: '小米10',
icon: 'xiaomi-10',
color: '#9C27B0',
},
{
name: '四等奖',
value: '华为平板',
icon: 'huawei-pingban',
color: '#795548',
},
{
name: '五等奖',
value: '现金200元',
icon: 'xianjin',
color: '#607D8B',
},
])
const segmentAngle = computed(() => 360 / prizes.value.length)
const getSegmentStyle = index => {
const angle = index * segmentAngle.value
return {
'--rotate': `${angle}deg`,
background: prizes.value[index].color,
'--text-rotate': `${-segmentAngle.value / 2}deg`,
}
}
// vue3+vite 实现动态加载图片
const requireImg = name => {
return new URL(`./assets/${name}.svg`, import.meta.url).href
}
const lotteryTimes = ref(3) // 剩余抽奖次数
const wheelRef = ref(null)
const isRotating = ref(false)
const currentRotation = ref(0)
const startLottery = async () => {
// 如果在抽奖过程中或者剩余抽奖次数为0则不做任何操作
if (isRotating.value || lotteryTimes.value <= 0) return
isRotating.value = true
lotteryTimes.value--
// 模拟API 请求获取抽奖结果
const restult = await mockLotteryAPI()
const winnerIndex = prizes.value.findIndex(p => p.value === restult.prize)
const centerAngel = segmentAngle.value / 2
// 计算目标旋转角度(5圈) + 目标角度
const targetAngle = 360 * 5 + winnerIndex * segmentAngle.value - centerAngel
// 创建GSAP动画时间轴
const tl = gsap.timeline({
onComplete: () => {
showPrizeAlert(winnerIndex)
isRotating.value = false
},
})
tl.to(wheelRef.value, {
rotation: currentRotation.value + 360 * 2 + centerAngel,
duration: 2,
ease: 'power2.inOut',
modifiers: {
rotating: r => {
r = parseFloat(r) % 360
return r + 'deg'
},
},
}).to(
wheelRef.value,
{
rotation: -targetAngle,
duration: 3,
ease: 'elastic.out(1, 0.5)',
modifiers: {
rotating: r => {
r = parseFloat(r) * 360
currentRotation.value = r
return r + 'deg'
},
},
},
'>'
)
}
// 模拟API请求的方法
const mockLotteryAPI = () => {
return new Promise(resolve => {
setTimeout(() => {
// 这里模拟一个有概率的抽奖结果
const random = Math.random()
let prizeValue
if (random < 0.05) prizeValue = 'iPhone 15' // 5% 一等奖
else if (random < 0.08) prizeValue = '华为 mate40' // 8% 二等奖
else if (random < 0.15) prizeValue = '小米10' // 7% 三等奖
else if (random < 0.25) prizeValue = '华为平板' // 10% 四等奖
else if (random < 0.45) prizeValue = '现金200元' // 20% 五等奖
else if (random < 0.7) prizeValue = 'coupon' // 25% 优惠券
else prizeValue = 'none' // 35% 谢谢参与
resolve({ prize: prizeValue })
}, 500)
})
}
// 中奖信息提示
const showPrizeAlert = index => {
const prize = prizes.value[index]
if (prize.value === 'none') {
alert('很遗憾,这次没有中奖,下次再接再厉!')
} else {
alert(`恭喜您获得:${prize.name}, ${prize.value}一个`)
}
}
onMounted(() => {
gsap.set(wheelRef.value, { rotation: segmentAngle.value / 2 })
})
</script>
<style scoped>
.lottery-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 30px;
background: linear-gradient(15deg, #f5f7fa 0%, #c3cfe2 100%);
min-height: 100vh;
}
.title {
color: #333;
margin-bottom: 30px;
font-size: 28px;
text-shadow: 1px 1px 2px rgba(0, 0, 0 0.1);
}
.prize-wheel-container {
position: relative;
width: 300px;
height: 300px;
margin: 60px auto 30px;
}
.particle {
z-index: 99;
}
.wheel {
width: 100%;
height: 100%;
border-radius: 50%;
position: relative;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
border: 8px solid #fff;
background: #fff;
}
.prize-segment {
position: absolute;
width: 50%;
height: 50%;
left: 0;
top: 0;
transform-origin: bottom right;
transform: rotate(var(--rotate));
display: flex;
flex-direction: column;
align-content: center;
justify-content: flex-start;
padding-top: 20px;
padding-left: 64px;
box-sizing: border-box;
clip-path: polygon(-26% 0, 100% 0, 100% 100%);
}
.prize-text {
transform: rotate(var(--text-rotate));
width: 60px;
text-align: center;
font-weight: bold;
color: #fff;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3);
font-size: 14px;
margin-bottom: 5px;
}
.prize-icon {
transform: rotate(var(--text-rotate));
color: white;
font-size: 24px;
text-align: center;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3);
}
.prize-icon img {
width: 40px;
}
.pointer {
position: absolute;
top: -30px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
cursor: pointer;
transition: all 0.3s;
}
.pointer:hover {
transform: translateX(-50%) scale(1.1);
}
.pointer-arrow {
width: 0;
height: 0;
border-left: 25px solid transparent;
border-right: 25px solid transparent;
border-top: 50px solid #e91e63;
filter: drop-shadow(0 2px 5px rgba(0, 0, 0, 0.3));
}
.pointer-text {
position: absolute;
top: -40px;
left: 50%;
transform: translateX(-50%);
background: #e91e63;
color: #fff;
padding: 5px 15px;
border-radius: 20px;
font-weight: bold;
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16);
white-space: nowrap;
}
.light-effect {
position: absolute;
top: -20px;
left: -20px;
right: -20px;
bottom: -20px;
background: radial-gradient(
circle,
rgba(255, 255, 255, 0.8) 0%,
rgba(255, 255, 255, 0) 70%
);
}
.lottery-info {
background: white;
padding: 10px 20px;
border-radius: 20px;
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.1);
font-size: 16px;
color: #666;
}
</style>
在上述代码中:
- 导入了gsap
- 点击开始抽奖调用了startLottery方法, 在startLottery 方法中
-
先判断上次抽奖是否结束,如果没有结束或者抽奖剩余次数为0 则直接返回,不做任何操作
-
接着调用了mockLotteryAPI 方法,mockLotteryAPI 是一个模拟api请求的方法,得到获奖对应的奖品名称
-
根据模拟请求得到的奖品名称匹配得到对应奖品信息的索引,计算出中奖的奖品需要旋转多少角度到达指针对应的位置
-
调用gsap的timeline方法得到一个时间轴动画
-
调用gsap的to方法处理旋转动画
-
timeline 上的动画执行完成之后会调用 onComplete 回调方法
-
在onComplete 方法中就可以调用showPrizeAlert 方法告诉用户中奖的信息
-
重置isRotating 的值告诉用户可以进行再次抽奖了 效果演示:
-
四、总结
今天主要分享了开发一个抽奖功能的过程:
- 扇形布局技巧
- 抽奖旋转角度计算技巧
- gsap 实现动画特效的方法
今天的分享就到这里了,感谢收看。