评分是用户反馈体系中最基础也最重要的交互之一——从电商的商品评价到影评网站的星级打分,从外卖的满意度调研到应用商店的五星好评。ArkUI 中的 Rating 组件把这个常见需求变成了几行配置:设置星星数量、步长、交互模式,然后在 onChange 回调中拿到用户选择的分值。本文用它构建一个"电影推荐"页面,展示评分展示和用户打分的完整实现模式。


一、为什么需要 Rating 组件?

在没有 Rating 之前,实现星级评分需要:

  1. 用 Row + Image 或 Text 手动铺 5 颗星星
  2. 给每颗星星绑定 onClick,判断点击的是第几颗
  3. 实现半星逻辑——判断点击位置在星星的左半边还是右半边
  4. 处理拖动评分——监听 onTouch,根据手指位置计算当前分值
  5. 维护高亮状态——哪些星星是亮的,哪些是暗的

最简版本至少需要:

  • 一个 @State rating: number 保存当前分值
  • 5 个 Image 组件(或 Text 展示 ★/☆)
  • 5 个 onClick 回调(每个星星一个)
  • 点击位置计算(区分左半/右半实现半星)
  • 一套颜色映射(高分红、中分橙、低分灰)

Rating 组件用一个标签解决了所有这些问题:

Rating({ rating: this.currentScore, indicator: false })
  .stars(5)
  .stepSize(1)
  .onChange((value: number) => {
    this.currentScore = value;
  })

一行组件 + 三个属性 + 一个回调 = 完整的交互式评分系统。拖动手势、半星精度、视觉反馈全部由框架处理。


在这里插入图片描述

二、Rating 核心 API

2.1 基本语法

Rating({ rating: number, indicator?: boolean })
  .stars(value: number)
  .stepSize(value: number)
  .onChange(callback: (value: number) => void)

三个关键配置:

  • rating:当前评分数值,支持小数(如 4.5)。这是唯一必填的构造参数。
  • indicator:是否为纯展示模式。true 表示只读,星星不可点击;false(默认)表示可交互评分。
  • stars:星星总数,默认 5。可以设为 10 实现"十分制"评分。
  • stepSize:评分步长。1 表示只能选整数(1、2、3、4、5),0.5 表示支持半星(3.5、4.5),0.1 表示精确到小数点。
  • onChange:用户评分变化时的回调,参数为新的评分值。

2.2 indicator 模式 vs 交互模式

Rating 有两种工作模式,通过 indicator 参数切换:

展示模式(indicator: true)

Rating({ rating: 4.5, indicator: true })
  .stars(5)
  .stepSize(0.5)

用于展示平均评分——如商品详情页的"4.8 分"、电影列表的豆瓣评分。星星只读,不可点击。

交互模式(indicator: false,默认)

Rating({ rating: this.userScore, indicator: false })
  .stars(5)
  .stepSize(1)
  .onChange((value: number) => {
    this.userScore = value;
  })

用于用户亲自打分——如在弹窗中给电影评分、收货后给商品打分。星星可点击/可拖动。

2.3 stepSize 与评分精度

stepSize 控制评分的颗粒度:

  • stepSize: 1 — 只能打 1、2、3、4、5 星(整数)
  • stepSize: 0.5 — 支持半星:3.5、4.5 等
  • stepSize: 0.1 — 精确到 0.1 分(豆瓣式的"4.7分")
// 整数评分:适合简单的满意度调研
Rating({ rating: this.score }).stars(5).stepSize(1)

// 半星评分:适合电影/图书评分(豆瓣风格)
Rating({ rating: this.score }).stars(5).stepSize(0.5)

// 精确评分:适合专业评测
Rating({ rating: this.score }).stars(5).stepSize(0.1)

2.4 自定义星星数量

虽然 5 星是最常见的配置,但 stars 可以设为任意值:

// 十分制评分(IMDb 风格)
Rating({ rating: 7.8 }).stars(10).stepSize(0.1)

// 三星制简化评分(好/中/差)
Rating({ rating: 2 }).stars(3).stepSize(1)

2.5 尺寸控制

Rating 的尺寸通过 .width().height() 链式调用控制:

// 大尺寸:适合详情页顶部展示
Rating({ rating: 4.5, indicator: true })
  .stars(5).stepSize(0.5)
  .width(200).height(36)

// 小尺寸:适合列表中缩略展示
Rating({ rating: 4.5, indicator: true })
  .stars(5).stepSize(0.5)
  .width(100).height(18)

星星图标会按设定的宽高等比例缩放。默认颜色是金色,但可以通过 .starStyle() 自定义。

2.6 自定义星星样式

Rating({ rating: 4.2, indicator: true })
  .stars(5)
  .stepSize(0.5)
  .starStyle({
    backgroundUri: '/path/to/star_bg.png',  // 未选中星星图片
    foregroundUri: '/path/to/star_fg.png',  // 选中星星图片
    secondaryUri: '/path/to/star_half.png'  // 半选星星图片(可选)
  })

可以替换默认的 ★ 图标为自定义图片——例如用爱心图标做"收藏"组件,用火焰图标做"热度"指标。


在这里插入图片描述

三、Demo:电影推荐

本 Demo 构建一个电影推荐页面——8 部热门电影,展示豆瓣评分(indicator 模式),点击弹窗邀请用户打分。

页面结构

MovieRatingPage (~170行)
├── Header(🎬 电影推荐 + N部计数)
├── Scroll
│   └── Column
│       └── 电影卡片 × 8
│           ├── 左侧:海报占位(70×100彩色背景 + Emoji)
│           ├── 右侧:
│           │   ├── 电影标题(粗体,单行截断)
│           │   ├── 年份 · 导演
│           │   ├── 类型标签(彩色文字 + 浅色背景)
│           │   ├── 豆瓣评分(数字 + 彩色)
│           │   └── Rating 组件(indicator 模式,5星 + 0.5步长,100×18)
│           └── onClick → 弹窗显示详情 + 邀请打分

评分展示(indicator 模式)

每张电影卡片下方都嵌入了 Rating 组件用于展示豆瓣评分:

Rating({ rating: movie.avgRating, indicator: true })
  .stars(5)
  .stepSize(0.5)
  .width(100)
  .height(18)

indicator: true 确保星星只用于展示,用户无法在列表中误触修改评分。

评分色彩映射

根据分值高低使用不同颜色,让用户一眼看出"好片"和"烂片":

getRatingColor(rating: number): string {
  if (rating >= 4.5) return '#E74C3C';  // 红色 — 神作
  if (rating >= 4.0) return '#E67E22';  // 橙色 — 佳作
  if (rating >= 3.0) return '#F39C12';  // 金色 — 还行
  return '#999';                         // 灰色 — 一般
}

豆瓣评分数字和 Rating 组件的星星一起配合,分值数字也用对应的颜色显示,视觉上形成统一。

电影类型标签

每种电影类型使用不同颜色的标签:

getGenreColor(genre: string): string {
  switch (genre) {
    case '科幻': return '#1677FF';
    case '文艺': return '#52C41A';
    case '音乐': return '#EB2F96';
    case '动画': return '#722ED1';
    case '喜剧': return '#FAAD14';
    case '悬疑': return '#13C2C2';
    case '战争': return '#FF4D4F';
    default: return AppColors.PRIMARY;
  }
}

标签样式:10 号字体 + 彩色文字 + 同色系 8% 透明度背景 + 4px 圆角,类似豆瓣的标签设计。

海报占位

用 Stack 包裹一个彩色背景的 Column + Emoji 文字,模拟电影海报。实际项目中可以替换为 Image 组件加载真实海报图。

Stack() {
  Column() {
    Text(movie.posterEmoji).fontSize(36)
  }
  .width('100%').height('100%')
  .justifyContent(FlexAlign.Center)
}
.width(70).height(100)
.borderRadius(BorderRadius.SM)
.backgroundColor(movie.posterColor)

每部电影有不同的 posterColor(深色背景)和 posterEmoji(🚀🏔️🥁🕸️🎭🌊🐼🎖️),形成视觉区分。

三个交互点

  1. 浏览评分 — 每张卡片展示电影的平均评分(Rating indicator 模式 + 评分数字 + 颜色映射),用户滚动浏览所有电影
  2. 卡片点击 — 弹窗展示电影详细信息(年份、导演、类型、评分、评价人数),底部有"打分"按钮
  3. (扩展)自定义打分 — 在弹窗中嵌入交互式 Rating,让用户提交自己的评分

四、完整代码

import { AppColors, BorderRadius, FontSize, Spacing } from '../common/Constants';
import { promptAction } from '@kit.ArkUI';

class Movie {
  id: number;
  title: string;
  year: number;
  director: string;
  genre: string;
  posterColor: string;
  posterEmoji: string;
  avgRating: number;
  ratingCount: number;

  constructor(id: number, title: string, year: number, director: string,
    genre: string, posterColor: string, posterEmoji: string, avgRating: number,
    ratingCount: number) {
    this.id = id;
    this.title = title;
    this.year = year;
    this.director = director;
    this.genre = genre;
    this.posterColor = posterColor;
    this.posterEmoji = posterEmoji;
    this.avgRating = avgRating;
    this.ratingCount = ratingCount;
  }
}

const MOVIES: Movie[] = [
  new Movie(1, '星际征途', 2026, '郭帆', '科幻', '#1a1a2e', '🚀', 4.5, 128000),
  new Movie(2, '山河故人', 2025, '贾樟柯', '文艺', '#2D4059', '🏔️', 4.2, 89000),
  new Movie(3, '爆裂鼓手2', 2026, '达米恩·查泽雷', '音乐', '#C0392B', '🥁', 4.7, 156000),
  new Movie(4, '夏洛特的网', 2026, '宫崎骏', '动画', '#27AE60', '🕸️', 4.8, 210000),
  new Movie(5, '无名之辈2', 2025, '饶晓志', '喜剧', '#E67E22', '🎭', 4.0, 67000),
  new Movie(6, '深海追凶', 2026, '陈思诚', '悬疑', '#1B4F72', '🌊', 4.3, 95000),
  new Movie(7, '功夫熊猫5', 2026, '梦工厂', '动画', '#8E44AD', '🐼', 4.1, 185000),
  new Movie(8, '长津湖之水门桥2', 2025, '陈凯歌', '战争', '#7B241C', '🎖️', 4.6, 320000),
];

@Entry
@Component
struct MovieRatingPage {
  @State movies: Movie[] = [...MOVIES];

  getRatingColor(rating: number): string {
    if (rating >= 4.5) return '#E74C3C';
    if (rating >= 4.0) return '#E67E22';
    if (rating >= 3.0) return '#F39C12';
    return '#999';
  }

  getGenreColor(genre: string): string {
    switch (genre) {
      case '科幻': return '#1677FF';
      case '文艺': return '#52C41A';
      case '音乐': return '#EB2F96';
      case '动画': return '#722ED1';
      case '喜剧': return '#FAAD14';
      case '悬疑': return '#13C2C2';
      case '战争': return '#FF4D4F';
      default: return AppColors.PRIMARY;
    }
  }

  build() {
    Column() {
      Row() {
        Text('🎬 电影推荐')
          .fontSize(FontSize.HEADLINE)
          .fontWeight(FontWeight.Bold)
          .fontColor(Color.White)
          .layoutWeight(1)

        Text(`${this.movies.length}`)
          .fontSize(FontSize.CAPTION)
          .fontColor('#FFFFFFAA')
      }
      .width('100%')
      .height(52)
      .backgroundColor('#1a1a2e')
      .padding({ left: Spacing.XXL, right: Spacing.XXL })

      Scroll() {
        Column() {
          ForEach(this.movies, (movie: Movie) => {
            Row() {
              Stack() {
                Column() {
                  Text(movie.posterEmoji)
                    .fontSize(36)
                }
                .width('100%').height('100%')
                .justifyContent(FlexAlign.Center)
              }
              .width(70).height(100)
              .borderRadius(BorderRadius.SM)
              .backgroundColor(movie.posterColor)
              .margin({ right: Spacing.LG })

              Column() {
                Text(movie.title)
                  .fontSize(FontSize.MEDIUM)
                  .fontColor(AppColors.TEXT_PRIMARY)
                  .fontWeight(FontWeight.Bold)
                  .width('100%')
                  .maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })

                Row() {
                  Text(`${movie.year}`)
                    .fontSize(FontSize.CAPTION)
                    .fontColor(AppColors.TEXT_TERTIARY)
                  Text(' · ')
                    .fontSize(FontSize.CAPTION)
                    .fontColor(AppColors.TEXT_DISABLED)
                  Text(movie.director)
                    .fontSize(FontSize.CAPTION)
                    .fontColor(AppColors.TEXT_TERTIARY)
                }
                .width('100%')
                .margin({ top: 2 })

                Row() {
                  Text(movie.genre)
                    .fontSize(10)
                    .fontColor(this.getGenreColor(movie.genre))
                    .padding({ left: 8, right: 8, top: 2, bottom: 3 })
                    .backgroundColor(this.getGenreColor(movie.genre) + '15')
                    .borderRadius(4)
                    .margin({ right: Spacing.SM })

                  Text(`豆瓣 ${movie.avgRating}`)
                    .fontSize(FontSize.CAPTION)
                    .fontColor(this.getRatingColor(movie.avgRating))
                    .fontWeight(FontWeight.Bold)
                }
                .width('100%')
                .margin({ top: Spacing.SM })

                Row() {
                  Rating({ rating: movie.avgRating, indicator: true })
                    .stars(5)
                    .stepSize(0.5)
                    .width(100)
                    .height(18)

                  Text(` ${movie.ratingCount / 10000}万人评`)
                    .fontSize(10)
                    .fontColor(AppColors.TEXT_DISABLED)
                }
                .width('100%')
                .margin({ top: 4 })
              }
              .layoutWeight(1)
            }
            .width('100%')
            .padding(Spacing.LG)
            .backgroundColor(Color.White)
            .borderRadius(BorderRadius.MD)
            .margin({ left: Spacing.LG, right: Spacing.LG, bottom: Spacing.SM })
            .onClick(() => {
              promptAction.showDialog({
                title: `🎬 ${movie.title}`,
                message: `${movie.year}年 · ${movie.director}导演\n` +
                  `类型:${movie.genre}\n豆瓣评分:${movie.avgRating} / 5.0\n` +
                  `${movie.ratingCount / 10000}万人参与评分\n\n` +
                  `请为你心中的这部电影打分:`,
                buttons: [
                  { text: '⭐ 打分', color: AppColors.PRIMARY },
                  { text: '关闭', color: AppColors.TEXT_TERTIARY }
                ]
              });
            })
          })
        }
        .width('100%')
        .padding({ top: Spacing.LG, bottom: Spacing.XXL })
      }
      .layoutWeight(1)
      .scrollBar(BarState.Off)
      .backgroundColor('#F5F6FA')
    }
    .width('100%')
    .height('100%')
  }
}

五、扩展:交互式评分弹窗

在上面的 Demo 中,弹窗只是"邀请用户打分"——真正的打分交互还没有实现。下面展示如何在弹窗中嵌入可交互的 Rating 组件,让用户完成真实打分。

5.1 使用 @CustomDialog 嵌入 Rating

@CustomDialog
struct RatingDialog {
  controller: CustomDialogController;
  movieTitle: string = '';
  @State userRating: number = 0;

  build() {
    Column() {
      Text(`为《${this.movieTitle}》打分`)
        .fontSize(18).fontWeight(FontWeight.Bold)
        .margin({ bottom: 16 })

      // 交互式 Rating — 用户可以点击/拖动评分
      Rating({ rating: this.userRating, indicator: false })
        .stars(5)
        .stepSize(0.5)
        .width(200)
        .height(40)
        .onChange((value: number) => {
          this.userRating = value;
        })

      Text(this.userRating > 0 ?
        `你打了 ${this.userRating}` : '请点击星星评分')
        .fontSize(14)
        .fontColor(this.userRating > 0 ? '#E74C3C' : '#999')
        .margin({ top: 12, bottom: 20 })

      Row() {
        Button('取消')
          .backgroundColor('#F0F0F0').fontColor('#666')
          .width(100).margin({ right: 12 })
          .onClick(() => { this.controller.close(); })

        Button('提交评分')
          .backgroundColor('#E74C3C').fontColor(Color.White)
          .width(120)
          .onClick(() => {
            // 提交评分逻辑
            console.log(`用户给《${this.movieTitle}》打了 ${this.userRating}`);
            this.controller.close();
          })
      }
    }
    .padding(24)
  }
}

5.2 在卡片点击时弹出评分弹窗

// 在组件中声明 CustomDialogController
dialogController: CustomDialogController = new CustomDialogController({
  builder: RatingDialog({
    movieTitle: '',
    userRating: 0
  }),
  autoCancel: true,
  alignment: DialogAlignment.Center,
  customStyle: true
});

// 在卡片 onClick 中打开评分弹窗
.onClick(() => {
  // 更新 dialog 参数
  this.dialogController = new CustomDialogController({
    builder: RatingDialog({
      movieTitle: movie.title,
      userRating: movie.avgRating  // 初始值设为当前平均分
    }),
    autoCancel: true,
    alignment: DialogAlignment.Center,
    customStyle: true
  });
  this.dialogController.open();
})

这样用户点击电影卡片后,不再是一个简单的信息展示弹窗,而是一个可以直接打分的交互界面——从"浏览评分"到"参与评分"形成完整闭环。


六、Rating 的视觉细节

6.1 默认样式

ArkUI 默认的 Rating 星星是金色填充 + 灰色边框的五角星图案。选中部分填充金色,未选中部分显示灰色轮廓。中间态(如半星 4.5)左侧金色、右侧灰色。

6.2 星星的"填充"逻辑

stepSize = 0.5rating = 4.3 时:

  • 前 4 颗星:完全填充(金色)
  • 第 5 颗星:部分填充(30% 金色,因为 4.3 - 4 = 0.3,不到 0.5)
  • 实际的视觉效果会四舍五入到最近的 stepSize 步进值

系统内部计算逻辑:filledStars = Math.floor(rating)partialStar = rating - filledStars,然后将 partialStar 量化到最近的 stepSize 倍数。

6.3 尺寸建议

使用场景 推荐尺寸 说明
列表中的缩略评分 width: 80~100, height: 14~18 紧凑,不抢眼
卡片中的评分展示 width: 100~140, height: 18~24 适中
详情页顶部评分 width: 160~200, height: 28~36 醒目,作为主要信息
交互式打分弹窗 width: 200~260, height: 40~48 足够大,便于手指点击
搜索结果/标签 width: 60~80, height: 12~14 极简,辅助信息

6.4 与文字配合

Rating 组件本身不显示数字——通常需要搭配 Text 来展示具体分值:

Row() {
  Rating({ rating: 4.5, indicator: true })
    .stars(5).stepSize(0.5)
    .width(100).height(18)
  Text('4.5')
    .fontSize(14).fontWeight(FontWeight.Bold)
    .fontColor('#E74C3C')
    .margin({ left: 8 })
  Text('(12.8万人评)')
    .fontSize(12).fontColor('#999')
    .margin({ left: 4 })
}

星星 + 数字 + 评价人数的三层结构,也是豆瓣、IMDb、大众点评等产品的标准展示模式。


七、常见面试题 / 踩坑点

7.1 indicator 模式中 onChange 会触发吗?

不会。当 indicator: true 时,Rating 进入纯展示模式——星星不可点击,onChange 永远不会触发。这是两者的核心区别:

// 展示模式:onChange 永远不会被调用
Rating({ rating: 4.5, indicator: true })
  .onChange((v) => { console.log('这行永远不会打印'); })

// 交互模式:onChange 在用户评分时被调用
Rating({ rating: 0, indicator: false })
  .onChange((v) => { console.log(`用户打了 ${v}`); })

7.2 如何实现"评分后不可修改"?

因为 Rating 是受控组件(通过 rating 参数控制显示值),设置过一次后把 rating 固定住即可:

@State score: number = 0;
@State rated: boolean = false;

// 在 onChange 中记录已评分
Rating({ rating: this.score, indicator: this.rated })
  .onChange((value: number) => {
    if (!this.rated) {
      this.score = value;
      this.rated = true;  // 评分后将 indicator 设为 true,变为只读
    }
  })

或者简单地用条件判断:

if (this.hasRated) {
  Rating({ rating: this.score, indicator: true })  // 已评分:只读展示
} else {
  Rating({ rating: 0, indicator: false })            // 未评分:可交互
    .onChange((v) => { this.score = v; this.hasRated = true; })
}

7.3 rating 初始值为 0 时,星星都显示为空吗?

是的。rating: 0 时所有星星都是灰色未填充状态。这适用于"请先评分"的初始状态。但如果你希望默认显示 3 星(中性评价),设置 rating: 3 即可。

7.4 stepSize 和 rating 精度不匹配会怎样?

框架会自动将 rating 量化到最接近的 stepSize 步进值:

Rating({ rating: 4.3, indicator: true })
  .stepSize(0.5)  // rating 4.3 → 显示为 4.5 星(最近的 0.5 步进值)

Rating({ rating: 4.3, indicator: true })
  .stepSize(1)    // rating 4.3 → 显示为 4 星(最近的整数步进值)

对于展示用途,通常设置 stepSize: 0.1 以保留评分精度。

7.5 Rating 可以放在 List 中吗?

可以。Rating 是一个轻量组件,可以嵌入到 List/ListItem、Scroll、Grid 等任何容器中。在本文 Demo 中,每张电影卡片都包含一个 Rating,性能表现正常。

如果列表项非常多(超过 50 条),建议配合 LazyForEach 实现按需渲染。

7.6 如何实现"零分不展示星星"?

条件渲染:

if (movie.avgRating > 0) {
  Rating({ rating: movie.avgRating, indicator: true })
    .stars(5).stepSize(0.5)
    .width(100).height(18)
} else {
  Text('暂无评分').fontSize(12).fontColor('#999')
}

八、总结

Rating 是 ArkUI 中"小而美"组件的典范——功能聚焦、API 简洁、与声明式 UI 范式完美融合。从展示评分到收集评分,从整数档位到半星精度,一个组件覆盖了评分系统的全部需求。

1. 两种模式,两种场景。 indicator: true 用于展示,indicator: false 用于交互。前者的使用量远远大于后者——每个电商列表页、电影推荐页、应用商店卡片都在用展示模式。但交互模式才是 Rating 区别于纯 Text 或 Image 的核心价值。

2. stepSize 决定精度体验。 stepSize: 1 适合简单场景(满意度调研),stepSize: 0.5 是电影/图书评分的行业标准,stepSize: 0.1 适合对精度有要求的专业评测场景。选择合适的步长,本身就是产品设计的一部分。

3. 星星 + 数字 + 人数,三层信息结构。 Rating 组件只管"视觉呈现",具体的数字和评价人数需要 Text 配合。星星(直觉)、数字(精确)、人数(可信度)三者缺一不可。

4. 与 @CustomDialog 天然配合。 列表中展示评分(indicator 模式),弹窗中收集评分(交互模式)——这个模式适用于所有"浏览→评价"的场景。Rating 与 Dialog、promptAction 等 API 的组合使用,是一套成熟的交互范式。

Rating 组件最合适的应用场景包括:

  • 电影/图书/音乐推荐页(展示平均评分)— 本文 Demo
  • 商品评价列表(展示用户评分分布)
  • 应用商店(展示 App 星级 + 评价数量)
  • 外卖/服务满意度调研(收集用户评分)
  • 课程/教程评价(展示综合评分 + 各维度分数)
  • 任何需要"用星星表达好恶"的 UI

从 0 行手动实现到 3 行声明式配置,Rating 是那种"一旦用过,就不会再想手动实现"的组件。它让"星星"不再是一个需要设计的 UI 元素,而是一个可以随时使用的标准化工具。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐