HarmonyOS 6商城开发学习:商品评分控件的“星形幻术“——用ContentModifier手搓滑动评分动效
熟悉我们购物比价应用的朋友一定对这个画面有印象:商品详情页的评分区——"★★★★☆ 4.2分 · 1289条评价"。看起来平平无奇,但有一天产品经理在评审会上指着竞品的评分控件说了一句话:
"你看人家那个评分,鼠标(手指)划过去星星会逐个亮起来,滑到哪颗就停在哪颗,半颗也能选……咱们这个咋就只能是死的?"
我们的评分当时就是一个 Text拼了几颗星字符 ★★★★☆,纯展示用,别说滑动选择了,连点击反馈都没有。要做成可交互的滑动评分,第一反应是找个评分组件库或者用 Rating组件——但华为的 Rating样式是固定的,设计给我们的稿子上有渐变色描边星、投影光晕、选中时弹性缩放,还要求在评分区直接滑动(不只是点星星),Rating组件一个都兜不住。
后来我们顺着华为官方文档的 ContentModifier+ ProgressConfiguration路子摸过去,发现了一条很有意思的路线:不靠图片、不靠三方库,只用 Path的手搓矢量星 + PanGesture的滑动映射 + Progress的 contentModifier插槽,就能做出一个完全自定义的滑动评分控件。这篇文章把这条路的原理、关键步骤和踩坑完整捋一遍。
一、问题场景:评分控件为什么"看起来简单,做起来烦"
商城的评分控件有三个隐性要求,任何一个都能把"简单方案"逼到墙角:
|
要求 |
看起来简单 |
实际麻烦在哪 |
|---|---|---|
|
矢量星,不要位图 |
"不就是个星星图标吗放进去呗" |
图标位图在暗色模式下锯齿、缩放糊;而且半星(☆→★过渡)用两张图拼不自然 |
|
支持 0.5 步长 |
"半颗星而已" |
你得有"左半实/右半虚"的裁剪或路径分割,位图很难做干净 |
|
可滑动连续选值 |
"加个滑动手势不就行了" |
滑动时要实时映射位置→分值→星的亮灭重绘,且不能跟 |
官方文档给出的核心答案是:用 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开始,一串 L到 Z闭合。
文档给出的核心画法骨架是这样的(只留思路,不铺全量魔法数字):
// 生成五角星的 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
文档的做法是给 Progress挂 PanGesture:
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的平滑动效会"吃掉"你的手势手搓值
如果你什么都不改,Progress在 value变化时默认会播一个平滑过渡动画。当你快速滑动手指时,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 是方向盘。
六、总结
|
要点 |
做法 |
|---|---|
|
星级不靠位图 |
|
|
半星干净 |
不直接 clip,而是用"半个五角星轮廓路径"画左/右半片 |
|
滑动选值 |
|
|
防动画冲突 |
|
|
触控容错 |
评分区整体宽度留 padding,别把缝隙做成死角 |
|
复用姿势 |
|
改完这轮之后,商品详情页的评分区终于从"死标签"变成了"活控件"。而且因为是纯矢量,换肤、暗色、字号缩放全不怕——这也是为什么官方文档挑这条路而不是扔几张 .png上去。
如果你只想快速要个能点的评分组件,用系统
Rating就行;但如果设计稿的星"长得很特别",且要求可滑动、半星、暗色不崩——ContentModifier + Path + PanGesture这条手搓路线,才是真正可控的底牌。
更多推荐

所有评论(0)