角标是 UI 中最轻量但也最关键的状态指示器——Tab 上的未读消息数、购物车图标上的商品数、列表项的状态圆点。它们不占用布局空间,但承载了"需要用户注意"的核心信息。ArkUI 的 Badge 组件用一个容器包裹任意内容,在指定位置叠加数字、文字或纯色圆点,覆盖了所有角标使用场景。本文用它构建一个"通知中心"页面,展示 Badge 在分类筛选、未读计数、状态标记中的完整应用。


一、为什么角标需要专门组件?

没有 Badge 组件时,实现角标通常用 Stack + 绝对定位:

// 手动实现:Stack + 定位 + 圆形背景 + 文字
Stack() {
  Text('消息')
  Text('5').fontSize(10).fontColor(Color.White)
    .width(16).height(16).borderRadius(8)
    .backgroundColor(Color.Red)
    .textAlign(TextAlign.Center)
    .position({ x: 30, y: -10 })
}

这个实现有几个问题:

  1. 位置需要手动计算(不同内容的角标位置不同)
  2. 长数字(99+)需要手动截断
  3. 零值角标(数量为0)需要手动隐藏
  4. 文字角标和数字角标需要不同的尺寸适配
  5. 无障碍属性需要手动添加

Badge 组件用一个容器包裹解决了所有这些问题:

Badge({ count: 5, position: BadgePosition.RightTop }) {
  Text('消息')
}

位置自动计算、数字自动格式化(超过99显示"99+")、count=0 自动隐藏、颜色和尺寸可配——一行容器代替了 6 行手动实现的代码。


在这里插入图片描述

二、Badge 核心 API

2.1 基本语法

Badge({
  count: number,            // 数字角标(0 自动隐藏)
  value?: string,           // 文字角标(与 count 互斥)
  position?: BadgePosition, // 位置
  style?: BadgeStyle,       // 样式
  maxCount?: number         // 最大显示数字
}) {
  // 被装饰的任意内容
}

关键参数:

  • count:角标数字。为 0 时角标自动隐藏(这是"0 = 不显示"的常见 UI 惯例)。
  • value:文字角标。如 'NEW''HOT'。与 count 互斥——传入 value 时忽略 count
  • position:角标位置。BadgePosition.RightTop(右上)、Right(右侧)、Left(左侧)等。
  • style:自定义样式对象。可设 badgeSize(角标大小)、badgeColor(背景色)、fontSize(数字字号)、fontColor(数字颜色)。
  • maxCount:最大显示数字。超过时显示 ${maxCount}+,默认 99。

2.2 三种角标模式

// 模式一:数字角标 — 未读消息数
Badge({ count: 5, position: BadgePosition.RightTop }) {
  Text('消息')
}

// 模式二:文字角标 — 新功能标记
Badge({ value: 'NEW', position: BadgePosition.RightTop,
  style: { badgeColor: '#FF4D4F', fontSize: 9, badgeSize: 18 } }) {
  Text('AI助手')
}

// 模式三:纯点角标 — 未读状态指示(空字符串 = 圆点)
Badge({ value: '', position: BadgePosition.RightTop,
  style: { badgeColor: '#FF4D4F', badgeSize: 8 } }) {
  Image($r('app.media.avatar')).width(40).height(40)
}

数字角标适合消息计数(“5条未读”),文字角标适合标签标记(“NEW”、“HOT”),纯点角标适合在线/离线/未读等二元状态——不显示数字,只显示一个彩色圆点。

2.3 位置与对齐

BadgePosition.RightTop     // 右上角(默认,最常用)
BadgePosition.Right        // 右侧居中
BadgePosition.Left         // 左侧居中

RightTop 是 Tab 图标角标的标准位置——角标覆盖在图标的右上角,不遮挡图标主体。Right 适用于列表项末尾的"数量指示器"。

2.4 零值自动隐藏

这是 Badge 最实用的特性:count: 0 时角标自动不可见。

// 未读为0时,角标自动消失 — 不需要手动判零
Badge({ count: this.unreadCount, position: BadgePosition.RightTop }) {
  Text('消息')
}

// 不需要写:
// if (this.unreadCount > 0) { Badge(...) } else { Text('消息') }

这个行为符合 UI 惯例——没有未读消息时,角标不应该显示"0"。框架内置了这条规则,开发者不需要额外处理。

2.5 maxCount 超限格式化

// 超过99显示"99+",超过999显示"999+"
Badge({ count: 150, maxCount: 99, position: BadgePosition.RightTop }) {
  Text('通知')  // 角标显示:"99+"
}

// 自定义上限
Badge({ count: 150, maxCount: 99, position: BadgePosition.RightTop }) {
  Text('通知')  // 角标显示:"99+"
}

默认 maxCount = 99,即 100 以上显示"99+"。对于需要更精确计数的场景(如购物车商品数),可设置 maxCount = 999


在这里插入图片描述

三、Demo:通知中心

本 Demo 构建一个分类筛选 + 未读标记的通知中心——5 个分类 Tab 上显示未读角标,列表项左侧图标显示已读/未读状态点。

页面结构

NotificationsPage (~200行)
├── Header(🔔 通知中心 + 总未读数 + 全部已读按钮)
├── 分类 Tab 栏(横向滚动)
│   ├── Badge(未读数) > "全部"
│   ├── Badge(未读数) > "系统"
│   ├── Badge(未读数) > "消息"
│   ├── Badge(未读数) > "活动"
│   └── Badge(未读数) > "更新"
├── 通知列表(已读/未读背景色区分)
│   └── 列表项 × N
│       ├── Badge(纯点·未读红点) > 图标
│       ├── 标题(未读粗体)
│       ├── 内容预览(2行截断)
│       └── 时间
└── 空态:📭 暂无通知

分类 Tab 上的数字角标

每个分类 Tab 包裹在 Badge 中,右上角显示该分类的未读通知数:

Badge({
  count: this.getUnreadCount(cat.key),
  position: BadgePosition.RightTop,
  style: { badgeSize: 16, badgeColor: cat.color }
}) {
  Text(cat.label)
    .fontSize(12)
    .fontColor(this.selectedCategory === cat.key ? Color.White :
    AppColors.TEXT_SECONDARY)
    .padding({ left: 14, right: 14, top: 6, bottom: 6 })
    .borderRadius(9999)
    .backgroundColor(this.selectedCategory === cat.key ?
    cat.color : '#F0F0F0')
}

每个分类使用不同的 badgeColor(系统蓝、消息绿、活动红、更新金),视觉上与分类主题一致。当某分类未读数为 0 时,角标自动隐藏。

列表项的状态点角标

每项通知的左侧图标外层包裹 Badge,右上角显示红色圆点(未读)或透明(已读):

Badge({
  value: '',  // 空字符串 = 纯圆点(不显示数字)
  position: BadgePosition.RightTop,
  style: {
    badgeSize: 8,
    badgeColor: n.isRead ? Color.Transparent : '#FF4D4F'
  }
}) {
  Column() {
    Text(n.icon).fontSize(24)
  }
  .width(48).height(48)
  .borderRadius(8)
  .backgroundColor(n.iconColor + '18')
  .justifyContent(FlexAlign.Center)
}

关键技巧:已读项设置 badgeColor: Color.Transparent(透明),点角标"消失"——而不是移除 Badge 组件。这样可以避免 DOM 结构变化导致的布局抖动。

替代方案:count: n.isRead ? 0 : 1, style: { badgeSize: 8, ... }——count=0 时角标自动隐藏。两种方式效果相同。

已读/未读视觉区分

除了角标圆点,列表项还有两层视觉区分:

  1. 背景色:未读 #F8FBFF(淡蓝),已读纯白
  2. 标题字重:未读 FontWeight.Bold,已读 FontWeight.Regular
.backgroundColor(n.isRead ? Color.White : '#F8FBFF')

Text(n.title)
  .fontWeight(n.isRead ? FontWeight.Regular : FontWeight.Bold)

点击列表项 → 标记为已读 → 圆点消失、背景变白、标题字重减轻。三个视觉层同时变化,给用户清晰的"已读"反馈。

全部已读

Header 右侧"全部已读"按钮,一键清除所有未读状态:

markAllRead(): void {
  this.notifications = this.notifications.map((n: NotificationItem) => {
    return new NotificationItem(n.id, n.title, n.content, n.time,
      n.category, n.icon, n.iconColor, true);
  });
}

所有角标(Tab 上的数字 + 列表项的红点)在数据更新后自动消失——因为 getUnreadCount 返回 0 → Badge count=0 → 自动隐藏。

四个交互点

  1. 分类筛选 — 点击分类 Tab(各带未读角标),列表仅显示该分类通知
  2. 标记已读 — 点击通知项 → 红点消失、背景变白、标题字重减轻
  3. 全部已读 — 点击 Header 按钮,所有未读角标清空
  4. 空态展示 — 选中的分类无通知时,显示"📭 暂无通知"占位

在这里插入图片描述

四、完整代码

import { AppColors, BorderRadius, FontSize, Spacing } from '../common/Constants';

class NotificationItem {
  id: number;
  title: string;
  content: string;
  time: string;
  category: string;
  icon: string;
  iconColor: string;
  isRead: boolean;

  constructor(id: number, title: string, content: string, time: string,
    category: string, icon: string, iconColor: string, isRead: boolean) {
    this.id = id;
    this.title = title;
    this.content = content;
    this.time = time;
    this.category = category;
    this.icon = icon;
    this.iconColor = iconColor;
    this.isRead = isRead;
  }
}

const NOTIFICATIONS: NotificationItem[] = [
  new NotificationItem(1, '系统更新提醒',
    'HarmonyOS NEXT 6.2.0 版本已推送,新增AI大模型能力和分布式协同增强功能。',
    '刚刚', 'system', '🔄', '#1677FF', false),
  new NotificationItem(2, '张三发来新消息',
    '你好!明天下午的会议改到3点可以吗?我这边有个临时的安排需要调整时间。',
    '3分钟前', 'message', '💬', '#52C41A', false),
  new NotificationItem(3, '你的文章被点赞',
    '文章《ArkUI状态管理V2深度解析》获得128个赞和36条评论。',
    '12分钟前', 'activity', '❤️', '#FF4D4F', false),
  new NotificationItem(4, '应用更新可用',
    'DevEco Studio 6.1.2、ArkUI组件库、方舟编译器均有重要更新。',
    '25分钟前', 'update', '📦', '#FAAD14', false),
  new NotificationItem(5, '系统存储空间不足',
    '设备存储空间已使用85%,建议清理缓存文件。',
    '1小时前', 'system', '⚠️', '#FF6B6B', true),
  new NotificationItem(6, '李四发来新消息',
    '看到了,方案A确实更好。我已经在文档里标注了修改意见。',
    '1小时前', 'message', '💬', '#52C41A', true),
  new NotificationItem(7, '电池健康度提醒',
    '电池健康度已降至82%,建议前往服务中心检测。',
    '2小时前', 'system', '🔋', '#1677FF', true),
  new NotificationItem(8, '评论回复提醒',
    '王五回复了你的评论:"非常实用的文章,收藏了!"',
    '2小时前', 'activity', '💬', '#FF4D4F', true),
  new NotificationItem(9, '隐私权限通知',
    '天气App在后台访问了位置信息,可以在设置中管理。',
    '3小时前', 'system', '🔒', '#722ED1', true),
  new NotificationItem(10, '系统安全补丁',
    '2026年6月安全补丁已下载完毕,点击重启设备完成安装。',
    '4小时前', 'update', '🛡️', '#FAAD14', true),
  new NotificationItem(11, '赵六发来新消息',
    '合同已经签好了,扫描件发到你邮箱了,请查收一下。',
    '5小时前', 'message', '💬', '#52C41A', true),
  new NotificationItem(12, '关注的人发新帖',
    '你关注的"鸿蒙开发者老王"发布了新文章《跨设备流转最佳实践》。',
    '昨天', 'activity', '📝', '#FF4D4F', true),
];

interface CategoryTab {
  key: string;
  label: string;
  color: string;
}

const CATEGORIES: CategoryTab[] = [
  { key: 'all', label: '全部', color: '#1677FF' },
  { key: 'system', label: '系统', color: '#1677FF' },
  { key: 'message', label: '消息', color: '#52C41A' },
  { key: 'activity', label: '活动', color: '#FF4D4F' },
  { key: 'update', label: '更新', color: '#FAAD14' },
];

@Entry
@Component
struct NotificationsPage {
  @State notifications: NotificationItem[] = NOTIFICATIONS.map(
  (n: NotificationItem) => {
    return new NotificationItem(n.id, n.title, n.content, n.time,
      n.category, n.icon, n.iconColor, n.isRead);
  });
  @State selectedCategory: string = 'all';

  getFilteredNotifications(): NotificationItem[] {
    if (this.selectedCategory === 'all') return [...this.notifications];
    return this.notifications.filter((n: NotificationItem) =>
    n.category === this.selectedCategory);
  }

  getUnreadCount(category: string): number {
    if (category === 'all')
      return this.notifications.filter((n: NotificationItem) => !n.isRead).length;
    return this.notifications.filter((n: NotificationItem) =>
    n.category === category && !n.isRead).length;
  }

  getTotalUnread(): number {
    return this.notifications.filter((n: NotificationItem) => !n.isRead).length;
  }

  markAsRead(id: number): void {
    this.notifications = this.notifications.map((n: NotificationItem) => {
      if (n.id === id && !n.isRead) {
        return new NotificationItem(n.id, n.title, n.content, n.time,
          n.category, n.icon, n.iconColor, true);
      }
      return n;
    });
  }

  markAllRead(): void {
    this.notifications = this.notifications.map((n: NotificationItem) => {
      return new NotificationItem(n.id, n.title, n.content, n.time,
        n.category, n.icon, n.iconColor, true);
    });
  }

  build() {
    Column() {
      Row() {
        Column() {
          Text('🔔 通知中心')
            .fontSize(FontSize.HEADLINE)
            .fontWeight(FontWeight.Bold)
            .fontColor(Color.White)
          if (this.getTotalUnread() > 0) {
            Text(`${this.getTotalUnread()} 条未读`)
              .fontSize(10).fontColor('#FFFFFF88').margin({ top: 2 })
          } else {
            Text('全部已读')
              .fontSize(10).fontColor('#FFFFFF88').margin({ top: 2 })
          }
        }
        .alignItems(HorizontalAlign.Start).layoutWeight(1)

        if (this.getTotalUnread() > 0) {
          Text('全部已读').fontSize(11).fontColor(Color.White)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .borderRadius(9999).backgroundColor('#FFFFFF22')
            .onClick(() => { this.markAllRead(); })
        }
      }
      .width('100%').height(92)
      .padding({ left: Spacing.XXL, right: Spacing.XXL })
      .backgroundColor('#1a1a2e')

      Scroll() {
        Row() {
          ForEach(CATEGORIES, (cat: CategoryTab) => {
            Badge({
              count: this.getUnreadCount(cat.key),
              position: BadgePosition.RightTop,
              style: { badgeSize: 16, badgeColor: cat.color }
            }) {
              Text(cat.label).fontSize(FontSize.CAPTION)
                .fontColor(this.selectedCategory === cat.key ? Color.White :
                AppColors.TEXT_SECONDARY)
                .fontWeight(this.selectedCategory === cat.key ?
                FontWeight.Medium : FontWeight.Regular)
                .padding({ left: 14, right: 14, top: 6, bottom: 6 })
                .borderRadius(9999)
                .backgroundColor(this.selectedCategory === cat.key ?
                cat.color : '#F0F0F0')
            }
            .margin({ right: Spacing.SM })
            .onClick(() => { this.selectedCategory = cat.key; })
          })
        }
        .padding({ left: Spacing.LG, right: Spacing.LG })
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off).width('100%')
      .padding({ top: Spacing.MD, bottom: Spacing.MD })
      .backgroundColor(Color.White)
      .border({ width: { bottom: 1 }, color: '#F0F0F0' })

      if (this.getFilteredNotifications().length === 0) {
        Column() {
          Text('📭').fontSize(48).margin({ bottom: Spacing.MD })
          Text('暂无通知').fontSize(FontSize.MEDIUM)
            .fontColor(AppColors.TEXT_TERTIARY)
        }
        .width('100%').layoutWeight(1)
        .justifyContent(FlexAlign.Center)
        .backgroundColor('#F5F6FA')
      } else {
        Scroll() {
          Column() {
            ForEach(this.getFilteredNotifications(),
            (n: NotificationItem) => {
              Row() {
                Column() {
                  Badge({
                    value: '',
                    position: BadgePosition.RightTop,
                    style: { badgeSize: 8, badgeColor: n.isRead ?
                    Color.Transparent : '#FF4D4F' }
                  }) {
                    Column() {
                      Text(n.icon).fontSize(24)
                    }
                    .width(48).height(48)
                    .borderRadius(BorderRadius.MD)
                    .backgroundColor(n.iconColor + '18')
                    .justifyContent(FlexAlign.Center)
                  }
                }
                .margin({ right: Spacing.LG })

                Column() {
                  Row() {
                    Text(n.title).fontSize(FontSize.BODY)
                      .fontColor(AppColors.TEXT_PRIMARY)
                      .fontWeight(n.isRead ? FontWeight.Regular :
                      FontWeight.Bold)
                      .maxLines(1).layoutWeight(1)
                      .textOverflow({ overflow: TextOverflow.Ellipsis })
                    Text(n.time).fontSize(10)
                      .fontColor(AppColors.TEXT_DISABLED)
                      .margin({ left: 8 })
                  }
                  .width('100%')

                  Text(n.content).fontSize(FontSize.CAPTION)
                    .fontColor(AppColors.TEXT_TERTIARY)
                    .maxLines(2).lineHeight(18).margin({ top: 4 })
                    .textOverflow({ overflow: TextOverflow.Ellipsis })
                }
                .layoutWeight(1).alignItems(HorizontalAlign.Start)
              }
              .width('100%').padding(Spacing.LG)
              .backgroundColor(n.isRead ? Color.White : '#F8FBFF')
              .border({ width: { bottom: 1 }, color: '#F0F0F0' })
              .onClick(() => { this.markAsRead(n.id); })
            })
          }
          .width('100%').padding({ bottom: Spacing.XXL })
        }
        .layoutWeight(1).scrollBar(BarState.Off)
        .backgroundColor('#F5F6FA')
      }
    }
    .width('100%').height('100%')
  }
}

五、Badge 的常见设计模式

5.1 Tab 角标 — 分类未读数

[ 全部⁵ ] [ 系统³ ] [ 消息¹ ] [ 活动⁰ ] [ 更新¹ ]

每个 Tab 右上角显示该分类的未读数。这是电商 App、社交 App、邮件客户端的标准模式。count: 0 时角标自动消失,所以"活动⁰"的角标不可见。

5.2 列表项状态点 — 已读/未读标识

🔴 [未读消息] 粗体标题 + 淡蓝背景
⚪ [已读消息] 常规标题 + 白色背景

纯点角标(value: '' + badgeSize: 8)用来标记"是否需要关注",不显示具体数字。适合消息列表、邮件列表、通知列表等"标记已读"场景。

5.3 数字角标 — 精确计数

购物车图标右上角 "3"
应用更新图标右上角 "12"

count + position: RightTop 是最常见的组合。maxCount 默认 99,超过自动显示"99+"——符合中文用户的数字阅读习惯。

5.4 文字角标 — 标签标记

[AI助手]NEW
[新功能]HOT
[限时]SALE

value 参数传入短文字,适合产品新功能引导、营销标签、状态标记。文字角标的尺寸需要手动调大(badgeSize: 18),因为汉字比数字需要更多空间。


六、常见面试题 / 踩坑点

6.1 count 和 value 能不能同时使用?

不能。value 的优先级高于 count——传入 value 时,count 被忽略。

// count 被忽略,角标显示 "NEW"
Badge({ count: 5, value: 'NEW', position: BadgePosition.RightTop }) { ... }

使用规则:数字用 count,文字用 value,二选一。

6.2 角标为零时真的会自动隐藏吗?

是的,这是 Badge 的设计特性。count: 0 → 角标不渲染:

// 未读数为0 → 角标不可见
Badge({ count: 0, position: BadgePosition.RightTop }) {
  Text('消息')
}

这个行为等价于:if (count > 0) { render badge } else { render nothing }——框架内置了这个逻辑。如果你需要显示"0"(如购物车空态),可以用 value: '0' 强制显示。

6.3 如何实现"NEW"文字角标?

Badge({
  value: 'NEW',
  position: BadgePosition.RightTop,
  style: {
    badgeColor: '#FF4D4F',
    badgeSize: 18,
    fontSize: 9,
    fontColor: Color.White
  }
}) {
  Text('AI助手')
}

注意 badgeSize: 18(而不是默认的 16),因为需要容纳 3 个字母。fontSize: 9 配合短文字刚好。

6.4 角标的位置不够灵活?

BadgePosition 提供三个预设位置:RightTop(右上)、Right(右侧居中)、Left(左侧居中)。对于大部分场景够用。

如果需要更精细的位置控制(如"距右上角偏移 8px"),可以用 .offset() 在 Badge 外层微调:

Badge({ count: 5, position: BadgePosition.RightTop }) {
  Text('消息')
}
.offset({ x: 4, y: -4 })  // 微调角标位置

6.5 Badge 内可以嵌套任意内容吗?

可以。Badge 是一个容器组件——接受任何子组件:

Badge({ count: 3 }) {
  Image($r('app.media.icon'))
}

Badge({ count: 5 }) {
  Button('提交')
}

Badge({ value: '' }) {
  Column() { ... }  // 复杂布局也可以
}

角标会渲染在子内容的指定位置(右上/右侧/左侧),不改变子内容的布局流。


七、总结

Badge 是那种"存在感低但缺了不行"的组件。它不占据布局流中的空间,不影响子内容的尺寸和位置,但它传递的信息——“这里有新东西需要你看”——是移动端交互的核心驱动力。

1. 三种模式覆盖全部角标场景。 数字角标(count)用于计数,文字角标(value)用于标记,纯点角标(value: '')用于状态指示。不需要三个组件——一个 Badge 全部搞定。

2. 零值自动隐藏是最实用的设计。 不用写 if (count > 0) ...count: 0 时角标自动消失。这个设计让"清除角标"只需要一步:把数据中的 count 更新为 0。

3. Badge 是容器,不改变布局。 角标悬浮在子内容之上,不挤占布局空间。这意味着给现有的 Tab、图标、按钮加上角标,不需要调整任何布局代码——加一层 Badge 包裹即可。

4. 与不可变更新天然配合。 标记已读 → map 创建新对象 → Badge 的 count 从数据重新计算 → 自动更新。整个流程不需要手动控制 Badge 的显隐——数据驱动一切。

Badge 最适用的场景:

  • 底部 Tab 导航角标(消息数、通知数、购物车数)
  • 消息/通知列表的未读状态点
  • 新功能/新版本的"NEW"、"HOT"标签
  • 联系人列表的在线状态(绿色圆点)
  • 任何"需要用户看一眼"的状态标记

Badge 是移动端 UI 系统的"标点符号"——它不承载主要内容,但没有了它,信息的层次和紧迫感就无从体现。

Logo

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

更多推荐