391 lines
13 KiB
Vue
391 lines
13 KiB
Vue
<template>
|
||
<view class="raffle-wheel" :style="{ width: canvasWidth + 44 + 'px', height: canvasHeight + 44 + 'px'}">
|
||
<view class="raffle-wheel-wrap" :style="{width: canvasWidth + 'px', height: canvasHeight + 'px'}">
|
||
<canvas
|
||
:class="className"
|
||
:canvas-id="canvasId"
|
||
:width="canvasWidth"
|
||
:height="canvasHeight"
|
||
:style="{
|
||
width: canvasWidth + 'px',
|
||
height: canvasHeight + 'px'
|
||
}"
|
||
/>
|
||
<image
|
||
class="canvas-img"
|
||
:src="canvasImg"
|
||
:style="{
|
||
width: canvasWidth + 'px',
|
||
height: canvasHeight + 'px',
|
||
transform: `rotate(${canvasAngle + targetAngle}deg)`,
|
||
transitionDuration: `${transitionDuration}s`
|
||
}"
|
||
v-if="canvasImg"></image>
|
||
<view class="raffle-wheel__action" @click="handleAction"></view>
|
||
<!-- 为了兼容 app 端 ctx.measureText 所需的标签 -->
|
||
<text class="raffle-wheel__measureText">{{ measureText }}</text>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
export default {
|
||
name: 'RaffleWheel',
|
||
props: {
|
||
// canvas 宽度
|
||
canvasWidth: {
|
||
type: Number,
|
||
default: 240
|
||
},
|
||
// canvas 高度
|
||
canvasHeight: {
|
||
type: Number,
|
||
default: 240
|
||
},
|
||
// 奖品列表
|
||
prizeList: {
|
||
type: Array,
|
||
// 必须是偶数
|
||
validator: function (value) {
|
||
return value.length % 2 === 0
|
||
},
|
||
required: true
|
||
},
|
||
// 奖品区块对应背景颜色
|
||
colors: {
|
||
type: Array,
|
||
default: () => [
|
||
'#FFF',
|
||
'#FFE9AA'
|
||
],
|
||
// 必须是偶数且仅为 2 个颜色相互交替
|
||
validator: function (value) {
|
||
return value.length === 2
|
||
}
|
||
},
|
||
// 旋转动画时间 单位s
|
||
duration: {
|
||
type: Number,
|
||
default: 8
|
||
},
|
||
// 旋转的圈数
|
||
ringCount: {
|
||
type: Number,
|
||
default: 8
|
||
},
|
||
// 字体颜色
|
||
fontColor: {
|
||
type: String,
|
||
default: '#C30B29'
|
||
},
|
||
// 文字的大小
|
||
fontSize: {
|
||
type: String,
|
||
default: '12px'
|
||
},
|
||
// 奖品文字多行情况下的行高
|
||
lineHeight: {
|
||
type: Number,
|
||
default: 20
|
||
},
|
||
// 奖品名称所对应的 key 值
|
||
strKey: {
|
||
type: String,
|
||
required: true
|
||
},
|
||
// 奖品文字总长度限制
|
||
strMaxLength: {
|
||
type: Number,
|
||
default: 12
|
||
},
|
||
// 奖品文字多行情况下第一行文字长度
|
||
strLineLength: {
|
||
type: Number,
|
||
default: 6
|
||
}
|
||
},
|
||
data () {
|
||
return {
|
||
// 画板className
|
||
className: 'raffle-wheel__canvas',
|
||
// 画板标识
|
||
canvasId: 'raffleWheelCanvas',
|
||
// 画板导出的图片
|
||
canvasImg: '',
|
||
// 旋转到奖品目标需要的角度
|
||
targetAngle: 0,
|
||
// 旋转动画时间 单位 s
|
||
transitionDuration: 0,
|
||
// 是否正在旋转
|
||
isRotate: false,
|
||
// 当前停留在那个奖品的序号
|
||
stayIndex: 0,
|
||
// 解决 app 不支持 measureText 的问题
|
||
measureText: ''
|
||
}
|
||
},
|
||
computed: {
|
||
// 设备像素密度
|
||
pixelRatio () {
|
||
return uni.getSystemInfoSync().pixelRatio
|
||
},
|
||
// 根据奖品列表计算 canvas 旋转角度
|
||
// 让 启动按钮指针 在奖品分区中间 position = 45
|
||
// 让 启动按钮指针 在奖品分区边界 position = 90
|
||
canvasAngle () {
|
||
let prizeCount = this.prizeList.length
|
||
let position = 90
|
||
if (prizeCount % 4 !== 0) {
|
||
return 0
|
||
} else {
|
||
let num = prizeCount / 4
|
||
return num % 2 === 0 ? position / num : position
|
||
}
|
||
},
|
||
// 根据画板的宽度计算奖品文字与中心点的距离
|
||
textRadius () {
|
||
return Math.round(this.canvasWidth / 2.4)
|
||
}
|
||
},
|
||
methods: {
|
||
// 开始旋转
|
||
handleStartRotate (targetIndex) {
|
||
// 奖品总数
|
||
let prizeCount = this.prizeList.length
|
||
let baseAngle = 360 / prizeCount
|
||
let angles = 0
|
||
if (this.targetAngle === 0) {
|
||
console.log('第一次旋转')
|
||
// 因为第一个奖品是从0°开始的,即水平向右方向
|
||
// 第一次旋转角度 = 270度 - (停留的序号-目标序号) * 每个奖品区间角度 - 每个奖品区间角度的一半 - canvas自身旋转的度数
|
||
angles = (270 - (targetIndex - this.stayIndex) * baseAngle - baseAngle / 2) - this.canvasAngle
|
||
} else {
|
||
console.log('后续旋转')
|
||
// 后续继续旋转 就只需要计算停留的位置与目标位置的角度
|
||
angles = -(targetIndex - this.stayIndex) * baseAngle
|
||
}
|
||
// 更新目前序号
|
||
this.stayIndex = targetIndex
|
||
// 转 8 圈,圈数越多,转的越快
|
||
this.targetAngle += angles + 360 * this.ringCount
|
||
|
||
// 计算转盘结束对时间,预加一些延迟确保转盘停止后触发结束事件
|
||
let endTime = this.transitionDuration * 1000 + 100
|
||
setTimeout(() => {
|
||
this.isRotate = false
|
||
this.$emit('actionEnd', this.stayIndex)
|
||
}, endTime)
|
||
},
|
||
// 点击 开始抽奖 按钮
|
||
handleAction () {
|
||
if (this.isRotate) return
|
||
this.isRotate = true
|
||
this.$emit('actionStart')
|
||
},
|
||
// 渲染转盘
|
||
async drawWheelCanvas () {
|
||
// 获取 canvas 画布
|
||
const canvasId = this.canvasId
|
||
const ctx = uni.createCanvasContext(canvasId, this)
|
||
|
||
// canvas 的宽高
|
||
let canvasW = this.canvasWidth
|
||
let canvasH = this.canvasHeight
|
||
|
||
// 根据奖品个数计算 角度
|
||
let prizeCount = this.prizeList.length
|
||
let baseAngle = Math.PI * 2 / prizeCount
|
||
// 在给定矩形内清空一个矩形
|
||
ctx.clearRect(0, 0, canvasW, canvasH)
|
||
|
||
// 设置描边颜色
|
||
ctx.strokeStyle = '#FFBE04'
|
||
|
||
// 设置字号字体,Canvas 文本字号字体的默认值是 10px sans-serif,这里必须对 字号 字体 同时覆盖
|
||
let family = "-apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', STHeiti, 'Microsoft Yahei', Tahoma, Simsun, sans-serif"
|
||
ctx.font = `${this.fontSize} ${family}`
|
||
|
||
// 注意,开始画的位置是从0°角的位置开始画的。也就是水平向右的方向。
|
||
// 画具体内容
|
||
for (let i = 0; i < prizeCount; i++) {
|
||
// 当前角度
|
||
let angle = i * baseAngle
|
||
// console.log('当前角度', angle)
|
||
|
||
// 开始画内容
|
||
ctx.beginPath()
|
||
|
||
// 开始画圆弧
|
||
// context.arc(x, y, ratius, startAngle, endAngle, anticlockwise)
|
||
// x => 圆弧对应的圆心横坐标 x
|
||
// y => 圆弧对应的圆心横坐标 y
|
||
// radius => 圆弧的半径大小
|
||
// startAngle => 圆弧开始的角度,单位是弧度
|
||
// endAngle => 圆弧结束的角度,单位是弧度
|
||
// anticlockwise(可选) => 绘制方向,true 为逆时针,false 为顺时针
|
||
let outsideRadius = canvasW / 2
|
||
let insideRadius = 20
|
||
ctx.arc(canvasW * 0.5, canvasH * 0.5, outsideRadius, angle, angle + baseAngle, false)
|
||
ctx.arc(canvasW * 0.5, canvasH * 0.5, insideRadius, angle + baseAngle, angle, true)
|
||
// 开始链接线条
|
||
ctx.stroke()
|
||
// 每个奖品区块背景填充颜色
|
||
ctx.fillStyle = this.colors[i % 2]
|
||
// 填充颜色
|
||
ctx.fill()
|
||
// 保存当前画布的状态
|
||
ctx.save()
|
||
|
||
// 开始绘制奖品内容
|
||
ctx.fillStyle = this.fontColor
|
||
let rewardName = this.strLimit(this.prizeList[i][this.strKey])
|
||
|
||
// translate方法重新映射画布上的 (0,0) 位置
|
||
let translateX = canvasW * 0.5 + Math.cos(angle + baseAngle / 2 ) * this.textRadius
|
||
let translateY = canvasH * 0.5 + Math.sin(angle + baseAngle / 2) * this.textRadius
|
||
ctx.translate(translateX, translateY)
|
||
|
||
// rotate方法旋转当前的绘图,因为文字是和当前扇形中心线垂直的
|
||
ctx.rotate(angle + (baseAngle / 2 ) + (Math.PI / 2))
|
||
|
||
// 设置文本位置并处理换行
|
||
// console.log('原始文本', rewardName)
|
||
if (rewardName.length > this.strLineLength && this.strLineLength !== 0) {
|
||
rewardName = rewardName.substring(0, this.strLineLength) + ',' + rewardName.substring(this.strLineLength)
|
||
let rewardNames = rewardName.split(',')
|
||
// console.log('多行文本', rewardNames)
|
||
for (let j = 0; j < rewardNames.length; j++) {
|
||
if (ctx.measureText && ctx.measureText(rewardNames[j]).width) {
|
||
ctx.fillText(rewardNames[j], -ctx.measureText(rewardNames[j]).width / 2, j * this.lineHeight)
|
||
} else {
|
||
this.measureText = rewardNames[j]
|
||
await this.$nextTick()
|
||
let textWidth = await this.getTextWidth()
|
||
ctx.fillText(rewardNames[j], -textWidth / 2, j * this.lineHeight)
|
||
// console.log(rewardNames[j], textWidth, i)
|
||
}
|
||
}
|
||
} else {
|
||
if (ctx.measureText && ctx.measureText(rewardName).width) {
|
||
ctx.fillText(rewardName, -ctx.measureText(rewardName).width / 2, 0)
|
||
} else {
|
||
this.measureText = rewardName
|
||
await this.$nextTick()
|
||
let textWidth = await this.getTextWidth()
|
||
ctx.fillText(rewardName, -textWidth / 2, 0)
|
||
// console.log(rewardName, textWidth, i)
|
||
}
|
||
}
|
||
// 还原画板的状态到上一个save()状态之前
|
||
ctx.restore()
|
||
}
|
||
// 保存绘图并导出图片
|
||
ctx.draw(true, () => {
|
||
uni.canvasToTempFilePath({
|
||
destWidth: this.canvasWidth * this.pixelRatio,
|
||
destHeight: this.canvasHeight * this.pixelRatio,
|
||
canvasId: this.canvasId,
|
||
success: (res) => {
|
||
// 在 H5 平台下,tempFilePath 为 base64
|
||
// console.log(res.tempFilePath)
|
||
this.canvasImg = res.tempFilePath
|
||
// 通知父级组件,抽奖转品生成图片完成
|
||
this.$emit('done')
|
||
}
|
||
}, this)
|
||
})
|
||
},
|
||
// 兼容 app 端不支持 ctx.measureText
|
||
// 已知问题:初始绘制时,低端安卓机 平均耗时 2s
|
||
getTextWidth () {
|
||
return new Promise((resolve, reject) => {
|
||
uni.createSelectorQuery().in(this).select('.raffle-wheel__measureText').fields({
|
||
size: true,
|
||
}, (res) => {
|
||
resolve(res.width)
|
||
}).exec()
|
||
})
|
||
},
|
||
// 处理文字溢出
|
||
strLimit (value) {
|
||
let maxLength = this.strMaxLength
|
||
if (!value || !maxLength) return value
|
||
return value.length > maxLength ? value.slice(0, maxLength - 1) + '...' : value
|
||
}
|
||
},
|
||
mounted () {
|
||
this.$nextTick(() => {
|
||
setTimeout(() => {
|
||
this.drawWheelCanvas()
|
||
this.transitionDuration = this.duration
|
||
}, 50)
|
||
})
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
$actionBgUrl: '~static/raffle-wheel/raffle-wheel__action';
|
||
$raffleBgUrl: '~static/raffle-wheel/raffle-wheel__bg';
|
||
.raffle-wheel {
|
||
position: relative;
|
||
left: 0;
|
||
top: 0;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
.raffle-wheel {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
margin: 0 auto;
|
||
background-repeat: no-repeat;
|
||
background-position: center center;
|
||
background-size: contain;
|
||
background-image: url($raffleBgUrl + ".png");
|
||
@media (-webkit-min-device-pixel-ratio: 2),(min-device-pixel-ratio: 2) {
|
||
background-image: url($raffleBgUrl + "@2x.png");
|
||
}
|
||
@media (-webkit-min-device-pixel-ratio: 3),(min-device-pixel-ratio: 3) {
|
||
background-image: url($raffleBgUrl + "@3x.png");
|
||
}
|
||
}
|
||
.raffle-wheel__canvas {
|
||
position: absolute;
|
||
left: -9999px;
|
||
opacity: 0;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
.raffle-wheel__action {
|
||
position: absolute;
|
||
top: calc(50% - 58px);
|
||
left: calc(50% - 58px);
|
||
width: 114px;
|
||
height: 114px;
|
||
background-repeat: no-repeat;
|
||
background-position: center center;
|
||
background-size: contain;
|
||
background-image: url($actionBgUrl + ".png");
|
||
@media (-webkit-min-device-pixel-ratio: 2),(min-device-pixel-ratio: 2) {
|
||
background-image: url($actionBgUrl + "@2x.png");
|
||
}
|
||
@media (-webkit-min-device-pixel-ratio: 3),(min-device-pixel-ratio: 3) {
|
||
background-image: url($actionBgUrl + "@3x.png");
|
||
}
|
||
}
|
||
.raffle-wheel__measureText {
|
||
position: absolute;
|
||
left: 0;
|
||
top: 0;
|
||
white-space: nowrap;
|
||
font-size: 12px;
|
||
opacity: 0;
|
||
}
|
||
.canvas-img {
|
||
transition: transform cubic-bezier(.34,.12,.05,.95);
|
||
}
|
||
</style>
|