熟悉我们购物比价应用的朋友一定知道,商城App里抽奖活动有多重要。签到送积分、消费满额抽奖、节日大转盘……这些玩法不仅能提升用户活跃度,还能促进转化。但问题来了:很多应用的抽奖动画做得太假了——金币从天上掉下来,要么匀速直线运动,要么直接闪现,完全没有物理世界的真实感。用户一看就觉得“这又是套路”,参与欲望大打折扣。

我们之前也做过一版抽奖动画,用定时器手动控制金币位置,结果不仅卡顿,而且下落轨迹生硬,反弹效果更是惨不忍睹——金币撞到地面后直接弹飞出去,像乒乓球一样。后来我们研究了华为官方文档中关于Circle组件实现自由落体的方案,结合关键帧动画和物理公式,终于做出了让用户眼前一亮的金币掉落效果。这篇文章完整记录一下实现过程和踩坑经验。

功能设计

先说说预期效果。

用户在商城首页点击“每日抽奖”按钮,弹出一个抽奖面板。点击“抽奖”按钮后,一枚金币从面板顶部自由落下,落到地面后轻微反弹几次,最终静止在面板中央,然后金币翻转显示中奖金额。整个过程要符合物理直觉:下落速度越来越快(加速),反弹高度逐次递减(能量损失),最终平稳停下。

核心目标:

  1. 物理真实感:金币下落符合自由落体公式,反弹遵循能量守恒(损失约10%动能)。

  2. 动画流畅:使用关键帧动画,避免定时器卡顿。

  3. 可复用:封装成一个通用组件,可用于积分雨、红包雨等场景。

  4. 性能:不阻塞UI线程,即使同时掉落多枚金币也不卡顿。

核心API

API/组件

说明

Circle

绘制圆形,用作金币本体

keyframeAnimateTo

关键帧动画,分段控制位置和曲线

Curve.FastOutLinearIn

加速曲线(模拟下落)

Curve.LinearOutSlowIn

减速曲线(模拟上升)

display.getDefaultDisplaySync()

获取屏幕尺寸,计算活动区域

UIContext.px2vp

像素转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设置为最终位置,并确保动画的fillModeForwards

// 在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的位置超出了父容器范围,会被裁切。解决方案:在计算maxHeightmiddleX时预留边距,确保金币始终在可视区域内。

this.maxHeight = uiContext.px2vp(displayInfo.height) * 0.85 - 50; // 减去金币直径
this.middleX = uiContext.px2vp(displayInfo.width) * 0.1;

问题4:反弹动画不自然

一开始我们直接用线性曲线,反弹时金币像被弹弓射出去一样。后来调整为FastOutLinearIn(下落加速)和LinearOutSlowIn(上升减速),效果才接近真实物理世界。

总结

用Circle组件模拟自由落体效果,核心实现要点如下:

要点

实现方式

物理公式

h = ½ g t²,计算下落时间和反弹高度

关键帧动画

keyframeAnimateTo分段控制位置和曲线

加速/减速曲线

下落用FastOutLinearIn,上升用LinearOutSlowIn

能量损失

每次反弹动能乘以0.9,高度和时间等比衰减

多金币优化

使用数组管理多个位置,错峰掉落

适配

根据屏幕尺寸动态计算活动区域

改完之后,抽奖动画的质感提升了一大截。用户反馈“金币掉下来的时候真的有重量感”,参与抽奖的转化率也提高了不少。如果你也在做购物比价类应用的抽奖功能,不妨试试这套基于物理公式的关键帧动画方案,让你的金币“落”得更真实。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐