鸿蒙版 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 重建,原理一致

两点重点说明:

  1. 为什么自建 AppBadge:Flutter 的 Material Badge 组件定位是"标准用法快速通道",参数(label / child / alignment / backgroundColor / textColor)与 ArkUI 的 count / maxCount / position / style 并不对齐——直接用它写 ArkUI 风格代码需要来回转换。自建组件把 ArkUI 的参数语义原样落地(count + maxCount + position + style 四个参数 + 一个 child),原理用 Stack + Positioned 完全透明,还能内置截断、描边、动态显隐这些细节。参数对齐 + 原理透明 + 细节可控,这是自建组件在"组件生态与需求不完全对齐"场景下的标准答案;
  2. 内置 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 运行步骤

  1. USB 连接鸿蒙真机,DevEco Studio 设备列表确认在线(图 1);
  2. flutter run -d <deviceId> 首构建,hvigor 编译原生层;
  3. 真机呈现消息中心主页(图 2);
  4. 按演示脚本逐项操作(图 3~图 6);
  5. 终端确认编译日志无 error(图 7)。

7.2 截图占位

截图占位共 7 张,覆盖角标的核心形态与交互:

图 1:DevEco Studio 设备列表(鸿蒙真机在线)
在这里插入图片描述

*图 1 说明:设备列表中 Mate 60 状态为 Online*

图 2:消息中心主页(数字角标)

在这里插入图片描述

*图 2 说明:底部 Tab 上消息 3、动态 1、首页红点;消息列表未读项带加粗与角标*

图 3:圆点角标与不同位置角标
在这里插入图片描述

*图 3 说明:动态 Tab 页展示四个位置的角标与圆点风格、99+ 截断对照*

图 4:数字角标与动态更新
在这里插入图片描述

*图 4 说明:等待数秒后新消息到达,消息 Tab 数字跳增、首页红点亮起、列表头插入新消息*

7.3 演示脚本

步骤 操作 预期结果
1 启动应用 消息 Tab 显示 3、动态 Tab 显示 1、首页有红点
2 切到动态 Tab 角标数字清零(查看即清除),展示四个位置与三种风格
3 等待 3 秒 动态角标 +1,首页红点亮起
4 切回消息 Tab 未读数随新消息增长
5 点击一条未读消息 该条变已读,未读数减一
6 点"全部已读" 所有角标清零,列表全已读,按钮禁用
7 再次等待 3 秒 新消息到达,角标重新出现(动态更新循环)

7.4 角标验证的三条金线

角标系统的验证,三条金线要逐一过:

  1. 显隐正确性:count 在 0 与正数间切换时角标出现/消失,且与数据源(未读数)严格同步——"角标还在但数据已清"是最典型的脱节 bug;
  2. 联动一致性:单条清除与全部已读后,数字角标、红点、列表标记三处显示一致——一处清了另一处还挂着,就是联动漏了;
  3. 动态平稳性:定时器驱动更新时角标跳动平滑、无卡顿、无闪烁——"动态更新不打扰"是角标体验的底线。

三条金线对应本文的三个核心机制:显隐规则、联动清除、动态更新。金线全过,角标系统的工程闭环才算合上。


八、角标设计规范

角标是"注意力指针",用得好是指引,用滥了是噪音。规范收束角标的使用边界:

8.1 用角标的场景

场景 角标类型 说明
未读消息 数字角标 数量有价值,值得显示
新内容提示 圆点角标 只提醒"有新的",数量不重要
待办计数 数字角标 数量驱动行动优先级
版本更新 圆点角标 一次性的提示,无需计数
购物车 数字角标 件数一目了然

8.2 不用角标的场景

场景 替代 理由
核心操作提醒 弹窗 / SnackBar 角标太轻,核心操作要重提醒
持续存在的状态 图标/文字常驻 角标是"变化提示",常驻即失效
超过 999 的计数 文案折叠(如 1.2万) 数字过长角标会失控
三个以上角标同屏 合并入口 角标过多=没有角标

8.3 角标设计的五条铁律

  1. 一个界面最多三个角标:超过三个,注意力被稀释到没有指引作用——宁可用"全部动态"入口聚合;
  2. 红点优先于数字:能只提醒"有"就别暴露数量——红点是打扰最小的提醒,数字是打扰最大的提醒,能用小打扰解决的就不要上大打扰
  3. 数字要可清零:用户看过(进入页面、打开列表、点击 Tab)后角标必须消失——不可清除的角标会积累到用户无视,形成"角标疲劳";
  4. 角标要即时更新:数据一变角标就变,不能等刷新——迟到的角标(该消没消、该现没现)比没有角标更糟;
  5. maxCount 宁低勿高:99 是通用上限,999 已经是视觉噪音——截断上限越低,角标越轻巧。

8.4 角标的颜色语义

角标默认用红色系(本文 0xFFE53935),但"红=角标"不是唯一解,颜色也是语义的一部分:

颜色 语义 适用场景
红色 紧急/重要/警示 未读消息、报错、超时(默认)
橙色 中等优先级 待办、提醒、订阅到期
蓝色 中性信息 新内容、功能提示、推荐
绿色 正向状态 已成功、在线、健康

换色的原则:颜色与内容的"紧急度"匹配——消息未读用红(用户在意的强度高),新内容提示用蓝(中性告知),绿色极少用于角标(角标本质是"有事"提示,正向状态通常用常驻图标表达)。配色还要与主题色系统保持一致:深浅色模式下角标颜色要可读(深色模式用亮红、浅色模式用标准红),描边颜色要随背景自适应(本文固定白描边,在深色导航栏上也成立,因为角标永远悬在元素之上、元素背后是页面背景,白描边与任意背景都能形成对比)。


九、无障碍与角标语义

角标是视觉提示,对读屏用户必须转成语义提示。三项要求:

要求 做法 落点
数字角标播报 Semantics 合并到载体 播报"消息,3 条未读"
圆点角标播报 语义标注"有新内容" 播报"首页,有更新"
操作可达 清除操作按钮语义清晰 "全部已读"动词化

两个容易被忽略的细节:

  1. 角标语义要合并进载体:角标是装饰性元素,读屏焦点应落在载体(图标)上、播报内容包含角标信息——用 Semantics(label: '消息,3 条未读') 包裹,而不是让角标单独成为焦点(单独焦点会让读屏用户在一个图标上停留两次);
  2. 动态更新的播报策略:新消息到达时角标数字变化,无需逐次播报(会刷屏)——只需在用户聚焦到图标时播报当前值,动态变化用系统通知提示音补充。验证标准:真机开启屏幕朗读,从主页到清空角标全程走一遍,每个角标的"有无 + 数量"都能被完整播报。

十、真机调试踩坑指南

症状 根因 解法
角标被截掉一半 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 测试锁定,三个高频用例:

  1. 显隐切换:count 从 0 到 1 → 断言角标出现;从 1 到 0 → 断言角标消失;
  2. 截断逻辑:count 156、maxCount 99 → 断言显示"99+";
  3. 清除联动:点未读消息 → 断言未读数 -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 才渲染、状态联动同步清、聚合值各来源独立维护

从本文工程出发可以扩展的方向:

  1. 角标动画:角标出现/消失的缩放动画(AnimatedScale)、数字变化动画(AnimatedSwitcher);
  2. 角标主题化:颜色、描边、尺寸跟随主题,深浅色模式自适应;
  3. 推送接入:把定时器替换为真实推送通道,角标随推送消息实时更新;
  4. 角标聚合组件:封装"多入口角标管理器",统一维护多个 Tab 的角标状态;
  5. 阅读位置记忆:记录每条消息的阅读位置,退出重进后角标与列表状态恢复。

最后用甘特图回顾消息中心的开发节奏,延续本系列(Button → TextInput → Search → Grid → CustomDialog → Stepper → Select → QRCode → Video → Badge)的工程化节奏:

2026-10-05 2026-10-06 2026-10-07 2026-10-08 2026-10-09 2026-10-10 2026-10-11 2026-10-12 2026-10-13 2026-10-14 环境与真机联调 角标四要素梳理 AppBadge 组件 消息列表与可清除角标 Tab 角标与动态更新 风格位置演示页 真机验证与截图采集 文章撰写与修订 准备 开发 验证 消息中心开发计划

角标的哲学是"克制":它只在"有事"时出现,在"没事"时消失,永远不占用多余空间。好的角标系统,用户感觉不到角标的存在,只感觉到"该看的地方有人提醒我"。这种"克制"的美德,比任何花哨的动画都更能赢得用户的信任——角标会消失,信任会留下。

下一篇预告:本系列下一篇文章将继续深入 Flutter 鸿蒙版(OHOS)的组件生态,保持"零第三方插件、完整可运行、真机验证、踩坑实录"的写作传统。读者可以从系列已有的 Button、TextInput、Search、Grid、CustomDialog、Stepper、Select、QRCode、Video、Badge 中任选一篇开始,每篇独立成章、相互印证——组件在不同场景下的组合使用,正是鸿蒙版 Flutter 工程能力的试金石。

Logo

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

更多推荐