鸿蒙版 Flutter Badge 角标组件:未读消息、红点提醒与动态更新
鸿蒙版 Flutter Badge 角标组件:未读消息、红点提醒与动态更新
本文代码均为完整可运行片段,新建 Flutter 工程后整段复制即可,无需额外依赖
运行载体:鸿蒙真机(Mate 60 / Pura 70),基于 OHOS 适配版 Flutter SDK
本文技术栈速览
| 项目 | 取值 |
|---|---|
| Flutter SDK | 3.27.5-ohos-1.0.1(OpenHarmony 适配版,非 Google 官方版) |
| 运行设备 | 鸿蒙真机(Mate 60 / Pura 70),不支持 DevEco 模拟器 |
| 角标实现 | 自建 AppBadge 组件(Stack + Positioned) |
| 演示载体 | 消息中心:Tab 角标、列表项红点、可清除角标、动态更新 |
一、引言:角标是界面的"注意力指针"
设想一个没有角标的聊天应用:消息来了,图标毫无变化,用户永远不会知道有新信息。再设想一个角标滥用成灾的应用:每个图标上挂着"99+",用户对红点彻底免疫。两种极端之间,是角标这门"注意力管理"的艺术——角标存在的全部意义,是把用户的注意力精准地指向"哪里有事"。
角标的价值可以拆成两半:红点(圆点角标)说"有",数字角标说"有多少"。前者是"注意力开关"(一眼扫过去就知道要不要点开看看),后者是"注意力量化"(数字告诉你值不值得现在处理)。一开一量,构成角标的两极,覆盖了未读消息、红点提醒、待办计数、版本更新提示这些场景的完整需求。角标做得好,用户无需逐条查看就能调度自己的注意力优先级——这正是信息过载时代界面设计的关键能力。
角标设计里还有一个常被忽略的维度:角标是"状态变化"的传感器,不是"状态本身"的展示器。用户看到消息 Tab 上的"3",得到的信息不是"有 3 条消息",而是"这 3 条和我上次看到的不一样"。同样的数字,如果永远不变,用户就会把它当作壁纸的一部分忽略掉——这就是"角标疲劳"的成因。因此角标的每一次出现都必须对应一次真实的状态变化,每一次消失都必须对应一次真实的处理动作。本文的"消息中心"工程会完整呈现这条链路:数据源变化(新消息到达)→ 状态更新(未读数 +1)→ 角标刷新(数字跳增)→ 用户处理(点开/已读)→ 角标清除(数字归零),一个闭环走完,角标才算完成了它的使命。
ArkUI 原生提供 Badge 组件(count / maxCount / position / style 四个核心参数,数字/圆点两种风格,右上/左上/右下/左下四个位置);Flutter 的 Material 3 也内置了 Badge 组件,但它的定位是"标准用法"的快速通道,参数与 ArkUI 并不完全对齐。本文的做法是自建一个 AppBadge 组件——把 ArkUI 的四个参数(count / maxCount / position / style)完整映射到 Flutter 的 Stack + Positioned 上,实现原理完全可控、参数语义与 ArkUI 对齐,然后基于它实现一个"消息中心"页面:Tab 角标(未读数)、列表项红点、可清除角标、定时器驱动的动态更新。自建组件既讲透了角标的原理,又给出了 ArkUI 参数在 Flutter 里的完整对应——一举两得。
术语解释:角标(Badge)是附着在图标或元素角落的小标记,用于提示状态或计数;数字角标显示数量(可设上限截断),圆点角标只提示"有无";maxCount 是数字角标的显示上限,超过时显示"maxCount+"(如 99+)。
二、环境准备
环境与系列前文一致,要点速览:
| 组件 | 版本 / 说明 |
|---|---|
| Flutter SDK | 3.27.5-ohos-1.0.1(OpenHarmony 适配版) |
| Dart SDK | 3.6.2(随适配版内置) |
| DevEco Studio | 5.0 及以上(管理真机连接) |
| 鸿蒙真机 | Mate 60 / Pura 70,开启开发者模式与 USB 调试 |
三步开工:flutter create 生成工程 → USB 连接真机并确认设备在线 → flutter run -d <deviceId> 首构建。本文工程为纯 Dart 层实现,不涉及 ArkTS 原生插件,ohos/ 目录无需改动。
角标演示的真机验证要点:一是角标的渲染清晰度——角标通常很小(数字角标高 18px、圆点角标直径 9px),鸿蒙真机的高分屏上要确认文字不糊、描边清晰(本文数字角标用 1.5px 白色描边与背景隔离);二是角标与图标的重叠——Stack 的 clipBehavior 必须设为 none,否则角标伸出图标范围的部分会被裁掉(踩坑指南有专条);三是定时器驱动的动态更新——每 3 秒新消息 +1 时角标数字跳动要流畅,setState 只影响角标区域(NavigationBar 局部重建),整页重建是性能反模式。
本系列读者可能注意到,每篇的环境章节都强调同一组版本号与验证要点——这不是重复,而是鸿蒙适配版 Flutter 的工程现实:官方 Flutter 的文档、示例、社区经验都基于 Google 版 SDK,而鸿蒙真机上的行为(渲染、滚动、动画、定时器精度)以适配版为准。适配版与官方版的差异大多体现在细节上(如本文的 Stack 裁剪行为、高 DPI 下的文字渲染),这些细节恰恰是"真机验证"的价值所在——本系列坚持"每篇必真机、每篇必踩坑",就是为了把适配版的行为边界如实呈现给读者。若你的开发机上还有 Google 官方版 Flutter,两版共存时务必用 flutter --version 确认当前使用的是 ohos 适配版,误用官方版会直接导致 hvigor 构建失败(踩坑指南有专门说明)。
三、鸿蒙版 Flutter 与官方 Flutter 的差异对比
角标在鸿蒙适配版上的差异集中在"组件来源":ArkUI 内置、Flutter 内置(Material 3)但参数不对齐、自建最可控:
| 对比维度 | ArkUI 原生 | Flutter(OHOS 适配版) |
|---|---|---|
| 组件来源 | Badge 内置组件 | 内置 Badge + 自建(本文方案) |
| 核心参数 | count / maxCount / position / style | 内置 Badge 参数不同,自建完全对齐 |
| 位置 | 四角可配 | 内置 Badge 以 alignment 控制,自建 Positioned 四角 |
| 风格 | 数字 / 圆点 | 内置 Badge 以 label 有无区分,自建 style 枚举 |
| 截断 | maxCount | 内置需手动处理,自建 _label 内置 |
| 动态更新 | 改 count 重建 | setState 重建,原理一致 |
两点重点说明:
- 为什么自建 AppBadge:Flutter 的 Material Badge 组件定位是"标准用法快速通道",参数(label / child / alignment / backgroundColor / textColor)与 ArkUI 的 count / maxCount / position / style 并不对齐——直接用它写 ArkUI 风格代码需要来回转换。自建组件把 ArkUI 的参数语义原样落地(count + maxCount + position + style 四个参数 + 一个 child),原理用 Stack + Positioned 完全透明,还能内置截断、描边、动态显隐这些细节。参数对齐 + 原理透明 + 细节可控,这是自建组件在"组件生态与需求不完全对齐"场景下的标准答案;
- 内置 Badge 的适用边界:若你的场景只需要最标准的"右上角数字角标"且不需要 ArkUI 参数语义,直接用 Material Badge 是最省事的;一旦需要四角位置、圆点风格、maxCount 截断、白描边隔离,自建的成本几乎为零(一个 StatelessWidget),收益是全参数对齐——本文选自建,是"参数对齐优先"的决策,与 Stepper、Select 两文"内置优先,定制可控"的取向一脉相承。
除此之外,角标的 Stack 叠加、Positioned 定位、setState 重建在鸿蒙适配版上与官方一致,无系统级差异。
四、核心 API 解析:AppBadge 组件四要素
4.1 ArkUI Badge 与自建 AppBadge 参数对照
| ArkUI Badge | AppBadge | 说明 |
|---|---|---|
| count | count | 显示数量(0 时隐藏,圆点风格同理) |
| maxCount | maxCount | 显示上限,超过截断为 “maxCount+” |
| position | position 枚举 | 右上/左上/右下/左下 |
| style(数字/圆点) | style 枚举 | count / dot 两种风格 |
| — | child | 承载角标的元素(图标、头像、卡片) |
| — | 内置白描边 | 与背景隔离,增强可读性 |
4.2 四要素逐一拆解
count:数量与显隐
count 决定"显示什么 + 显不显示":数字风格下 count 是显示的数字,圆点风格下 count > 0 就显示圆点、count 为 0 隐藏。显隐规则集中在一个 getter:
bool get _visible => count > 0; // 圆点与数字统一按 count>0 显隐
显隐规则的语义:count 为 0 的角标不渲染——这不仅避免了"0 挂在图标上"的噪音,还省了一次 widget 构建。动态更新(消息 +1/-1)时,count 在 0 与正数之间切换,角标自动出现/消失,无需额外逻辑。
maxCount:显示上限的截断
maxCount 解决"数字过长"的问题:未读消息攒了 156 条,角标显示"156"会拉得过宽、挤占图标——截断为"99+",宽度恒定、语义清楚。截断逻辑一行:
String get _label => count > maxCount ? '$maxCount+' : '$count';
maxCount 的取值策略:社交应用常用 99(两位数上限),电商购物车常用 999,截断上限越高,数字角标越宽,视觉越重——99 是"通用且克制"的默认值。
position:四角定位
position 枚举映射到 Stack 的 Positioned 四角偏移:
| 枚举 | 语义 | Positioned 偏移 |
|---|---|---|
| topRight | 右上(默认) | right: -6, top: -6 |
| topLeft | 左上 | top: -6, left: -6 |
| bottomRight | 右下 | right: -6, bottom: -6 |
| bottomLeft | 左下 | left: -6, bottom: -6 |
负偏移让角标"探出"元素边缘半个身位——角标与元素既有重叠感(属于它)又有分离感(看得清),负 6px 是标准的手感值。实现上用扩展方法把枚举转成偏移量,四个位置一个扩展,页面里按枚举选位即可。
style:数字与圆点
数字角标与圆点角标是两套渲染,本文拆成 _CountBadge 与 _DotBadge 两个私有组件:数字角标是"圆形胶囊 + 随位数拉宽"(一位数 18px 圆、两位数 26px 胶囊、三位数 34px 更宽),圆点角标是固定 9px 正圆。数字角标的宽度随位数自适应是细节工程——minWidth: 18 + (位数-1) * 8 让 1 位数是圆、多位数是胶囊,宽度永远贴合内容。
4.3 组件骨架:Stack + Positioned 的叠加模型
AppBadge 的骨架是三层结构:child(载体)在最底、角标在最上、整体用 Stack 叠加。核心一行是 clipBehavior: Clip.none——默认的 Stack 会裁掉溢出范围的内容,角标探出元素的部分会被切掉,必须显式关闭裁剪。
4.4 位置语义:角标四角的"文化惯例"
position 的四角选项不是随便定的,各角有约定俗成的语义,选用时尽量贴合惯例:
| 位置 | 惯例语义 | 典型场景 |
|---|---|---|
| 右上 | 最通用:新内容/数量 | Tab 角标、图标角标(绝大多数) |
| 左上 | 状态/属性标记 | 头像上的在线状态、认证标记 |
| 右下 | 操作/工具标记 | 卡片上的快捷操作入口 |
| 左下 | 附属信息标记 | 媒体元素的格式标记(如"HD") |
惯例的意义在于"可预测性":用户看到右上角的数字会自然理解为"这里有 N 条未读",看到左上角的标记会理解为"这有个状态"——遵循惯例,用户无需学习;打破惯例(比如把未读数放在左下角),用户就要多一步思考。本文的演示页把四个位置并排展示(图 3),正是为了让读者直观感受"同一个角标,换一个角,语义就变了"。
还有一个细节:右上角是"默认"并不等于"唯一"——当载体本身有右向操作(如列表项右侧的箭头图标)时,角标挂在右上会和箭头抢视觉;此时考虑左上角反而更清爽。位置的选择本质是"注意力避让"的艺术:角标永远让开元素最重要的部分。
4.5 style 的深层语义:数字与圆点的"打扰等级"
style 的选择不是纯视觉偏好,背后是"打扰等级"的决策:
- 数字角标(count):显示精确数量,吸引注意力最强——适用于"数量驱动行动"的场景(未读消息、购物车件数),用户看到"156"会评估"现在要不要处理";
- 圆点角标(dot):只显示"有",吸引注意力最弱——适用于"存在即提醒"的场景(版本更新、新内容),用户看一眼知道"有新的"就足够。
一个实用的决策准则:拿不准时选圆点——圆点是打扰下限,数字是打扰上限,能用圆点解决的需求不升级为数字;反之,一旦某个入口从圆点升级为数字,说明它的信息量确实值得数字(比如从"有新动态"升级为"有 3 条未读消息")。本文的消息 Tab 用数字(未读数对用户有价值)、首页用圆点(红点只是提示),正是这个准则的实践。
五、消息中心页面的状态设计
角标是"状态的可视化",页面设计的核心是状态与角标的映射关系。
5.1 状态字段与角标的映射
消息中心有三个状态源,各自驱动对应的角标:
| 状态字段 | 含义 | 驱动角标 |
|---|---|---|
_unread |
消息未读数 | 消息 Tab 数字角标 |
_newsUnread |
动态未读数 | 动态 Tab 数字角标(maxCount 9) |
_homeDot |
首页红点 | 首页 Tab 圆点角标 |
消息列表 unread 标记 |
单条消息是否未读 | 列表项右侧角标 |
状态与角标的映射规则:一个状态字段只驱动一个角标,一个角标只反映一个状态字段——不串台、不混用,排查时一个字段对应一个显示,思路清晰。
5.2 动态更新:定时器驱动新消息
动态更新用 Timer 每 3 秒模拟一条新消息:未读数 +1、首页红点点亮、列表头插一条未读消息。定时器演示了"状态源"的概念:角标只是显示层,真正的数据源是"新消息到达"这个事件——真实项目中它来自推送、轮询或 WebSocket,本文用定时器模拟,把"数据源 → 状态 → 角标"的链路完整跑通。定时器的生命周期纪律与 Video 一文一致:dispose 里 cancel,页面销毁即停,不泄漏。
5.3 清除逻辑:角标的"回收"
可清除角标有两种粒度:单条清除(点某条消息 → 该条未读标记清除 → 未读数 -1)与全部已读(一键清零所有角标)。单条清除的边界处理:未读数不能减成负数——用 ( _unread - 1 ).clamp(0, 9999) 兜底;全部已读的联动范围:未读数归零、动态数归零、首页红点熄灭、列表所有 unread 标记清空——四路状态同步清零,漏一路就是"角标清了列表还挂着未读标记"的状态脱节。
5.4 角标更新性能
角标的更新频率与范围是性能的两把尺子:频率由数据源决定(定时器 3 秒一次、推送随时到达),范围由 setState 的粒度决定。本文的 setState 发生在页面 State,重建范围是 NavigationBar 与当前列表页——角标在 NavigationBar 内,重建成本(三个图标 + 三个角标)极小。性能纪律:角标更新用最小粒度 setState(局部状态),不用全局状态管理触发整树重建;角标组件本身是 StatelessWidget + 简单 Container,构建成本近乎为零,性能瓶颈只可能来自"更新范围过大",而那是页面设计问题,不是角标组件问题。本文的定时器更新对帧率零影响,真机实测顺畅——这正是"状态最小化"的收益。
再往深一层,角标更新的性能本质是**"重建什么"与"不重建什么"的选择**。以本文为例,定时器每 3 秒触发一次 setState,重建的是整个页面 State 的 build 输出——NavigationBar 的三个 destination 和当前列表页。列表页如果是几百条消息的长列表,重建成本就会上升;解决思路是把列表抽成独立的 StatefulWidget(列表页自己管理自己的 setState),角标所在的 NavigationBar 单独一套状态——两个状态域互不牵连,角标跳增时列表不重建、列表滚动时角标不重建。这是"状态域隔离"的原则:一个状态域内共享的更新频率与重建范围成正比,域划分得越细,重建范围越小。本文规模下不需要这么重的拆法,但读者在自己的真实项目里,消息列表 + 角标系统几乎是标配,这个"域隔离"思路值得提前布局。
另外有一个性能误区要澄清:角标数字变化用动画会放大成本。数字 3→4 的动画(AnimatedSwitcher)虽然观感精致,但每次动画都触发一次隐式动画重建;高频更新(每秒多次推送)时,动画反而成为性能负担。本文的定位是"可靠优先"——数字直接跳变,不引入动画层。若你的产品需要动画,先确认更新频率:低频(秒级以下)可以加动画,高频(秒级以上)保持直变。动画是锦上添花,可靠是底线——角标的核心诉求是"准确",不是"好看"。
性能瓶颈只可能来自"更新范围过大",而那是页面设计问题,不是角标组件问题。本文的定时器更新对帧率零影响,真机实测顺畅——这正是"状态最小化"的收益。
六、完整代码实现:消息中心
本文代码全部内嵌,先给依赖配置,再给完整入口代码,最后分模块讲解。
6.1 pubspec.yaml
name: badge_demo
description: "消息中心:Flutter 鸿蒙版(OHOS)Badge 角标组件实战配套工程"
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ^3.6.2
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter:
uses-material-design: true
6.2 完整入口代码
import 'dart:async';
import 'package:flutter/material.dart';
void main() {
runApp(const BadgeDemoApp());
}
/// 消息中心:Tab 角标、列表项红点、可清除角标、动态更新
class BadgeDemoApp extends StatelessWidget {
const BadgeDemoApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: '消息中心',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF0A59F7)),
useMaterial3: true,
),
home: const MessageCenterPage(),
);
}
}
/// 自建角标组件:count / maxCount / position / style 四要素
class AppBadge extends StatelessWidget {
final int count;
final int maxCount;
final BadgePosition position; // 右上/左上/右下/左下
final BadgeStyle style; // 数字 / 圆点
final Widget child;
const AppBadge({
super.key,
required this.count,
this.maxCount = 99,
this.position = BadgePosition.topRight,
this.style = BadgeStyle.count,
required this.child,
});
bool get _visible => count > 0;
String get _label => count > maxCount ? '$maxCount+' : '$count';
Widget build(BuildContext context) {
return Stack(
clipBehavior: Clip.none,
children: [
child,
if (_visible)
Positioned(
right: position.right,
top: position.top,
child: style == BadgeStyle.dot
? const _DotBadge()
: _CountBadge(label: _label, count: count),
),
],
);
}
}
enum BadgePosition { topRight, topLeft, bottomRight, bottomLeft }
extension BadgePositionX on BadgePosition {
double get right => switch (this) {
BadgePosition.topRight || BadgePosition.bottomRight => -6,
_ => -2,
};
double get top => switch (this) {
BadgePosition.topRight || BadgePosition.topLeft => -6,
_ => -2,
};
}
enum BadgeStyle { count, dot }
/// 数字角标:圆形胶囊,随位数自动拉宽
class _CountBadge extends StatelessWidget {
final String label;
final int count;
const _CountBadge({required this.label, required this.count});
Widget build(BuildContext context) {
final digits = label.length;
final size = 18.0 + (digits > 1 ? (digits - 1) * 8 : 0);
return Container(
height: 18,
constraints: BoxConstraints(minWidth: size),
padding: const EdgeInsets.symmetric(horizontal: 5),
decoration: BoxDecoration(
color: const Color(0xFFE53935),
borderRadius: BorderRadius.circular(9),
border: Border.all(color: Colors.white, width: 1.5),
),
alignment: Alignment.center,
child: Text(
label,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
height: 1,
),
),
);
}
}
/// 圆点角标:固定尺寸小圆点
class _DotBadge extends StatelessWidget {
const _DotBadge();
Widget build(BuildContext context) {
return Container(
width: 9,
height: 9,
decoration: BoxDecoration(
color: const Color(0xFFE53935),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 1.5),
),
);
}
}
class MessageCenterPage extends StatefulWidget {
const MessageCenterPage({super.key});
State<MessageCenterPage> createState() => _MessageCenterPageState();
}
class _MessageCenterPageState extends State<MessageCenterPage> {
int _unread = 3; // 消息 Tab 未读数
int _newsUnread = 1; // 动态 Tab 未读数
bool _homeDot = true; // 首页红点
int _tabIndex = 0;
// 模拟消息列表:每条消息是否已读
final List<Map<String, Object>> _messages = [
{'title': '系统通知:版本更新', 'unread': true},
{'title': '新粉丝关注了你', 'unread': true},
{'title': '订单发货提醒', 'unread': true},
{'title': '优惠券到账通知', 'unread': false},
{'title': '社区周报已生成', 'unread': false},
{'title': '客服回复了你的咨询', 'unread': true},
];
Timer? _simulator;
void initState() {
super.initState();
// 动态更新演示:每 3 秒模拟收到一条新消息
_simulator = Timer.periodic(const Duration(seconds: 3), (_) {
if (!mounted) return;
setState(() {
_unread += 1;
_newsUnread += 1;
_homeDot = true;
_messages.insert(
0,
{'title': '新消息 ${DateTime.now().hour}:${DateTime.now().minute}', 'unread': true},
);
});
});
}
void dispose() {
_simulator?.cancel();
super.dispose();
}
// 清除单条消息角标
void _clearMessage(int index) {
setState(() {
if (_messages[index]['unread'] == true) {
_unread = (_unread - 1).clamp(0, 9999);
}
_messages[index]['unread'] = false;
});
}
// 全部已读:清除所有角标
void _markAllRead() {
setState(() {
_unread = 0;
_newsUnread = 0;
_homeDot = false;
for (final m in _messages) {
m['unread'] = false;
}
});
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('消息中心'),
centerTitle: true,
actions: [
TextButton.icon(
onPressed: _unread > 0 ? _markAllRead : null,
icon: const Icon(Icons.done_all),
label: const Text('全部已读'),
),
],
),
body: _tabIndex == 0 ? _buildMessageList() : _buildNewsPage(),
bottomNavigationBar: NavigationBar(
selectedIndex: _tabIndex,
onDestinationSelected: (i) {
setState(() {
_tabIndex = i;
if (i == 1) _newsUnread = 0; // 查看动态后清除该 Tab 角标
});
},
destinations: [
NavigationDestination(
icon: AppBadge(
count: _homeDot ? 1 : 0,
style: BadgeStyle.dot,
child: const Icon(Icons.home_outlined),
),
selectedIcon: AppBadge(
count: _homeDot ? 1 : 0,
style: BadgeStyle.dot,
child: const Icon(Icons.home),
),
label: '首页',
),
NavigationDestination(
icon: AppBadge(
count: _unread,
child: const Icon(Icons.message_outlined),
),
selectedIcon: AppBadge(
count: _unread,
child: const Icon(Icons.message),
),
label: '消息',
),
NavigationDestination(
icon: AppBadge(
count: _newsUnread,
maxCount: 9,
child: const Icon(Icons.bolt_outlined),
),
selectedIcon: AppBadge(
count: _newsUnread,
maxCount: 9,
child: const Icon(Icons.bolt),
),
label: '动态',
),
],
),
);
}
Widget _buildMessageList() {
return ListView.separated(
itemCount: _messages.length,
separatorBuilder: (_, __) => const Divider(height: 1, indent: 16),
itemBuilder: (context, index) {
final item = _messages[index];
final unread = item['unread'] == true;
return ListTile(
leading: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
child: Icon(
unread ? Icons.mark_email_unread : Icons.mark_email_read,
color: Theme.of(context).colorScheme.primary,
),
),
title: Text(
item['title'] as String,
style: TextStyle(fontWeight: unread ? FontWeight.w600 : null),
),
subtitle: Text(unread ? '未读' : '已读'),
trailing: unread
? AppBadge(
count: 1,
child: const Icon(Icons.chevron_right, color: Colors.grey),
)
: const Icon(Icons.chevron_right, color: Colors.grey),
onTap: () => _clearMessage(index),
);
},
);
}
Widget _buildNewsPage() {
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text('角标位置演示', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_demoBadge(BadgePosition.topRight, '右上'),
_demoBadge(BadgePosition.topLeft, '左上'),
_demoBadge(BadgePosition.bottomRight, '右下'),
_demoBadge(BadgePosition.bottomLeft, '左下'),
],
),
const SizedBox(height: 24),
Text('角标风格演示', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Column(children: [
AppBadge(
count: 5,
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.shopping_bag_outlined),
),
),
const SizedBox(height: 8),
const Text('数字角标'),
]),
Column(children: [
AppBadge(
count: 1,
style: BadgeStyle.dot,
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.notifications_outlined),
),
),
const SizedBox(height: 8),
const Text('圆点角标'),
]),
Column(children: [
AppBadge(
count: 156,
maxCount: 99,
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.mail_outline),
),
),
const SizedBox(height: 8),
const Text('超限截断 99+'),
]),
],
),
const SizedBox(height: 24),
const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text(
'角标是"注意力指引"而非"信息载体"——只告诉你"这里有东西",不负责展示全部内容。'
'数字角标传达"有多少",圆点角标只传达"有没有"。',
style: TextStyle(height: 1.6),
),
),
),
],
);
}
Widget _demoBadge(BadgePosition position, String label) {
return Column(
children: [
AppBadge(
count: 8,
position: position,
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.apps),
),
),
const SizedBox(height: 8),
Text(label),
],
);
}
}
6.3 分模块讲解
AppBadge 组件:四要素(count/maxCount/position/style)+ child,Stack + Positioned 叠加,clipBehavior: Clip.none 防裁剪,count>0 显隐、maxCount 截断、数字/圆点两套渲染拆分。组件是 StatelessWidget,纯粹由参数决定渲染——可预测、可复用、可测试。
消息列表:ListTile 列表,未读项标题加粗 + 右侧数字角标(count: 1 的红点式角标挂在箭头图标上,点击清除)。列表用 ListView.separated(分隔线内置),数据驱动渲染——列表项的角标状态就是数据里的 unread 标记。
动态更新:Timer 每 3 秒新消息 +1,状态三路联动(未读 +1、动态 +1、首页红点亮)+ 列表头插新消息。dispose 取消定时器,页面销毁即停。
全部已读:四路状态同步清零(未读/动态/红点/列表标记),按钮在无未读时禁用(_unread > 0 才可点)——动作可用性与状态对齐,与 Select、QRCode 两文一致。
风格与位置演示页:切换到动态 Tab 后展示四个位置(右上/左上/右下/左下)与三种风格(数字/圆点/99+ 截断)的对照卡片——把 AppBadge 的参数能力可视化,读者对照截图一眼看懂每个参数的效果。
代码结构与职责边界:整个工程只有两个文件——pubspec.yaml 与 main.dart,组件(AppBadge)、页面(MessageCenterPage)、状态(unread 系列字段)都在一个文件内。这个安排对 8000 字级别的单篇教程是合理的(读者整段复制、一键运行),但在真实项目中,建议拆成三层:组件层(badge.dart,AppBadge 独立文件)、页面层(message_page.dart,页面与状态)、数据层(message_model.dart,消息模型与聚合逻辑)。拆层的意义在于职责边界清晰:组件层不感知业务状态、数据层不感知 UI 细节、页面层只做桥接——角标系统的演进(加动画、加推送、加主题)都在各自的层内完成,互不侵入。本系列的文章代码刻意保持"单文件可运行",读者若要落地到项目,先按这三层拆开,再按本文的性能建议做状态域隔离。
七、真机运行与效果展示
7.1 运行步骤
- USB 连接鸿蒙真机,DevEco Studio 设备列表确认在线(图 1);
flutter run -d <deviceId>首构建,hvigor 编译原生层;- 真机呈现消息中心主页(图 2);
- 按演示脚本逐项操作(图 3~图 6);
- 终端确认编译日志无 error(图 7)。
7.2 截图占位
截图占位共 7 张,覆盖角标的核心形态与交互:
图 1:DevEco Studio 设备列表(鸿蒙真机在线)
图 2:消息中心主页(数字角标)

图 3:圆点角标与不同位置角标
图 4:数字角标与动态更新
7.3 演示脚本
| 步骤 | 操作 | 预期结果 |
|---|---|---|
| 1 | 启动应用 | 消息 Tab 显示 3、动态 Tab 显示 1、首页有红点 |
| 2 | 切到动态 Tab | 角标数字清零(查看即清除),展示四个位置与三种风格 |
| 3 | 等待 3 秒 | 动态角标 +1,首页红点亮起 |
| 4 | 切回消息 Tab | 未读数随新消息增长 |
| 5 | 点击一条未读消息 | 该条变已读,未读数减一 |
| 6 | 点"全部已读" | 所有角标清零,列表全已读,按钮禁用 |
| 7 | 再次等待 3 秒 | 新消息到达,角标重新出现(动态更新循环) |
7.4 角标验证的三条金线
角标系统的验证,三条金线要逐一过:
- 显隐正确性:count 在 0 与正数间切换时角标出现/消失,且与数据源(未读数)严格同步——"角标还在但数据已清"是最典型的脱节 bug;
- 联动一致性:单条清除与全部已读后,数字角标、红点、列表标记三处显示一致——一处清了另一处还挂着,就是联动漏了;
- 动态平稳性:定时器驱动更新时角标跳动平滑、无卡顿、无闪烁——"动态更新不打扰"是角标体验的底线。
三条金线对应本文的三个核心机制:显隐规则、联动清除、动态更新。金线全过,角标系统的工程闭环才算合上。
八、角标设计规范
角标是"注意力指针",用得好是指引,用滥了是噪音。规范收束角标的使用边界:
8.1 用角标的场景
| 场景 | 角标类型 | 说明 |
|---|---|---|
| 未读消息 | 数字角标 | 数量有价值,值得显示 |
| 新内容提示 | 圆点角标 | 只提醒"有新的",数量不重要 |
| 待办计数 | 数字角标 | 数量驱动行动优先级 |
| 版本更新 | 圆点角标 | 一次性的提示,无需计数 |
| 购物车 | 数字角标 | 件数一目了然 |
8.2 不用角标的场景
| 场景 | 替代 | 理由 |
|---|---|---|
| 核心操作提醒 | 弹窗 / SnackBar | 角标太轻,核心操作要重提醒 |
| 持续存在的状态 | 图标/文字常驻 | 角标是"变化提示",常驻即失效 |
| 超过 999 的计数 | 文案折叠(如 1.2万) | 数字过长角标会失控 |
| 三个以上角标同屏 | 合并入口 | 角标过多=没有角标 |
8.3 角标设计的五条铁律
- 一个界面最多三个角标:超过三个,注意力被稀释到没有指引作用——宁可用"全部动态"入口聚合;
- 红点优先于数字:能只提醒"有"就别暴露数量——红点是打扰最小的提醒,数字是打扰最大的提醒,能用小打扰解决的就不要上大打扰;
- 数字要可清零:用户看过(进入页面、打开列表、点击 Tab)后角标必须消失——不可清除的角标会积累到用户无视,形成"角标疲劳";
- 角标要即时更新:数据一变角标就变,不能等刷新——迟到的角标(该消没消、该现没现)比没有角标更糟;
- maxCount 宁低勿高:99 是通用上限,999 已经是视觉噪音——截断上限越低,角标越轻巧。
8.4 角标的颜色语义
角标默认用红色系(本文 0xFFE53935),但"红=角标"不是唯一解,颜色也是语义的一部分:
| 颜色 | 语义 | 适用场景 |
|---|---|---|
| 红色 | 紧急/重要/警示 | 未读消息、报错、超时(默认) |
| 橙色 | 中等优先级 | 待办、提醒、订阅到期 |
| 蓝色 | 中性信息 | 新内容、功能提示、推荐 |
| 绿色 | 正向状态 | 已成功、在线、健康 |
换色的原则:颜色与内容的"紧急度"匹配——消息未读用红(用户在意的强度高),新内容提示用蓝(中性告知),绿色极少用于角标(角标本质是"有事"提示,正向状态通常用常驻图标表达)。配色还要与主题色系统保持一致:深浅色模式下角标颜色要可读(深色模式用亮红、浅色模式用标准红),描边颜色要随背景自适应(本文固定白描边,在深色导航栏上也成立,因为角标永远悬在元素之上、元素背后是页面背景,白描边与任意背景都能形成对比)。
九、无障碍与角标语义
角标是视觉提示,对读屏用户必须转成语义提示。三项要求:
| 要求 | 做法 | 落点 |
|---|---|---|
| 数字角标播报 | Semantics 合并到载体 | 播报"消息,3 条未读" |
| 圆点角标播报 | 语义标注"有新内容" | 播报"首页,有更新" |
| 操作可达 | 清除操作按钮语义清晰 | "全部已读"动词化 |
两个容易被忽略的细节:
- 角标语义要合并进载体:角标是装饰性元素,读屏焦点应落在载体(图标)上、播报内容包含角标信息——用
Semantics(label: '消息,3 条未读')包裹,而不是让角标单独成为焦点(单独焦点会让读屏用户在一个图标上停留两次); - 动态更新的播报策略:新消息到达时角标数字变化,无需逐次播报(会刷屏)——只需在用户聚焦到图标时播报当前值,动态变化用系统通知提示音补充。验证标准:真机开启屏幕朗读,从主页到清空角标全程走一遍,每个角标的"有无 + 数量"都能被完整播报。
十、真机调试踩坑指南
| 症状 | 根因 | 解法 |
|---|---|---|
| 角标被截掉一半 | Stack 默认裁剪溢出 | clipBehavior: Clip.none |
| 数字角标挤成一团 | 宽度未随位数自适应 | minWidth 随位数拉宽 |
| 角标显示 0 | 未处理 count=0 显隐 | count>0 才渲染 |
| 清除后角标还在 | 状态联动漏了某处 | 四路状态同步清零 |
| 未读数变负数 | 重复清除同一条 | clamp 兜底 |
| 动态更新卡顿 | setState 重建范围过大 | 最小粒度 setState,局部重建 |
| 角标文字发虚 | 角标过小 + 无描边 | 白描边 + 足够字号 |
| 红点看不清 | 圆点太小 | 9px 起,可加描边增强 |
| 角标与图标间距怪异 | 偏移值不适配图标尺寸 | 负偏移随图标大小微调 |
| 真机日志找不到 Flutter 输出 | 日志走 hdc 而非 adb | hdc shell hilog 过滤 flutter 关键字 |
10.1 一段典型的踩坑实录
初版的消息列表角标踩了"数字成负数"的坑:点一条未读消息,未读数 -1,但同一时间定时器恰好 +1——读数和新增竞态,界面出现"3→2→4"的跳动,连续快速点击多条时数字短暂变成 -1(负数角标显示 “-1” 挂在图标上,惨不忍睹)。修复就是两处:单条清除用 clamp(0, 9999) 兜底负数,动态新增与清除各自独立修改自己的状态字段(互不覆盖)——竞态的本质是"多个数据源同时改一个状态",把"读数"与"新增"两个来源各自收敛到独立字段再汇总,竞态空间就消失了。这个坑的教训是:角标的数字是"聚合结果",聚合的每个来源都要独立维护,谁都不能直接改聚合值。
10.2 角标逻辑的测试姿势
角标系统适合用 widget 测试锁定,三个高频用例:
- 显隐切换:count 从 0 到 1 → 断言角标出现;从 1 到 0 → 断言角标消失;
- 截断逻辑:count 156、maxCount 99 → 断言显示"99+";
- 清除联动:点未读消息 → 断言未读数 -1、该条标记已读、角标消失。
三条用例恰好覆盖角标的核心逻辑:显隐、截断、联动。角标的渲染正确性(颜色、位置、尺寸)属于视觉验证,真机截图核对即可,不必写进单元测试——测试锁定逻辑,截图锁定外观,两条验证路径分工明确。
写测试时还有两个实践建议:其一,把角标逻辑与 UI 解耦——AppBadge 的显隐、截断是纯函数(输入 count/maxCount 输出 label/visible),可以抽成可单测的纯逻辑,widget 测试只验证"状态变化 → 组件更新"的链路;其二,用 pump 推进定时器——动态更新用例里 Timer.periodic 用 tester.pump(Duration(seconds: 3)) 推进虚拟时间,断言角标数字 +1,无需真实等待。这两个建议把"逻辑测试"与"时间测试"分开,测试的稳定性与速度都更优——角标系统的维护成本,很大一部分被测试锁住,改代码时才有底气。
十一、总结与扩展
角标是界面的"注意力指针",本文自建 AppBadge 组件把 ArkUI 的 count / maxCount / position / style 四个参数完整映射到 Flutter 的 Stack + Positioned,并基于它实现消息中心页面:Tab 角标、列表项红点、可清除角标、定时器驱动的动态更新。五条铁律值得背下来:一屏最多三个角标、红点优先于数字、数字必须可清零、角标即时更新、maxCount 宁低勿高。三条工程纪律:count>0 才渲染、状态联动同步清、聚合值各来源独立维护。
从本文工程出发可以扩展的方向:
- 角标动画:角标出现/消失的缩放动画(AnimatedScale)、数字变化动画(AnimatedSwitcher);
- 角标主题化:颜色、描边、尺寸跟随主题,深浅色模式自适应;
- 推送接入:把定时器替换为真实推送通道,角标随推送消息实时更新;
- 角标聚合组件:封装"多入口角标管理器",统一维护多个 Tab 的角标状态;
- 阅读位置记忆:记录每条消息的阅读位置,退出重进后角标与列表状态恢复。
最后用甘特图回顾消息中心的开发节奏,延续本系列(Button → TextInput → Search → Grid → CustomDialog → Stepper → Select → QRCode → Video → Badge)的工程化节奏:
角标的哲学是"克制":它只在"有事"时出现,在"没事"时消失,永远不占用多余空间。好的角标系统,用户感觉不到角标的存在,只感觉到"该看的地方有人提醒我"。这种"克制"的美德,比任何花哨的动画都更能赢得用户的信任——角标会消失,信任会留下。
下一篇预告:本系列下一篇文章将继续深入 Flutter 鸿蒙版(OHOS)的组件生态,保持"零第三方插件、完整可运行、真机验证、踩坑实录"的写作传统。读者可以从系列已有的 Button、TextInput、Search、Grid、CustomDialog、Stepper、Select、QRCode、Video、Badge 中任选一篇开始,每篇独立成章、相互印证——组件在不同场景下的组合使用,正是鸿蒙版 Flutter 工程能力的试金石。
更多推荐


所有评论(0)