HarmonyOS 6商城开发学习:星级评分与滑动打分交互——从Path绘制星星到PanGesture半星精度动效
商品详情页最容易被低估的交互控件,不是加购按钮,而是星级评分区:左侧一颗金色小星星 + 一句"4.7(1289条)",看着简单,但一旦产品说"点一下评1星~5星、手指划过去能滑着打分",它就从一个文本展示问题变成一个精度 + 手势 + 自定义绘制的小工程。
很多团队第一反应是:
"拿
Slider改改thumb样式,或者Row叠Image切几张星图,凑合用。"
但做到一半会撞到三类不满:
-
半星精度:Slider的step是离散值,你还得自己处理"0.5格"的左右半区着色,Image切图方案更痛苦
-
滑动打分手感:手指横划时星星要逐个点亮(不是滑块跳格),要有弹性感
-
视觉一致性:设计稿要的是自定义轮廓/渐变/发光,不是系统默认的实心矩形进度
华为购物比价实践给的路线,本质是把三件事拆开:
|
层 |
做什么 |
用什么 |
|---|---|---|
|
进度数据源 |
当前分值 value / 满分 total |
|
|
图案绘制 |
完全自定义"星星长什么样" |
|
|
交互接管 |
手指滑过多远 → 映射到分数 |
|
一句话:让 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,手指相对起点偏移 x(clamp(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 })
})
)
}
}
六、常见翻车点与修法(省你两小时调试)
|
现象 |
原因 |
修法 |
|---|---|---|
|
星星画出来歪/不对称 |
极坐标起始角没 |
|
|
半星不是一半,是"斜着裁" |
裁切容器没 |
亮星那层包 |
|
手指划上去星星不亮,点到才亮 |
你用 |
打分交互必须用 Pan(或至少 Pan + Tap兜底) |
|
抬手后值还在半格跳一下 |
|
update 只写 |
|
手势区热区比星星短一截 |
Row的padding/margin吃掉宽度 |
用 |
七、总结
商品详情页的星级评分很容易写成"几张图的事",但只要产品开口要"滑着打分 + 半星 + 自定义轮廓发光",它就变成了一道正经的几何+手势题:
-
展示态:
contentModifier+Path.commands()画可编程星星,用config.value/config.total驱动亮/灰/半星裁切——干净、无图、主题色自由。 -
交互态:不用Slider伪装,用
PanGesture把横移映射为分数(0.5步进),tmp当实时视觉、score当确认值,animateTo做弹性落定。 -
拆两个组件:只读
RatingStars与可交互ScoreScoring——复杂度不互相污染,评审/后期维护也清楚。
一句话记住:星星不是图标集,是极坐标;打分不是Slider,是PanGesture把距离翻译成分数。把绘制、数据、手势三层拆开,这颗"4.7分"就能从静态展示进化成真正带手感的打分控件。
更多推荐

所有评论(0)