☰
HarmonyOS 6从零封装可复用星级评分组件:交互与避坑指南
2026/10/21 15:26:03 网站建设 项目流程

做评价模块的时候,我第一件事不是画UI,而是先把星级评分这个组件的交互规则想清楚。它是用户接触评价页最直接的操作入口,看起来只是五颗星摆在那,实际上一碰就涉及手势、状态、动画、数据回传和适配。这篇内容我打算围绕HarmonyOS 6里如何从零封装一个可复用的星级评分组件展开,把需求拆解、渲染方案、手势处理、视觉打磨、踩坑经验都过一遍,适合正在做鸿蒙应用评价模块、又不想直接用系统默认样式的开发者参考。

1. 需求拆解:星级评分不是画五颗星那么简单

1.1 评分组件在评价场景里的真实地位

很多需求文档里写“添加一个五星评分控件”,看起来很轻,但真正落到页面上,这个控件承担的事情远不止展示五颗星。用户对一件商品的整体满意度、对一次服务的接受程度、对某个内容的认可与否,都要通过这一个小东西表达出来。它不仅要好看,还要让用户在一两秒内完成操作,并且操作结果能清晰、准确地反映到后续的数据统计里。

我做过一个模拟项目X的评价页,最初的版本只支持整星评分。用户觉得3星和4星差别太小,很多反馈集中在“想给3.5星但没有这个选项”,导致最终评分分布两极分化。后来改成半星粒度后,用户表达意愿的准确度立刻上来了。这件事给我的启发是:做组件之前,先和产品确认评分粒度,是只允许整星,还是支持半星,还是支持任意小数,这决定了整个组件的底层数据结构。

从数据角度看,评分值最终会落到列表页的平均分计算、好评率统计、甚至用户个人标签里。如果组件本身不规范,比如同一套逻辑在不同页面分别实现,有的页面四舍五入,有的页面直接截断,最后汇总出来的数据会很不可信。所以评分组件一定要收敛成公共组件,做成单点维护,所有页面统一调用。

1.2 先定规则:整星、半星还是任意小数

评分粒度不是拍脑袋定的,要结合使用场景。整星最简单,适合快速评价、低门槛场景,比如“这个功能好不好用,点一下就行”。半星是电商和服务评价里最常见的,用户在肯定与否定之间有了中间选项,表达更细腻,同时不会让操作变得太复杂。任意小数则适合评分后再提交表单的场景,比如课程评分、文章评分,用户通过滑动滑块可以精确到0.1分。

在HarmonyOS 6的组件设计里,我建议把粒度做成对外开放的配置项,而不是写死。这样同一个组件既能跑整星模式,也能切到半星和连续小数模式。对外暴露一个step参数,默认0.5,业务方传1就是整星,传0.1就是更精细的滑动评分。组件的核心逻辑只是“根据触摸位置算出原始比例,再按step取整”,这样整个组件就能适配绝大多数评价场景。

我实际开发时更关注另一个隐性需求:组件是否只读。商品详情页经常要展示历史平均分,这时候星星只是展示,不能被点击;评价页则是可交互状态。所以组件必须有一个readonly开关,并且只读状态下不能继续响应触摸事件,避免用户在看详情时误触评分。

2. 组件设计:布局选型与自绘方案

2.1 为什么要自己写组件而不直接用系统组件

HarmonyOS 6的ArkUI框架自带了一些基础组件,Slider可以用来做滑杆评分,但直接拿滑杆当评分控件,视觉上缺少星星的直观反馈。系统也没有一个专门为星级评分封装好的、支持半星和滑动交互的标准组件,更不用说星星颜色、间距、尺寸这些定制需求了。

自己写组件最直接的好处是可以完全控制视觉和交互。我可以让星星在评分时呈现黄色、未评分时呈现浅灰,可以在星星下方显示当前分数,可以控制点击和滑动的反馈差异,甚至可以为动画单独做一层状态。这些都是系统基础组件不太好实现或者实现成本很高的东西。

另一个理由是样式复用。同一个评分组件可能会出现在提交评价页、订单评价列表、售后反馈页等多个位置,每个页面如果都重新布局一次,后续调整星星大小或者更换图标素材时就会到处改。收敛成公共组件后,所有页面的调用方式都一样,改一处即可全局生效。

2.2 两种渲染方案对比:逐颗图标与遮罩裁剪

星级评分常见的渲染方式有两大类。逐颗图标方案是维护五颗星星的图片或符号,根据当前分数把每一颗判断为空星、半星、满星三种状态,然后分别渲染对应图标。这个方案的优点是单颗星星状态清晰,动画可以做得比较精细;缺点是半颗星的素材往往要额外准备,或者通过裁剪生成,整体实现会比较繁琐,如果要支持任意小数,比如3.7分,就很难用“空星半星满星”三态表达了。

遮罩裁剪方案则是把一整排灰色星星放在最底层,上面叠加一整排同样大小的黄色星星,然后通过裁剪上面那层黄色星星的宽度来模拟分数。比如当前值是3.5分,就裁掉黄色星星行右侧30%的宽度,这样视觉上正好是三星半。这个方案非常灵活,天然支持任意小数评分,不需要额外准备半星素材,实现代码也短很多。

我在实际项目中最终选择了遮罩裁剪方案,主要原因是它不需要为每个状态准备独立图标,也不需要频繁切换资源,只要控制一层Row的宽度和裁剪就能做到连续、平滑的评分反馈。这个方案对滑动评分的支持尤其好,手指拖到哪,黄色星星就实时显示到哪,不会出现很明显的跳变感。

2.3 组件对外参数设计

组件不是自己写完就行,还要让其他人好用。我会把常用配置都暴露成参数,并提供合理默认值,这样业务方只需要传最少的内容就能跑起来。

参数名类型默认值说明
defaultValuenumber0初始评分值
starCountnumber5星星总数
starSizenumber32星星字号或图标尺寸,单位vp
spacingnumber6星星之间的间距,单位vp
stepnumber0.5评分粒度,1为整星,0.5为半星,0.1为更细粒度
readonlybooleanfalse是否只读展示
onValueChangefunction无评分变化后的回调

参数不是越多越好,但基础控制必须覆盖。starCount可以让组件适配从五颗星到十颗星的不同场景;starSize和spacing决定控件整体大小,适配不同页面布局;step决定评分精度;readonly区分展示和编辑。回调函数则负责把组件内部算出的分数传回父页面,让父页面拿到数据去提交或者做其他业务处理。

3. 核心实现:用 ArkUI 把星级评分跑起来

3.1 搭建基础布局:两层星星叠起来

在HarmonyOS 6的ArkUI声明式开发中,我们用Stack做层叠布局,底层放灰色星星行,顶层放黄色星星行,通过裁剪顶层宽度来映射评分值。下面对应一个比较接近生产可用的组件结构。

@Component export struct StarRating { @Prop defaultValue: number = 0; @Prop starCount: number = 5; @Prop starSize: number = 32; @Prop spacing: number = 6; @Prop step: number = 0.5; @Prop readonly: boolean = false; onValueChange?: (value: number) => void; @State private currentValue: number = 0; private starArray: number[] = []; aboutToAppear(): void { this.starArray = []; for (let i = 0; i < this.starCount; i++) { this.starArray.push(i); } this.currentValue = this.defaultValue; } build() { Stack({ alignContent: Alignment.Start }) { // 底层灰色空星 Row({ space: this.spacing }) { ForEach(this.starArray, () => { Text('★') .fontSize(this.starSize) .foregroundColor('#E5E5EA') .flexShrink(0) }) } // 顶层黄色满星,宽度按比例裁剪 Row({ space: this.spacing }) { ForEach(this.starArray, () => { Text('★') .fontSize(this.starSize) .foregroundColor('#FFB940') .flexShrink(0) }) } .width(`${(this.currentValue / this.starCount) * 100}%`) .clip(true) .align(Alignment.Start) } .width(this.starCount * this.starSize + (this.starCount - 1) * this.spacing) .height(this.starSize) .onTouch((event: TouchEvent) => { this.handleTouchEvent(event); }) } }

这里有几处关键点。灰星和黄星必须完全对齐,所以它们都在同一个Stack里,并且底层的Row和顶层的Row使用完全相同的子级排列,包括相同的space和相同数量的星星。flexShrink(0)的作用是防止星星在某些异常布局下被压缩变形,保证每一颗都是正圆或正方形。

顶层的黄色星星Row通过百分比宽度控制自身占整个Stack宽度的比例,再配合clip(true)把超出部分裁剪掉。当currentValue为0时,顶层宽度为0,显示的全是灰星;为5时,顶层宽度100%,全是黄星;为3.5时,显示到第三颗半的位置。这样就把数值换算成了直观的视觉呈现。

3.2 手势交互:点击、滑动、边界处理

有了静态展示,下一步就是让用户能改评分。在onTouch回调里,我们根据手指相对于组件左边界的横向位置来换算得分。这里用event.touches[0].x获取相对坐标,组件总宽度是已知的,所以比例可以直接算出来。

private handleTouchEvent(event: TouchEvent): void { if (this.readonly) { return; } if (event.type !== TouchType.Down && event.type !== TouchType.Move) { return; } const touch = event.touches[0]; if (!touch) { return; } const totalWidth = this.starCount * this.starSize + (this.starCount - 1) * this.spacing; let ratio = touch.x / totalWidth; ratio = Math.max(0, Math.min(1, ratio)); const rawValue = ratio * this.starCount; if (this.step > 0) { // 按步进取整,例如 step=0.5,则评分只会落 0、0.5、1、1.5... this.currentValue = Math.round(rawValue / this.step) * this.step; } else { this.currentValue = rawValue; } this.currentValue = Math.max(0, Math.min(this.starCount, this.currentValue)); this.onValueChange?.(this.currentValue); }

边界处理是我最早踩坑的地方。手指在星星右侧滑出组件区域时,touch.x可能会超过组件宽度,如果不加限制,会出现评分直接超出最大值的现象。所以我强制把比例限制在0到1之间,同时将最终结果限制在0到starCount范围内。

另一个细节是TouchType。如果对TouchType.Up也做处理,当手指离开屏幕时touches数组可能为空,直接访问touches[0]会引发异常。所以只处理Down和Move,并且在访问前判空。这里也体现出组件需要具备防御性编程思维,不能假设每次触摸事件都带完整坐标。

3.3 数值回传与父子组件同步

组件内部维护的是自己的状态,但业务页面需要知道用户最终选了多星。这里的做法是暴露onValueChange回调,在每次手势改变评分时把值传给父组件。

@Builder buildStarRating() { StarRating({ defaultValue: 3.5, step: 0.5, onValueChange: (value: number) => { this.currentScore = value; this.scoreText = `当前评分:${value.toFixed(1)}`; } }) }

父组件接收回调时要注意作用域。在ArkUI的Builder函数里,箭头函数会捕获外层的this,所以可以在回调里安全地给父组件的状态赋值。我建议不要在回调里做复杂业务逻辑,比如调用提交接口,因为手势移动会频繁触发回调。正确的做法是先更新界面上的本地状态,等用户点击“提交评价”按钮后再读取最终值。

这里有一个非常容易踩的坑:@Prop默认是单向同步的,父组件修改defaultValue并不会强制更新子组件内部已经保存的currentValue。如果页面需要在评分提交后重置组件,不能只修改defaultValue,而是要给组件加一个key,或者用@Link做双向绑定。我通常采用给组件加key的方式重置状态,重新创建组件实例成本很低,逻辑也最清晰。

4. 视觉与体验打磨:一个能用的评分组件还差什么

4.1 动画:松手后在分值附近回弹

直接跟手变化的评分其实体验一般,因为手指滑动过程中如果步长是0.5,星星会在某些临界点反复跳动,看起来很生硬。更自然的交互是:手指移动时,星星可以实时连续跟随,松手后再通过一个短动画吸附到最近的步进值上。这样既有平滑感,又保留了半星/整星的规则。

实现思路很简单,把手势过程中和最终稳定后的状态分开。我在组件里专门维护一个displayValue,手势中负责更新它,松手时再根据step算出目标值,用animateTo把displayValue过渡到目标值,过渡结束后再回调给父组件。

private handleTouchEnd(): void { if (this.readonly) { return; } const target = Math.round(this.currentValue / this.step) * this.step; animateTo({ duration: 180, curve: Curve.EaseOut }, () => { this.currentValue = target; }); this.onValueChange?.(target); }

这样用户松手后,如果手指停在3.2星附近,组件会轻微滑动到3.5星;停在4.1星附近则回到4星。动画时长我一般控制在150到220毫秒之间,太短会显得生硬,太长会让用户觉得评分结果不确定。动画只作用于松手后的吸附阶段,手势过程中不带动画,否则高频率的animateTo会对性能造成压力。

4.2 无障碍、字体校准与资源适配

评分组件如果只做到视觉可用,还不算真正完成。无障碍模式下,读屏用户需要知道当前评分的数值和操作方式。我会给整个组件设置无障碍语义,比如“当前评分3.5分,可上下拖动调整”,这样读屏软件才能准确播报。

.accessibilityText(`${this.currentValue.toFixed(1)}颗星,可滑动评分`) .accessibilityLevel('yes')

字体校准是另一个容易忽略的问题。字符“★”在不同系统字体和字号下,视觉重心可能不一样,而且不是所有设备都支持这个字符。如果组件内部用Text('★')展示,我会检查它在目标机型上的显示效果,必要时改用SymbolGlyph或者自定义图标资源。实际项目中更推荐使用svg或png星星图标,这样颜色、圆角、阴影都可控,不会因为字体渲染差异导致星星看起来歪斜或大小不一。

适配也涉及点击区域。星星的实际字符可能只有32vp高,但手势热区应该略微扩大一些,方便手指点击。我通常会把Stack的宽高设置得比星星整体尺寸大一点,或者在Stack外面再包一层透明响应区域,保证用户不容易点偏。

4.3 从组件到页面:业务状态接入

组件本身能用了,还要考虑它在真实页面里的交互链路。评价页通常有多个维度,比如商品描述相符度、物流速度、服务态度,每一维度都是五星评分。如果每个维度都创建一个组件实例,并且各自维护状态,页面代码会变得很散。我更倾向于把多个评分项包在一个数据模型里。

class RatingItem { title: string = ''; score: number = 0; }

页面上用ForEach渲染评价项列表,每一项绑定同一个onValueChange回调,根据传入的索引更新对应的score字段。这样提交评价时,只需遍历这个数组就能拿到所有评分,不需要为每个评分项单独写状态。

展示场景也要复用同一个组件。在商品详情页展示平均分时,我会把readonly设为true,defaultValue设为后端返回的平均分,step可以设为0.1。这样不用额外写一套只读UI,组件内部会根据readonly自动屏蔽触摸响应,用户在详情页按住星星怎么滑动都不会误改评分。

5. 踩坑记录与性能排查

5.1 常见问题速查表

开发过程中我记录了不少实际遇到的问题,有些是排查了很久才发现原因。这里整理成一个速查表,给大家参考。

问题现象可能原因解决办法
滑动评分时星星被压缩变形Row子项默认参与了弹性布局给星星Text或图标设置flexShrink(0)
评分偶尔超过最大值手指滑出组件右边界,touch.x过大对比例做0-1的clamp处理
松手后评分没有按半星吸附缺少对TouchType.Up的处理或没有计算step在触摸结束时计算最近步进值并执行动画
父组件重置defaultValue后组件评分不变@Prop单向同步,子组件内部状态未被刷新使用key强制重建组件,或改用@Link
展示模式下星星还能被拖动没有判断readonly状态在onTouch回调最前面拦截readonly
回调触发太频繁导致页面卡顿Move事件里即时回调父组件手势中只更新内部状态,松手后再回调
星星显示位置偏左或偏右字体字符存在内边距改用图片资源或SymbolGlyph
无障碍读屏不朗读评分组件缺少语义信息设置accessibilityText和accessibilityLevel

这些坑大概率不是只在某个机型上遇到,而是ArkUI声明式布局的共性。组件调试时我习惯先在真机上验证,因为模拟器和真机在触摸事件坐标、字体渲染上都有细微差别。

5.2 性能优化心得

评分组件本身不复杂,但如果页面里出现大量评分项,比如后台的评价管理列表,每个评价都要显示星级,那性能还是需要关注的。ForEach的key要稳定,我直接用星星的索引作为key,避免组件在状态更新时被重复创建销毁。

手势更新状态时,高频的@State变更会触发组件局部刷新。我在滑动过程中不会执行任何耗时的运算,所有换算都是简单的乘除和Math.round,所以性能压力不大。真正需要注意的是父组件回调里的逻辑,如果把提交操作或者复杂的数组遍历放在onValueChange里,会造成明显的卡顿。我的做法是回调里只更新本地显示状态,具体提交动作放到按钮事件里。

另一个性能细节是裁剪区域的刷新范围。顶层Row的宽度变化时,系统要对这个区域重复裁剪。为了减少无意义的裁剪计算,我会在readonly模式下不更新currentValue,同时不在顶层Row上绑定手势响应。这样只读场景几乎不涉及动态裁剪,性能更好。

5.3 下一步能做什么

组件做到这里其实已经足够日常业务使用了。我自己继续扩展的方向是增加“评分气泡提示”,手指滑动时在星星上方显示当前分值的小气泡,让用户更清楚自己选了多少分。实现思路是在组件的Stack上再叠加一个Text,位置根据触摸比例计算,松手后自动隐藏。

另一个可以考虑的方向是评分后触发的粒子或光晕动效。这种动效比较适合内容社区的场景,评分完成后星星会轻轻震动一下,增加完成感。动效建议只做在非只读模式且评分稳定后,不要在滑动过程中一直触发,否则很容易做出让人头晕的效果。

最后想说的是,星级评分这种组件看似基础,但它直接决定用户对整个评价模块的第一印象。把基础交互做好、边界情况处理好、性能细节考虑到,才能在各个业务页面上长期稳定使用。我在实际开发中把评分参数收敛成配置项之后,后续接入新页面几乎不需要改动组件内部,只需要传不同的参数和回调。如果你也在HarmonyOS 6上做类似功能,建议先花一点时间把需求粒度和交互规则定清楚,再动手写布局代码,后面会省掉很多返工。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询