HarmonyOS 6商城开发学习:Circle组件模拟金币自由落体——抽奖动画的物理真实感实战
熟悉我们购物比价应用的朋友一定知道,商城App里抽奖活动有多重要。签到送积分、消费满额抽奖、节日大转盘……这些玩法不仅能提升用户活跃度,还能促进转化。但问题来了:很多应用的抽奖动画做得太假了——金币从天上掉下来,要么匀速直线运动,要么直接闪现,完全没有物理世界的真实感。用户一看就觉得“这又是套路”,参与欲望大打折扣。
我们之前也做过一版抽奖动画,用定时器手动控制金币位置,结果不仅卡顿,而且下落轨迹生硬,反弹效果更是惨不忍睹——金币撞到地面后直接弹飞出去,像乒乓球一样。后来我们研究了华为官方文档中关于Circle组件实现自由落体的方案,结合关键帧动画和物理公式,终于做出了让用户眼前一亮的金币掉落效果。这篇文章完整记录一下实现过程和踩坑经验。
功能设计
先说说预期效果。
用户在商城首页点击“每日抽奖”按钮,弹出一个抽奖面板。点击“抽奖”按钮后,一枚金币从面板顶部自由落下,落到地面后轻微反弹几次,最终静止在面板中央,然后金币翻转显示中奖金额。整个过程要符合物理直觉:下落速度越来越快(加速),反弹高度逐次递减(能量损失),最终平稳停下。
核心目标:
-
物理真实感:金币下落符合自由落体公式,反弹遵循能量守恒(损失约10%动能)。
-
动画流畅:使用关键帧动画,避免定时器卡顿。
-
可复用:封装成一个通用组件,可用于积分雨、红包雨等场景。
-
性能:不阻塞UI线程,即使同时掉落多枚金币也不卡顿。
核心API
|
API/组件 |
说明 |
|---|---|
|
|
绘制圆形,用作金币本体 |
|
|
关键帧动画,分段控制位置和曲线 |
|
|
加速曲线(模拟下落) |
|
|
减速曲线(模拟上升) |
|
|
获取屏幕尺寸,计算活动区域 |
|
|
像素转vp,适配不同分辨率 |
实现过程
物理公式推导
自由落体的核心公式是:h = ½ g t²。其中g取9.8m/s²,t是时间。我们根据起始高度maxHeight计算出落地所需时间:t = sqrt(2 * maxHeight / g)。
反弹时,假设损失10%的动能,那么反弹速度变为原来的0.9倍,反弹高度变为原来的0.9² ≈ 0.81倍,对应的时间也按比例缩短。我们一共设计了17段关键帧(8次落地反弹),每次反弹高度递减,直到几乎静止。
// 计算关键帧位置和时间
generatePosition(maxHeight: number): Array<KeyframeState> {
let time = Math.sqrt(2 * maxHeight / 9.8); // 首次落地时间
let result: Array<KeyframeState> = [];
for (let i = 0; i < 17; i++) {
let isDown = i % 2 === 0; // 偶数帧为下落,奇数帧为上升
let bounceFactor = Math.pow(0.9, Math.ceil(i / 2)); // 每次反弹能量损失
result.push({
duration: time * 63 * bounceFactor, // 63帧≈1秒
curve: isDown ? Curve.FastOutLinearIn : Curve.LinearOutSlowIn,
event: () => {
if (isDown) {
this.sportY = maxHeight; // 落到地面
} else {
// 上升到最高点
this.sportY = maxHeight - 9.8 / 2 * Math.pow(time * bounceFactor, 2);
}
}
});
}
return result;
}
金币组件实现
我们用Circle组件绘制金币,并给它加上渐变和阴影,让它看起来更有质感。
// view/GoldCoin.ets
@Component
export struct GoldCoin {
@State sportY: number = 0;
private maxHeight: number;
private middleX: number;
aboutToAppear() {
let displayInfo = display.getDefaultDisplaySync();
let uiContext = this.getUIContext();
this.maxHeight = uiContext.px2vp(displayInfo.height) * 0.85;
this.middleX = uiContext.px2vp(displayInfo.width) * 0.03;
}
startDrop() {
let rs = this.generatePosition(this.maxHeight);
this.getUIContext().keyframeAnimateTo({ iterations: 1 }, rs);
}
build() {
Stack() {
Circle()
.width(50)
.height(50)
.fill(LinearGradient({ colors: [['#FFD700', 0], ['#FFA500', 1]] }))
.shadow({ radius: 10, color: '#40FFD700', offsetX: 0, offsetY: 5 })
.position({ x: this.middleX, y: this.sportY })
}
.width('100%')
.height('100%')
}
}
抽奖面板集成
在抽奖面板中,点击“抽奖”按钮触发金币掉落,掉落结束后显示中奖金额。
// view/LotteryPanel.ets
@Entry
@Component
export struct LotteryPanel {
@State showGold: boolean = false;
@State prizeAmount: string = '';
private goldCoinRef: GoldCoin;
startLottery() {
this.showGold = true;
// 延迟一小段时间后开始掉落(让金币先渲染)
setTimeout(() => {
this.goldCoinRef.startDrop();
}, 100);
// 模拟抽奖结果(实际应调用后端接口)
setTimeout(() => {
this.prizeAmount = '5元优惠券';
this.showPrize();
}, 3000);
}
build() {
Column({ space: 20 }) {
// 抽奖面板背景
Image($r('app.media.lottery_bg'))
.width('100%')
.height(400)
// 金币动画区域
if (this.showGold) {
GoldCoin({ ref: this.goldCoinRef })
.width('100%')
.height(300)
}
// 中奖结果显示
if (this.prizeAmount) {
Text(`恭喜获得 ${this.prizeAmount}`)
.fontSize(24)
.fontColor('#FFD700')
.fontWeight(FontWeight.Bold)
}
Button('抽奖')
.width('80%')
.height(50)
.backgroundColor('#FF5500')
.fontColor(Color.White)
.onClick(() => this.startLottery())
}
.width('100%')
.height('100%')
}
}
遇到的问题与解决方案
问题1:关键帧动画结束后金币位置不对
第一次实现时,关键帧跑完后金币回到了初始位置(顶部)。原因是keyframeAnimateTo只是临时改变了position,动画结束后如果没有保留最终状态,组件会恢复原状。解决方案:在最后一个关键帧的event中,将sportY设置为最终位置,并确保动画的fillMode为Forwards。
// 在generatePosition的最后添加
result.push({
duration: 0,
curve: Curve.Linear,
event: () => {
this.sportY = finalY; // 最终静止位置
}
});
问题2:多枚金币同时掉落时卡顿
抽奖活动有时会下“金币雨”,十几枚金币同时掉落。如果每枚金币都单独创建一个GoldCoin组件,性能急剧下降。解决方案:使用Canvas组件统一绘制所有金币,或者复用同一个Circle组件,用@State数组控制多个位置。
// 多金币方案:使用@State数组
@State coinPositions: number[] = [];
startRain(count: number) {
for (let i = 0; i < count; i++) {
let delay = i * 200; // 错峰掉落
setTimeout(() => {
this.coinPositions.push(0);
// 对该金币启动动画
}, delay);
}
}
问题3:金币在屏幕边缘被裁切
如果Circle的位置超出了父容器范围,会被裁切。解决方案:在计算maxHeight和middleX时预留边距,确保金币始终在可视区域内。
this.maxHeight = uiContext.px2vp(displayInfo.height) * 0.85 - 50; // 减去金币直径
this.middleX = uiContext.px2vp(displayInfo.width) * 0.1;
问题4:反弹动画不自然
一开始我们直接用线性曲线,反弹时金币像被弹弓射出去一样。后来调整为FastOutLinearIn(下落加速)和LinearOutSlowIn(上升减速),效果才接近真实物理世界。
总结
用Circle组件模拟自由落体效果,核心实现要点如下:
|
要点 |
实现方式 |
|---|---|
|
物理公式 |
|
|
关键帧动画 |
|
|
加速/减速曲线 |
下落用 |
|
能量损失 |
每次反弹动能乘以0.9,高度和时间等比衰减 |
|
多金币优化 |
使用数组管理多个位置,错峰掉落 |
|
适配 |
根据屏幕尺寸动态计算活动区域 |
改完之后,抽奖动画的质感提升了一大截。用户反馈“金币掉下来的时候真的有重量感”,参与抽奖的转化率也提高了不少。如果你也在做购物比价类应用的抽奖功能,不妨试试这套基于物理公式的关键帧动画方案,让你的金币“落”得更真实。
更多推荐



所有评论(0)