fix(miniprogram): 转盘浮层遮挡/错位——canvas 原生组件退到屏外只绘制贴图,展示与旋转动画改用普通 image+CSS rotate
canvas type=2d 是原生组件,同层渲染在开发者工具不支持、真机部分场景失效,失效时浮在所有 web 内容之上、z-index 失效、布局变化位置同步延迟,导致 toast/弹窗与底部常驻栏(swipe-hint/float-share)出现时转盘遮挡或错位。改为屏外 canvas 一次性绘制转盘贴图并 toTempFilePath 导出,页面用 image 展示,旋转由 4s easeOutCubic CSS transition 驱动(GPU 合成,替代逐帧重绘)。顺带修复 _currentRotation 未初始化致首次抽奖旋转角 NaN 不转动的潜在 bug。验证:wheel 组件 13 用例+详情页 38 用例全绿,tsc 零报错;真机/开发者工具渲染效果待人工确认
This commit is contained in:
@@ -71,10 +71,15 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('initCanvas', () => {
|
||||
it('节点就绪时初始化画布并绘制', () => {
|
||||
it('节点就绪时初始化画布、绘制并导出贴图', () => {
|
||||
vi.useFakeTimers()
|
||||
const ctx = mockCtx()
|
||||
const canvas: any = { width: 0, height: 0, getContext: () => ctx }
|
||||
const canvas: any = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: () => ctx,
|
||||
toTempFilePath: (opts: any) => opts.success({ tempFilePath: '/tmp/wheel.png' }),
|
||||
}
|
||||
const exec = vi.fn((cb: any) => cb([{ node: canvas, width: 300, height: 300 }]))
|
||||
const inst = createInstance({ sectors: sectors6 })
|
||||
inst.createSelectorQuery = () => ({
|
||||
@@ -88,6 +93,7 @@ describe('initCanvas', () => {
|
||||
expect(canvas.width).toBe(600) // 300 * dpr 2
|
||||
expect(canvas.height).toBe(600)
|
||||
expect(inst.data.canvasReady).toBe(true)
|
||||
expect(inst.data.wheelImg).toBe('/tmp/wheel.png')
|
||||
expect((ctx as any).__calls).toContain('clearRect')
|
||||
// 6 扇区各 fill 一次
|
||||
expect((ctx as any).__calls.filter((c: string) => c === 'fill')).toHaveLength(6)
|
||||
@@ -144,26 +150,32 @@ describe('onStart', () => {
|
||||
})
|
||||
|
||||
describe('spinTo / targetIndex observer', () => {
|
||||
it('动画结束后抛出 result 事件并带目标索引', () => {
|
||||
it('设置累加旋转角,动画结束后抛出 result 事件并带目标索引', () => {
|
||||
vi.useFakeTimers()
|
||||
const inst = createInstance({ sectors: sectors6 })
|
||||
inst._currentRotation = 0
|
||||
inst.spinTo(2)
|
||||
expect(inst.events).toEqual([]) // 动画未结束
|
||||
// 6 扇区:5 整圈 + 扇区 2 中心对准顶部 = 1800° + 210°
|
||||
expect(inst.data.rotation).toBeCloseTo(2010)
|
||||
vi.advanceTimersByTime(4100) // duration 4000ms + 余量
|
||||
expect(inst.events).toEqual([{ name: 'result', detail: { index: 2 } }])
|
||||
})
|
||||
|
||||
it('canvas 节点存在时优先用其 requestAnimationFrame', () => {
|
||||
it('连续抽取时旋转角在上一轮基础上累加(只增不减)', () => {
|
||||
vi.useFakeTimers()
|
||||
const inst = createInstance({ sectors: sectors6 })
|
||||
inst._currentRotation = 0
|
||||
const raf = vi.fn((cb: any) => setTimeout(cb, 16) as any)
|
||||
inst._canvasNode = { requestAnimationFrame: raf }
|
||||
inst.spinTo(0)
|
||||
expect(raf).toHaveBeenCalled()
|
||||
inst.spinTo(2)
|
||||
vi.advanceTimersByTime(4100)
|
||||
expect(inst.events).toEqual([{ name: 'result', detail: { index: 0 } }])
|
||||
const first = inst.data.rotation
|
||||
inst.spinTo(0)
|
||||
expect(inst.data.rotation).toBeGreaterThan(first)
|
||||
vi.advanceTimersByTime(4100)
|
||||
expect(inst.events).toEqual([
|
||||
{ name: 'result', detail: { index: 2 } },
|
||||
{ name: 'result', detail: { index: 0 } },
|
||||
])
|
||||
})
|
||||
|
||||
it('targetIndex observer:合法索引触发旋转', () => {
|
||||
@@ -196,10 +208,15 @@ describe('norm', () => {
|
||||
})
|
||||
|
||||
describe('detached', () => {
|
||||
it('清理 canvas 节点引用', () => {
|
||||
const inst = createInstance()
|
||||
it('清理 canvas 节点引用与未完成的旋转定时器', () => {
|
||||
vi.useFakeTimers()
|
||||
const inst = createInstance({ sectors: sectors6 })
|
||||
inst._canvasNode = {}
|
||||
inst._currentRotation = 0
|
||||
inst.spinTo(1)
|
||||
captured.lifetimes.detached.call(inst)
|
||||
expect(inst._canvasNode).toBeNull()
|
||||
vi.advanceTimersByTime(4100)
|
||||
expect(inst.events).toEqual([]) // 定时器已清除,不再回调 result
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// wheel 大转盘组件(6 扇区):canvas 绘制扇区 + 旋转动画 + 中心抽奖按钮。
|
||||
// wheel 大转盘组件(6 扇区):屏外 canvas 一次性绘制转盘贴图并导出为图片,
|
||||
// 页面上用普通 <image> 展示,旋转动画由 CSS transform 驱动。
|
||||
// 文档(抽奖助手·大转盘玩法):一等奖/二等奖/三等奖 + 3 个"谢谢参与"扇区。
|
||||
// 父页面通过 sectors 传入扇区配置(最多 8 个,建议 6 个),bind:draw 触发抽奖,
|
||||
// 组件旋转到目标扇区后 bind:result 回传中奖结果索引。
|
||||
@@ -16,6 +17,10 @@ Component({
|
||||
},
|
||||
data: {
|
||||
canvasReady: false,
|
||||
// 转盘贴图(canvas 导出),为空时转盘区域暂不渲染
|
||||
wheelImg: '',
|
||||
// 当前旋转角(度,累加不减),驱动 image 的 CSS rotate
|
||||
rotation: 0,
|
||||
},
|
||||
observers: {
|
||||
targetIndex(idx: number) {
|
||||
@@ -26,11 +31,17 @@ Component({
|
||||
},
|
||||
lifetimes: {
|
||||
attached() {
|
||||
// 当前旋转角(弧度),累加不减;不初始化则首次 spinTo 得 NaN
|
||||
this._currentRotation = 0
|
||||
// canvas 需在节点挂载后初始化
|
||||
setTimeout(() => this.initCanvas(), 50)
|
||||
},
|
||||
detached() {
|
||||
this._canvasNode = null
|
||||
if (this._spinTimer) {
|
||||
clearTimeout(this._spinTimer)
|
||||
this._spinTimer = null
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
@@ -52,6 +63,10 @@ Component({
|
||||
this._size = res[0].width
|
||||
this.setData({ canvasReady: true })
|
||||
this.draw(0)
|
||||
// 导出转盘贴图,页面用 image 展示(规避 canvas 原生组件层级问题)
|
||||
canvas.toTempFilePath({
|
||||
success: (r: any) => this.setData({ wheelImg: r.tempFilePath }),
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
@@ -106,7 +121,7 @@ Component({
|
||||
this.triggerEvent('draw')
|
||||
},
|
||||
|
||||
// spinTo 旋转到指定扇区(带缓动动画)
|
||||
// spinTo 旋转到指定扇区(CSS transition 缓动,时长与 .wheel-img 的 transition 一致)
|
||||
spinTo(idx: number) {
|
||||
const n = (this.properties.sectors as WheelSector[]).length || 6
|
||||
const anglePer = (Math.PI * 2) / n
|
||||
@@ -117,24 +132,14 @@ Component({
|
||||
const targetRotation = -(idx * anglePer + anglePer / 2)
|
||||
const fullSpins = 5 * Math.PI * 2
|
||||
const finalRotation = this._currentRotation + fullSpins + this.norm(targetRotation - this._currentRotation)
|
||||
|
||||
const duration = 4000
|
||||
const start = Date.now()
|
||||
const startRotation = this._currentRotation
|
||||
const tick = () => {
|
||||
const elapsed = Date.now() - start
|
||||
const t = Math.min(1, elapsed / duration)
|
||||
// easeOutCubic
|
||||
const eased = 1 - Math.pow(1 - t, 3)
|
||||
this._currentRotation = startRotation + (finalRotation - startRotation) * eased
|
||||
this.draw(this._currentRotation)
|
||||
if (t < 1) {
|
||||
this._raf = this._canvasNode ? this._canvasNode.requestAnimationFrame(tick) : setTimeout(tick, 16)
|
||||
} else {
|
||||
this.triggerEvent('result', { index: idx })
|
||||
}
|
||||
}
|
||||
tick()
|
||||
this._currentRotation = finalRotation
|
||||
this.setData({ rotation: (finalRotation * 180) / Math.PI })
|
||||
// 动画时长与 .wheel-img 的 transition 保持一致,结束后回传结果
|
||||
if (this._spinTimer) clearTimeout(this._spinTimer)
|
||||
this._spinTimer = setTimeout(() => {
|
||||
this._spinTimer = null
|
||||
this.triggerEvent('result', { index: idx })
|
||||
}, 4000)
|
||||
},
|
||||
|
||||
// norm 把角度归一化到 [0, 2π)
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
<view class="wheel-wrap">
|
||||
<!-- 屏外 canvas:只负责绘制转盘贴图并导出为图片,不直接展示。
|
||||
canvas 是原生组件,未同层渲染时浮在页面之上、z-index 失效,
|
||||
toast/底部固定栏出现时会遮挡错位;故展示与旋转均用普通 image -->
|
||||
<canvas type="2d" id="wheelCanvas" class="wheel-canvas-offscreen"></canvas>
|
||||
<!-- 转盘贴图:CSS rotate 驱动旋转动画 -->
|
||||
<image
|
||||
wx:if="{{wheelImg}}"
|
||||
class="wheel-img"
|
||||
src="{{wheelImg}}"
|
||||
mode="aspectFit"
|
||||
style="transform: rotate({{rotation}}deg)"
|
||||
/>
|
||||
<!-- 顶部指针 -->
|
||||
<view class="wheel-pointer">
|
||||
<view class="pointer-arrow"></view>
|
||||
</view>
|
||||
<!-- 转盘 canvas -->
|
||||
<canvas type="2d" id="wheelCanvas" class="wheel-canvas" disable-scroll="true"></canvas>
|
||||
<!-- 中心抽奖按钮 -->
|
||||
<view class="wheel-center {{spinning ? 'spinning' : ''}}" bindtap="onStart">
|
||||
<text class="center-text">{{spinning ? '抽奖中' : '开始'}}</text>
|
||||
|
||||
@@ -5,9 +5,21 @@
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.wheel-canvas {
|
||||
/* 屏外 canvas:仅用于绘制转盘贴图,移出视口避免原生组件浮层遮挡页面内容 */
|
||||
.wheel-canvas-offscreen {
|
||||
position: fixed;
|
||||
top: -2000rpx;
|
||||
left: -2000rpx;
|
||||
width: 560rpx;
|
||||
height: 560rpx;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wheel-img {
|
||||
width: 560rpx;
|
||||
height: 560rpx;
|
||||
/* easeOutCubic,与 spinTo 的 4s 动画时长对应 */
|
||||
transition: transform 4s cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
}
|
||||
|
||||
.wheel-pointer {
|
||||
|
||||
Reference in New Issue
Block a user