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:
2026-10-09 16:47:53 +08:00
parent 60801139e6
commit 64adb49ea4
4 changed files with 78 additions and 34 deletions
+28 -11
View File
@@ -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
})
})
+25 -20
View File
@@ -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π)
+12 -2
View File
@@ -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>
+13 -1
View File
@@ -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 {