鸿蒙新特性:Badge 角标组件 — 通知未读计数与状态标记深度解析
角标是 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 })
}
这个实现有几个问题:
- 位置需要手动计算(不同内容的角标位置不同)
- 长数字(99+)需要手动截断
- 零值角标(数量为0)需要手动隐藏
- 文字角标和数字角标需要不同的尺寸适配
- 无障碍属性需要手动添加
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 时角标自动隐藏。两种方式效果相同。
已读/未读视觉区分
除了角标圆点,列表项还有两层视觉区分:
- 背景色:未读
#F8FBFF(淡蓝),已读纯白 - 标题字重:未读
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 → 自动隐藏。
四个交互点
- 分类筛选 — 点击分类 Tab(各带未读角标),列表仅显示该分类通知
- 标记已读 — 点击通知项 → 红点消失、背景变白、标题字重减轻
- 全部已读 — 点击 Header 按钮,所有未读角标清空
- 空态展示 — 选中的分类无通知时,显示"📭 暂无通知"占位

四、完整代码
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 系统的"标点符号"——它不承载主要内容,但没有了它,信息的层次和紧迫感就无从体现。
更多推荐

所有评论(0)