商品详情页最容易被低估的交互控件,不是加购按钮,而是星级评分区:左侧一颗金色小星星 + 一句"4.7(1289条)",看着简单,但一旦产品说"点一下评1星~5星、手指划过去能滑着打分",它就从一个文本展示问题变成一个精度 + 手势 + 自定义绘制的小工程。

很多团队第一反应是:

"拿 Slider改改thumb样式,或者 RowImage切几张星图,凑合用。"

但做到一半会撞到三类不满:

  1. 半星精度:Slider的step是离散值,你还得自己处理"0.5格"的左右半区着色,Image切图方案更痛苦

  2. 滑动打分手感:手指横划时星星要逐个点亮(不是滑块跳格),要有弹性感

  3. 视觉一致性:设计稿要的是自定义轮廓/渐变/发光,不是系统默认的实心矩形进度

华为购物比价实践给的路线,本质是把三件事拆开:

做什么

用什么

进度数据源

当前分值 value / 满分 total

Progress({value, total})当宿主

图案绘制

完全自定义"星星长什么样"

contentModifier+ Path(命令路径)

交互接管

手指滑过多远 → 映射到分数

PanGestureon gesture()

一句话:让 Progress 出数据,让 Path 出样子,让 PanGesture 出交互。


一、先想清楚:展示态 vs 交互态是两种组件,不是一种

商品详情页的评分区通常有两态:

  • 只读展示"★ 4.7(1289)"——不需要手势,只需要把值渲染成"亮星+半星+灰星"

  • 可交互的打分:用户点/滑——需要手势、精度、动效

所以工程上最好的结构不是"一个组件硬扛",而是:

RatingStars          ← 只读展示(纯Path/Image,无gesture)
ScoreSlider / StarScoring  ← 可交互(Progress宿主 + Path星星 + PanGesture)

这样只读路径走极简渲染,交互路径才承担复杂度。


二、五角星为什么用 Path.commands()而不是切图

设计稿要"自定义轮廓粗细、颜色、发光"时,PNG切图会迅速变质:

  • 不同字号/不同密度下锯齿

  • 换主题色要出两套图

  • 半星=裁切1px边框就容易露底

Path的好处是:一颗星的几何完全由极坐标公式定义,调颜色/描边/缩放就是改参数。

五角星的外顶点间隔是 72°(2π/5),但你要的正五角星(尖朝上)常用角是 18° 与 36°​ 的偏移:

  • 外半径 R

  • 内半径 r = R · sin(18°) / cos(36°)(这是正五边形的内接关系)

一个"尖朝上"的五星命令串长这样(以中心 cx,cy为基准):

// 生成五角星路径 commands(尖朝上)
function starCommands(cx: number, cy: number, R: number): string {
  const deg = (d: number) => (d * Math.PI) / 180
  const pts: { x: number; y: number }[] = []

  for (let i = 0; i < 5; i++) {
    // 外顶点:72°一步
    const aOut = deg(-90 + i * 72)          // -90让尖朝上
    pts.push({
      x: cx + R * Math.cos(aOut),
      y: cy + R * Math.sin(aOut)
    })

    // 内凹点:插在中间
    const aIn = deg(-90 + 36 + i * 72)
    const r = R * Math.sin(deg(18)) / Math.cos(deg(36))
    pts.push({
      x: cx + r * Math.cos(aIn),
      y: cy + r * Math.sin(aIn)
    })
  }

  // 组装 SVG-style commands(ArkUI Path.commands 用同类语法)
  let cmds =
    `M ${pts[0].x.toFixed(2)} ${pts[0].y.toFixed(2)}`
  for (let i = 1; i < pts.length; i++) {
    cmds += ` L ${pts[i].x.toFixed(2)} ${pts[i].y.toFixed(2)}`
  }
  cmds += ' Z'
  return cmds
}

然后渲染:

Path()
  .width(starSize)
  .height(starSize)
  .commands(starCommands(cx, cy, R))
  .fill(isLit ? activeColor : dimColor)
  .stroke(strokeColor)
  .strokeWidth(1.2)

这样就有了可程序化、可换色、可半星裁切的星星——这才是"自定义图案"的正路。


三、contentModifier的意义:把Progress当"数据壳",内容区全交给你

官方文档里这句话很关键:

ContentModifier支持通过样式builder自定义特定组件的内容区。Progress + ContentModifier = "我借用Progress的参数体系(value/total/enabled),但内容区你别管我的进度条样式,我全自己画。"

骨架是:

class StarRatingModifier implements ContentModifier {
  activeColor: ResourceColor = '#FFA726'
  size: number = 24

  applyContent(): WrappedBuilder {
    return wrapBuilder(starRatingBuilder)
  }
}

@Builder
function starRatingBuilder(config: ProgressConfiguration) {
  // config.value / config.total / config.enabled 就是你的评分数据
  const score = config.value   // 4.3 之类
  const max  = config.total    // 5

  Row({ space: 2 }) {
    // 画 max 颗星
    for (let i = 1; i <= max; i++) {
      StarPiece({
        size: config.contentModifier.size ?? 24,   // 从modifier拿到的布局信息
        lit: score >= i,
        half: !!(score > i - 1 && score < i)       // 半星判断
      })
    }
  }
}

然后挂在 Progress 上:

Progress({ value: this.score, total: 5, type: ProgressType.Linear })
  .contentModifier(new StarRatingModifier('#FFA726', 22))

重要心智模型contentModifier不是"改Progress颜色",而是把Progress的内容区替换成你的声明式UI,而 ProgressConfiguration只负责把 value/total传进来——你用它决定画几颗亮星、半星裁多少。

如果你的需求只是"展示态",到这步就结束了,完全不需要手势。


四、滑动打分:PanGesture接管"手指横移→分数"

交互态要的不是 Slider,而是:

  • 手指在星星区横划

  • 系统告诉你横移距离dx

  • 你把dx映射成 0..max的分数,步进0.5

  • 然后用 animateTo把星星点亮做弹性过渡

4.1 映射公式(核心三行)

设星星区总宽 zoneW,手指相对起点偏移 xclamp(0, zoneW)):

const rawScore = (x / zoneW) * max      // 0..max 浮点
const stepped  = Math.round(rawScore * 2) / 2   // 0.5步进
const score    = Math.max(0, Math.min(max, stepped))

4.2 手势绑在谁身上

绑在星星区的外层容器上,不要绑在个别 Path 上:

Row()  // ← 星星区
  .width(zoneW)
  .height(starSize)
  .gesture(
    PanGesture()
      .onActionStart((ev) => { this.startX = ev.fingerList[0].localX })
      .onActionUpdate((ev) => {
        const dx = ev.fingerList[0].localX - this.startX
        const newScore = this.mapToScore(dx)   // 映射
        // 先用临时值驱动UI(视觉连续),抬手再确认
        this.tempScore = newScore
      })
      .onActionEnd(() => {
        // 抬手:确认值 + 动画落定
        animateTo({ duration: 180, curve: Curve.EaseOut }, () => {
          this.score = this.tempScore
        })
      })
  )

4.3 "半星裁切"怎么画

半星不需要两张图,而是用 Row的裁剪逻辑:

// 单颗星的"半区"
Row() {
  // 底:灰星(全宽)
  Path().commands(starCmd).fill(dimColor)
  // 上一层:亮星裁一半
  Row() {
    Path().commands(starCmd).fill(activeColor)
  }
  .clip(true)
  .width( starSize * fraction )   // fraction=0.5时只露半颗
}

这样 fraction来自 tempScore - Math.floor(tempScore),星星就能连续"泡"出来而不跳格。


五、最小可跑示意(克制版,逻辑完整)

下面不放完整 class 文件,只放你真正要抄的骨架:

// ScoreScoring.ets(示意)
@Component
struct ScoreScoring {
  @State score: number = 0
  @State tmp:   number = 0
  readonly max: number = 5
  readonly starW: number = 28
  readonly gap: number = 4
  get zoneW(): number { return this.max * (this.starW + this.gap) - this.gap }

  mapToScore(x: number): number {
    const raw = (Math.max(0, Math.min(x, this.zoneW)) / this.zoneW) * this.max
    return Math.max(0, Math.min(this.max, Math.round(raw * 2) / 2))
  }

  build() {
    Row() {
      // 星星渲染(由 this.tmp 当视觉驱动)
      buildStars(this.tmp, this.max, this.starW, this.gap)
    }
    .width(this.zoneW)
    .height(this.starW)
    .gesture(
      PanGesture()
        .onActionUpdate((e) => {
          const dx = e.fingerList[0].localX
          this.tmp = this.mapToScore(dx)
        })
        .onActionEnd(() => {
          animateTo({ duration: 160 }, () => { this.score = this.tmp })
        })
    )
  }
}

六、常见翻车点与修法(省你两小时调试)

现象

原因

修法

星星画出来歪/不对称

极坐标起始角没 -90°对齐尖朝上

aOut = -90 + i*72

半星不是一半,是"斜着裁"

裁切容器没 .clip(true)

亮星那层包 Row.clip(true)

手指划上去星星不亮,点到才亮

你用 onClick,没接 PanGesture

打分交互必须用 Pan(或至少 Pan + Tap兜底)

抬手后值还在半格跳一下

mapToScoreMath.round在 update 里就执行了

update 只写 tmp,end 里才 animateTo→score

手势区热区比星星短一截

Row的padding/margin吃掉宽度

.width(zoneW)算准,别用 wrapContent 猜


七、总结

商品详情页的星级评分很容易写成"几张图的事",但只要产品开口要"滑着打分 + 半星 + 自定义轮廓发光",它就变成了一道正经的几何+手势题:

  1. 展示态contentModifier+ Path.commands()画可编程星星,用 config.value/config.total驱动亮/灰/半星裁切——干净、无图、主题色自由。

  2. 交互态:不用Slider伪装,用 PanGesture把横移映射为分数(0.5步进),tmp当实时视觉、score当确认值,animateTo做弹性落定。

  3. 拆两个组件:只读 RatingStars与可交互 ScoreScoring——复杂度不互相污染,评审/后期维护也清楚。

一句话记住:星星不是图标集,是极坐标;打分不是Slider,是PanGesture把距离翻译成分数。把绘制、数据、手势三层拆开,这颗"4.7分"就能从静态展示进化成真正带手感的打分控件。

Logo

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

更多推荐