熟悉我们购物比价应用的朋友一定对这个画面有印象:商品详情页的评分区——"★★★★☆ 4.2分 · 1289条评价"。看起来平平无奇,但有一天产品经理在评审会上指着竞品的评分控件说了一句话:

"你看人家那个评分,鼠标(手指)划过去星星会逐个亮起来,滑到哪颗就停在哪颗,半颗也能选……咱们这个咋就只能是死的?"

我们的评分当时就是一个 Text拼了几颗星字符 ★★★★☆,纯展示用,别说滑动选择了,连点击反馈都没有。要做成可交互的滑动评分,第一反应是找个评分组件库或者用 Rating组件——但华为的 Rating样式是固定的,设计给我们的稿子上有渐变色描边星、投影光晕、选中时弹性缩放,还要求在评分区直接滑动(不只是点星星),Rating组件一个都兜不住。

后来我们顺着华为官方文档的 ContentModifier+ ProgressConfiguration路子摸过去,发现了一条很有意思的路线:不靠图片、不靠三方库,只用 Path的手搓矢量星 + PanGesture的滑动映射 + ProgresscontentModifier插槽,就能做出一个完全自定义的滑动评分控件。这篇文章把这条路的原理、关键步骤和踩坑完整捋一遍。


一、问题场景:评分控件为什么"看起来简单,做起来烦"

商城的评分控件有三个隐性要求,任何一个都能把"简单方案"逼到墙角:

要求

看起来简单

实际麻烦在哪

矢量星,不要位图

"不就是个星星图标吗放进去呗"

图标位图在暗色模式下锯齿、缩放糊;而且半星(☆→★过渡)用两张图拼不自然

支持 0.5 步长

"半颗星而已"

你得有"左半实/右半虚"的裁剪或路径分割,位图很难做干净

可滑动连续选值

"加个滑动手势不就行了"

滑动时要实时映射位置→分值→星的亮灭重绘,且不能跟 Progress的默认进度动画打架

官方文档给出的核心答案是:ContentModifier<ProgressConfiguration>把 Progress 的内容区完全接管,在里面用 Path手画星星,再用 PanGesture把手指X坐标映射成分值。等于把 Progress 当成一个"带配置信息的画布容器",你负责画,它负责生命周期。


二、技术原理:Progress 在这里扮演什么角色

先说清一个最容易误解的点:

Progress在这套方案里不是用来"画进度条"的,而是借它的 contentModifier插槽,当一个可定制内容区 + 可携带配置 (value/total/enabled) 的宿主来用。

ContentModifier<T>要求你实现一个类:

class MyStarModifier implements ContentModifier<ProgressConfiguration> {
  constructor(...) { ... }

  applyContent(): WrappedBuilder<[ProgressConfiguration]> {
    return wrapBuilder(myStarPainter)
  }
}

然后你在 myStarPainter这个 @Builder里拿到 config: ProgressConfiguration,就能读到 config.value(当前分值)、config.total(满分)、config.enabled。接下来你想画什么就画什么——而文档选的方案是用 Path+ SVG path 命令字符串画五角星。

五角星为什么要手搓路径

一颗五角星 = 10个顶点(外顶点5个 + 内凹顶点5个)交替连线,从 M开始,一串 LZ闭合。

文档给出的核心画法骨架是这样的(只留思路,不铺全量魔法数字):

// 生成五角星的 SVG path 命令串
paintingPath(startX: number, startY: number, isHalf: boolean, isLeft: boolean): string {
  // point1~point10 用三角函数算出来
  // 如果 isHalf && isLeft  → 只取左半边路径
  // 如果 isHalf && !isLeft → 只取右半边路径
  return `M${point1} L${point2} ... L${point1} Z`
}

然后分别做两个 @Builder——

  • leftStar(config, value):画左半实/左半灰

  • rightStar(config, value):画右半实/右半灰

同一颗星的两个半片叠在一起,就拼出了 半星效果(比如 3.5 = 3颗全实 + 第4颗画左半实/右半灰 + 后面全灰)。


三、滑动手势怎么映射成分值(最关键的10行逻辑)

评分控件的滑动,本质是一道线性映射

手指在控件上的 X 像素位置  →  折算为 0~total 的分值  →  按步长(1或0.5)对齐  →  写回 config.value

文档的做法是给 ProgressPanGesture

Progress({ value: this.currentValue, total: 5 })
  .contentModifier(this.starModifier)
  .width(starZoneWidth)  // 整个评分区的宽度(5颗星+间距)
  .height(starSize)
  .gesture(
    PanGesture()
      .onActionUpdate((event: GestureEvent) => {
        // 手指相对于控件左上角的X
        const fingerX = event.fingerList[0].localX
        // 映射:fingerX / 控件宽 → ratio → value
        let v = (fingerX / starZoneWidth) * 5
        // 步长 0.5 对齐:round(v*2)/2
        v = Math.round(v * 2) / 2
        v = Math.max(0, Math.min(5, v))
        this.currentValue = v
      })
  )

这里有个容易被忽视的细节:localX相对于被手势绑定的组件左上角的坐标,这正是你想要的——它天然把"第几颗星"的比例算对了,前提是你 width设置的是整个五星区的宽度而不是单颗星。


四、我们踩过的坑(比"画个星"更重要)

坑1:Progress的平滑动效会"吃掉"你的手势手搓值

如果你什么都不改,Progressvalue变化时默认会播一个平滑过渡动画。当你快速滑动手指时,value在高频改写,Progress 的动画队列会让你看到星星"追不上手指"——视觉上像滞后、闪烁。

解法:关掉 enableSmoothEffect(或者干脆接受 Progress 不负责动画,动画你自己用 animateTo做弹性):

Progress({ value: this.currentValue, total: 5 })
  .style({ enableSmoothEffect: false }) // 关键
  .contentModifier(this.starModifier)

这样 value变多少,你的 Path重绘就立刻跟多少,手势才跟手。

坑2:Path 的 commands()字符串拼错一个空格就黑屏

Path.commands('M100 0 L...')这个字符串本质上就是 SVG path,语法极脆:

  • 数字和指令之间有没有空格、L后面有没有多余的逗号,都会影响渲染结果(严重时整颗星不显示)

  • 我们用模板字面量拼的时候,最容易在三元返回那里多出换行或空格

避坑习惯:把 paintingPath()写成纯函数,只返回最终字符串,不在这里做任何样式分支;样式的分支(实心/半星/灰)放到 fill()的布尔判断里。

坑3:半星的"左半边"不是简单 clip,最好用路径劈开

文档的办法聪明——不靠裁剪,而是直接画半个五角星路径:左半边用从 point1→point6 那段轮廓 + 一条中线回到起点,右半边同理。这样半星边缘永远是矢量锋利的,不会在暗色背景下出现裁剪的抗锯齿毛边。

坑4:控件区域要"比星星大一圈",否则边缘划不到

用户手指通常点的是两颗星之间的缝隙,如果你的 width/height刚好包着星的 commandsbbox,缝隙区域就不归你管了。解决方式很简单:在外面套一层 Row/Stack,用 padding把可触控区撑大,或者直接在 Progress上设 width"100%"并管好内部居中对齐。


五、最小成品骨架(只放关键结构,不放百行三角算)

// StarRating.ets —— 只示意"拼装关系",三角计算略
class StarModifier implements ContentModifier<ProgressConfiguration> {
  color = '#FFA500'
  build() { /* 返回 wrapBuilder(this.paintStars) */ }
}

@Builder
function paintStars(cfg: ProgressConfiguration) {
  const score: number = cfg.value   // 例 3.5
  Row({ space: 4 }) {
    ForEach([1,2,3,4,5], (i) => {
      Stack() {
        // 底层灰星(always)
        Path().commands(fullStarPath).fill('#EEE')
        // 当前星需要:全亮 / 半亮 / 不亮?
        if (score >= i) {
          Path().commands(fullStarPath).fill('#FFA500')
        } else if (score > i - 1 && score < i) {
          // 半星:左实右灰
          leftHalf().fill('#FFA500')
          rightHalf().fill('#EEE')
        }
      }
      .width(24).height(24)
    })
  }
}

// 页面
Progress({ value: this.score, total: 5 })
  .style({ enableSmoothEffect: false })
  .contentModifier(new StarModifier())
  .gesture(panGesture...)

这就是官方文档那条路的"骨架真相":Progress 是宿主,ContentModifier 是画布,Path 是笔,PanGesture 是方向盘。


六、总结

要点

做法

星级不靠位图

Path+ SVG path 命令串手搓矢量星

半星干净

不直接 clip,而是用"半个五角星轮廓路径"画左/右半片

滑动选值

PanGesturelocalX / 总宽 → 分值→ 回写 value

防动画冲突

.style({ enableSmoothEffect: false })让手势追得上

触控容错

评分区整体宽度留 padding,别把缝隙做成死角

复用姿势

ContentModifier<ProgressConfiguration>里用 cfg.value/cfg.total驱动全部重绘

改完这轮之后,商品详情页的评分区终于从"死标签"变成了"活控件"。而且因为是纯矢量,换肤、暗色、字号缩放全不怕——这也是为什么官方文档挑这条路而不是扔几张 .png上去。

如果你只想快速要个能点的评分组件,用系统 Rating就行;但如果设计稿的星"长得很特别",且要求可滑动、半星、暗色不崩——ContentModifier + Path + PanGesture这条手搓路线,才是真正可控的底牌。

Logo

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

更多推荐