OpenHarmony PC徽章列表 - 用Flutter实现
·
案例概述
本案例展示如何在列表中添加徽章,用于显示未读数量、状态指示、优先级等重要信息。徽章是一种轻量级的视觉指示器,用于吸引用户注意力并传达重要信息。在消息应用、电商应用、任务管理系统等中,徽章常用于显示未读消息数、待处理订单数、优先级标记等。
徽章系统需要处理动态更新、条件显示、响应式设计、性能优化等问题。此外,还应考虑动画效果、无障碍支持、主题适配等。在 PC 端应用中,徽章需要在不同屏幕尺寸下保持清晰可见,并提供良好的交互体验。
核心概念
1. Badge 组件与徽章类型
徽章有多种类型和用途:
- 计数徽章:显示未读数量或待处理项数,如消息应用中的未读消息数
- 状态徽章:表示项目的状态,如在线/离线、已读/未读等
- 优先级徽章:表示项目的优先级,如高/中/低优先级
- 标签徽章:显示分类或标签信息
- 动态徽章:实时更新的徽章,如计时器或进度指示
2. 徽章位置与布局
徽章的位置影响用户体验:
- 图标右上角:常见于应用图标或列表项图标
- 列表项右侧:显示在列表项的尾部
- 自定义位置:通过 Stack 和 Positioned 实现任意位置
- 浮动徽章:在内容上方浮动显示
3. 徽章内容与样式
徽章可以包含多种内容和样式:
- 数字内容:显示具体数量
- 文本内容:显示状态或标签
- 图标内容:显示状态图标
- 自定义颜色:根据优先级或状态使用不同颜色
- 动画效果:添加缩放、旋转等动画
代码详解
1. 基础徽章实现
创建一个基础的徽章列表需要使用 Badge 组件和 ListTile 的组合:
class BadgeListWidget extends StatefulWidget {
State<BadgeListWidget> createState() => _BadgeListWidgetState();
}
class _BadgeListWidgetState extends State<BadgeListWidget> {
List<BadgeItem> items = [
BadgeItem(id: '1', title: '消息', icon: Icons.message, count: 5),
BadgeItem(id: '2', title: '通知', icon: Icons.notifications, count: 3),
BadgeItem(id: '3', title: '订单', icon: Icons.shopping_cart, count: 0),
BadgeItem(id: '4', title: '评论', icon: Icons.comment, count: 12),
];
Widget build(BuildContext context) {
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return ListTile(
leading: Badge(
label: Text(item.count.toString()),
isLabelVisible: item.count > 0,
backgroundColor: _getBadgeColor(item.count),
child: Icon(item.icon, size: 28),
),
title: Text(item.title),
subtitle: Text('${item.count} 条未读'),
trailing: IconButton(
icon: Icon(Icons.close),
onPressed: () => _clearBadge(index),
),
);
},
);
}
Color _getBadgeColor(int count) {
if (count > 10) return Colors.red;
if (count > 5) return Colors.orange;
return Colors.blue;
}
void _clearBadge(int index) {
setState(() {
items[index].count = 0;
});
}
}
class BadgeItem {
final String id;
final String title;
final IconData icon;
int count;
BadgeItem({
required this.id,
required this.title,
required this.icon,
required this.count,
});
}
2. 自定义徽章样式
创建可自定义样式的徽章组件,支持不同的颜色、大小和形状:
class CustomBadgeWidget extends StatelessWidget {
final String label;
final Color backgroundColor;
final Color textColor;
final double size;
final Widget child;
final BadgeShape shape;
const CustomBadgeWidget({
required this.label,
this.backgroundColor = Colors.red,
this.textColor = Colors.white,
this.size = 24,
required this.child,
this.shape = BadgeShape.circle,
});
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.topRight,
children: [
child,
if (label.isNotEmpty)
Container(
width: size,
height: size,
decoration: BoxDecoration(
color: backgroundColor,
shape: shape == BadgeShape.circle ? BoxShape.circle : BoxShape.rectangle,
borderRadius: shape == BadgeShape.roundedSquare
? BorderRadius.circular(size / 3)
: null,
border: Border.all(color: Colors.white, width: 2),
),
child: Center(
child: Text(
label,
style: TextStyle(
color: textColor,
fontSize: size * 0.4,
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
],
);
}
}
enum BadgeShape { circle, square, roundedSquare }
3. 动画徽章
为徽章添加动画效果,如缩放、旋转、闪烁等:
class AnimatedBadgeWidget extends StatefulWidget {
final String label;
final Widget child;
final BadgeAnimationType animationType;
const AnimatedBadgeWidget({
required this.label,
required this.child,
this.animationType = BadgeAnimationType.scale,
});
State<AnimatedBadgeWidget> createState() => _AnimatedBadgeWidgetState();
}
class _AnimatedBadgeWidgetState extends State<AnimatedBadgeWidget>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
void initState() {
super.initState();
_controller = AnimationController(
duration: Duration(milliseconds: 1000),
vsync: this,
)..repeat(reverse: true);
_animation = Tween<double>(begin: 1.0, end: 1.3).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
}
void dispose() {
_controller.dispose();
super.dispose();
}
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.topRight,
children: [
widget.child,
if (widget.label.isNotEmpty)
AnimatedBuilder(
animation: _animation,
builder: (context, child) {
if (widget.animationType == BadgeAnimationType.scale) {
return Transform.scale(scale: _animation.value, child: child);
} else if (widget.animationType == BadgeAnimationType.rotate) {
return Transform.rotate(angle: _animation.value * 0.5, child: child);
} else {
return Opacity(opacity: _animation.value, child: child);
}
},
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
child: Center(
child: Text(
widget.label,
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
),
),
],
);
}
}
enum BadgeAnimationType { scale, rotate, fade }
4. 响应式徽章列表
根据屏幕宽度调整徽章的大小和布局:
class ResponsiveBadgeListWidget extends StatefulWidget {
State<ResponsiveBadgeListWidget> createState() => _ResponsiveBadgeListWidgetState();
}
class _ResponsiveBadgeListWidgetState extends State<ResponsiveBadgeListWidget> {
List<BadgeItem> items = [
BadgeItem(id: '1', title: '消息', icon: Icons.message, count: 5),
BadgeItem(id: '2', title: '通知', icon: Icons.notifications, count: 3),
BadgeItem(id: '3', title: '订单', icon: Icons.shopping_cart, count: 0),
];
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final isMobile = screenWidth < 600;
final isTablet = screenWidth < 1200;
return LayoutBuilder(
builder: (context, constraints) {
if (isMobile) {
return _buildMobileLayout();
} else if (isTablet) {
return _buildTabletLayout();
} else {
return _buildDesktopLayout();
}
},
);
}
Widget _buildMobileLayout() {
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return Padding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: ListTile(
leading: Badge(
label: Text(item.count.toString()),
isLabelVisible: item.count > 0,
child: Icon(item.icon),
),
title: Text(item.title),
subtitle: Text('${item.count} 条'),
),
);
},
);
}
Widget _buildTabletLayout() {
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Card(
child: ListTile(
leading: Badge(
label: Text(item.count.toString()),
isLabelVisible: item.count > 0,
child: Icon(item.icon, size: 32),
),
title: Text(item.title, style: TextStyle(fontSize: 18)),
subtitle: Text('${item.count} 条未读', style: TextStyle(fontSize: 14)),
trailing: IconButton(
icon: Icon(Icons.clear),
onPressed: () => _clearBadge(index),
),
),
),
);
},
);
}
Widget _buildDesktopLayout() {
return GridView.builder(
padding: EdgeInsets.all(24),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
childAspectRatio: 1.2,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return Card(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Badge(
label: Text(item.count.toString()),
isLabelVisible: item.count > 0,
child: Icon(item.icon, size: 40),
),
SizedBox(height: 12),
Text(item.title, style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
SizedBox(height: 8),
Text('${item.count} 条', style: TextStyle(color: Colors.grey)),
],
),
);
},
);
}
void _clearBadge(int index) {
setState(() {
items[index].count = 0;
});
}
}
高级话题:徽章的企业级应用
1. 动态/响应式设计与多屏幕适配
class BadgeSizeManager {
static const double MOBILE_BADGE_SIZE = 20;
static const double TABLET_BADGE_SIZE = 24;
static const double DESKTOP_BADGE_SIZE = 28;
static double getBadgeSize(BuildContext context) {
final width = MediaQuery.of(context).size.width;
if (width < 600) return MOBILE_BADGE_SIZE;
if (width < 1200) return TABLET_BADGE_SIZE;
return DESKTOP_BADGE_SIZE;
}
static double getIconSize(BuildContext context) {
final width = MediaQuery.of(context).size.width;
if (width < 600) return 24;
if (width < 1200) return 32;
return 40;
}
static EdgeInsets getListPadding(BuildContext context) {
final width = MediaQuery.of(context).size.width;
if (width < 600) return EdgeInsets.symmetric(horizontal: 8);
if (width < 1200) return EdgeInsets.symmetric(horizontal: 16);
return EdgeInsets.symmetric(horizontal: 24);
}
}
2. 动画与过渡效果
class AnimatedBadgeCountWidget extends StatefulWidget {
final int count;
final Duration duration;
const AnimatedBadgeCountWidget({
required this.count,
this.duration = const Duration(milliseconds: 500),
});
State<AnimatedBadgeCountWidget> createState() => _AnimatedBadgeCountWidgetState();
}
class _AnimatedBadgeCountWidgetState extends State<AnimatedBadgeCountWidget>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
late Animation<double> _opacityAnimation;
void initState() {
super.initState();
_controller = AnimationController(duration: widget.duration, vsync: this);
_setupAnimations();
}
void _setupAnimations() {
_scaleAnimation = Tween<double>(begin: 0.5, end: 1.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.elasticOut),
);
_opacityAnimation = Tween<double>(begin: 0, end: 1.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeIn),
);
}
void didUpdateWidget(AnimatedBadgeCountWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.count != widget.count) {
_controller.forward(from: 0);
}
}
void dispose() {
_controller.dispose();
super.dispose();
}
Widget build(BuildContext context) {
return ScaleTransition(
scale: _scaleAnimation,
child: FadeTransition(
opacity: _opacityAnimation,
child: Badge(
label: Text(widget.count.toString()),
isLabelVisible: widget.count > 0,
),
),
);
}
}
3. 搜索/过滤/排序功能
class BadgeFilterManager {
List<BadgeItem> _allItems;
List<BadgeItem> _filteredItems;
String _searchQuery = '';
String _sortBy = 'count'; // count, title, id
bool _sortAscending = false;
bool _showOnlyWithBadges = false;
BadgeFilterManager(this._allItems) : _filteredItems = _allItems;
void setSearchQuery(String query) {
_searchQuery = query;
_applyFilters();
}
void setSortBy(String sortBy, bool ascending) {
_sortBy = sortBy;
_sortAscending = ascending;
_applyFilters();
}
void setShowOnlyWithBadges(bool show) {
_showOnlyWithBadges = show;
_applyFilters();
}
void _applyFilters() {
_filteredItems = _allItems.where((item) {
// 搜索过滤
final matchesSearch = _searchQuery.isEmpty ||
item.title.toLowerCase().contains(_searchQuery.toLowerCase());
// 徽章过滤
final matchesBadgeFilter = !_showOnlyWithBadges || item.count > 0;
return matchesSearch && matchesBadgeFilter;
}).toList();
// 排序
_filteredItems.sort((a, b) {
int comparison = 0;
switch (_sortBy) {
case 'count':
comparison = a.count.compareTo(b.count);
break;
case 'title':
comparison = a.title.compareTo(b.title);
break;
case 'id':
comparison = a.id.compareTo(b.id);
break;
}
return _sortAscending ? comparison : -comparison;
});
}
List<BadgeItem> get filteredItems => _filteredItems;
}
4. 选择与批量操作
class SelectableBadgeListWidget extends StatefulWidget {
final List<BadgeItem> items;
const SelectableBadgeListWidget({required this.items});
State<SelectableBadgeListWidget> createState() => _SelectableBadgeListWidgetState();
}
class _SelectableBadgeListWidgetState extends State<SelectableBadgeListWidget> {
final Set<String> _selectedIds = {};
void _toggleSelection(String id) {
setState(() {
if (_selectedIds.contains(id)) {
_selectedIds.remove(id);
} else {
_selectedIds.add(id);
}
});
}
void _clearAllBadges() {
// 批量清除选中项的徽章
for (var item in widget.items) {
if (_selectedIds.contains(item.id)) {
item.count = 0;
}
}
setState(() {
_selectedIds.clear();
});
}
Widget build(BuildContext context) {
return Column(
children: [
if (_selectedIds.isNotEmpty)
Container(
padding: EdgeInsets.all(16),
color: Colors.blue.shade50,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('已选择 ${_selectedIds.length} 项'),
ElevatedButton(
onPressed: _clearAllBadges,
child: Text('清除徽章'),
),
],
),
),
Expanded(
child: ListView.builder(
itemCount: widget.items.length,
itemBuilder: (context, index) {
final item = widget.items[index];
final isSelected = _selectedIds.contains(item.id);
return CheckboxListTile(
value: isSelected,
onChanged: (_) => _toggleSelection(item.id),
leading: Badge(
label: Text(item.count.toString()),
isLabelVisible: item.count > 0,
child: Icon(item.icon),
),
title: Text(item.title),
subtitle: Text('${item.count} 条'),
);
},
),
),
],
);
}
}
5. 加载与缓存策略
class BadgeDataManager {
List<BadgeItem> _cachedItems = [];
bool _isLoading = false;
Future<List<BadgeItem>> loadBadgeItems() async {
if (_cachedItems.isNotEmpty) {
return _cachedItems;
}
_isLoading = true;
try {
// 模拟网络请求
await Future.delayed(Duration(milliseconds: 500));
_cachedItems = [
BadgeItem(id: '1', title: '消息', icon: Icons.message, count: 5),
BadgeItem(id: '2', title: '通知', icon: Icons.notifications, count: 3),
BadgeItem(id: '3', title: '订单', icon: Icons.shopping_cart, count: 0),
];
return _cachedItems;
} finally {
_isLoading = false;
}
}
void updateBadgeCount(String id, int count) {
final index = _cachedItems.indexWhere((item) => item.id == id);
if (index >= 0) {
_cachedItems[index].count = count;
}
}
void clearCache() {
_cachedItems.clear();
}
bool get isLoading => _isLoading;
}
6. 键盘导航与快捷键
class KeyboardNavigableBadgeList extends StatefulWidget {
final List<BadgeItem> items;
const KeyboardNavigableBadgeList({required this.items});
State<KeyboardNavigableBadgeList> createState() => _KeyboardNavigableBadgeListState();
}
class _KeyboardNavigableBadgeListState extends State<KeyboardNavigableBadgeList> {
int _focusedIndex = 0;
late FocusNode _focusNode;
void initState() {
super.initState();
_focusNode = FocusNode();
}
void dispose() {
_focusNode.dispose();
super.dispose();
}
void _handleKeyEvent(RawKeyEvent event) {
if (event.isKeyPressed(LogicalKeyboardKey.arrowDown)) {
setState(() {
_focusedIndex = (_focusedIndex + 1) % widget.items.length;
});
} else if (event.isKeyPressed(LogicalKeyboardKey.arrowUp)) {
setState(() {
_focusedIndex = (_focusedIndex - 1 + widget.items.length) % widget.items.length;
});
} else if (event.isKeyPressed(LogicalKeyboardKey.enter)) {
// 清除当前项的徽章
setState(() {
widget.items[_focusedIndex].count = 0;
});
}
}
Widget build(BuildContext context) {
return RawKeyboardListener(
focusNode: _focusNode,
onKey: _handleKeyEvent,
child: ListView.builder(
itemCount: widget.items.length,
itemBuilder: (context, index) {
final item = widget.items[index];
final isFocused = index == _focusedIndex;
return Container(
decoration: BoxDecoration(
border: isFocused ? Border.all(color: Colors.blue, width: 2) : null,
),
child: ListTile(
leading: Badge(
label: Text(item.count.toString()),
isLabelVisible: item.count > 0,
child: Icon(item.icon),
),
title: Text(item.title),
subtitle: Text('${item.count} 条'),
),
);
},
),
);
}
}
7. 无障碍支持与屏幕阅读器
class AccessibleBadgeListWidget extends StatelessWidget {
final List<BadgeItem> items;
const AccessibleBadgeListWidget({required this.items});
Widget build(BuildContext context) {
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return Semantics(
label: '${item.title},有 ${item.count} 条未读',
button: true,
enabled: true,
onTap: () => print('选择: ${item.title}'),
child: ListTile(
leading: Semantics(
label: '${item.title}徽章',
child: Badge(
label: Text(item.count.toString()),
isLabelVisible: item.count > 0,
child: Icon(item.icon),
),
),
title: Semantics(
label: '标题',
child: Text(item.title),
),
subtitle: Semantics(
label: '未读数量',
child: Text('${item.count} 条未读'),
),
),
);
},
);
}
}
8. 样式自定义与主题适配
class BadgeTheme {
final Color badgeColor;
final Color textColor;
final double badgeSize;
final double fontSize;
final EdgeInsets padding;
const BadgeTheme({
this.badgeColor = Colors.red,
this.textColor = Colors.white,
this.badgeSize = 24,
this.fontSize = 12,
this.padding = const EdgeInsets.all(4),
});
static BadgeTheme light() {
return BadgeTheme(
badgeColor: Colors.red,
textColor: Colors.white,
badgeSize: 24,
fontSize: 12,
);
}
static BadgeTheme dark() {
return BadgeTheme(
badgeColor: Colors.red.shade700,
textColor: Colors.white,
badgeSize: 24,
fontSize: 12,
);
}
}
class ThemedBadgeWidget extends StatelessWidget {
final String label;
final Widget child;
final BadgeTheme theme;
const ThemedBadgeWidget({
required this.label,
required this.child,
required this.theme,
});
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.topRight,
children: [
child,
if (label.isNotEmpty)
Container(
width: theme.badgeSize,
height: theme.badgeSize,
decoration: BoxDecoration(
color: theme.badgeColor,
shape: BoxShape.circle,
),
child: Center(
child: Text(
label,
style: TextStyle(
color: theme.textColor,
fontSize: theme.fontSize,
fontWeight: FontWeight.bold,
),
),
),
),
],
);
}
}
9. 数据持久化与导出
class BadgeDataExporter {
static String exportToCSV(List<BadgeItem> items) {
final buffer = StringBuffer();
buffer.writeln('ID,标题,未读数');
for (var item in items) {
buffer.writeln('${item.id},${item.title},${item.count}');
}
return buffer.toString();
}
static String exportToJSON(List<BadgeItem> items) {
final jsonList = items.map((item) => {
'id': item.id,
'title': item.title,
'count': item.count,
}).toList();
return jsonEncode(jsonList);
}
static Future<void> saveToFile(String filename, String content) async {
final directory = await getApplicationDocumentsDirectory();
final file = File('${directory.path}/$filename');
await file.writeAsString(content);
}
}
10. 单元测试与集成测试
void main() {
group('Badge Tests', () {
test('徽章计数更新', () {
final item = BadgeItem(
id: '1',
title: '消息',
icon: Icons.message,
count: 5,
);
expect(item.count, 5);
item.count = 10;
expect(item.count, 10);
});
test('徽章过滤', () {
final items = [
BadgeItem(id: '1', title: '消息', icon: Icons.message, count: 5),
BadgeItem(id: '2', title: '通知', icon: Icons.notifications, count: 0),
BadgeItem(id: '3', title: '订单', icon: Icons.shopping_cart, count: 3),
];
final filtered = items.where((item) => item.count > 0).toList();
expect(filtered.length, 2);
});
test('徽章排序', () {
final items = [
BadgeItem(id: '1', title: '消息', icon: Icons.message, count: 5),
BadgeItem(id: '2', title: '通知', icon: Icons.notifications, count: 10),
BadgeItem(id: '3', title: '订单', icon: Icons.shopping_cart, count: 3),
];
items.sort((a, b) => b.count.compareTo(a.count));
expect(items[0].count, 10);
expect(items[1].count, 5);
expect(items[2].count, 3);
});
});
testWidgets('Badge List Widget 集成测试', (WidgetTester tester) async {
final items = [
BadgeItem(id: '1', title: '消息', icon: Icons.message, count: 5),
];
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: BadgeListWidget(),
),
),
);
expect(find.text('消息'), findsOneWidget);
expect(find.text('5 条未读'), findsOneWidget);
});
}
OpenHarmony PC 端适配要点
- 屏幕宽度检测:根据不同屏幕宽度调整徽章大小和布局
- 响应式布局:在 PC 端使用网格布局充分利用屏幕空间
- 鼠标交互:使用 MouseRegion 提供悬停效果
- 键盘导航:支持方向键和 Enter 键操作
- 无障碍支持:为屏幕阅读器提供完整的语义标签
实际应用场景
- 消息应用:显示未读消息数
- 电商应用:显示购物车商品数、待处理订单数
- 任务管理:显示待完成任务数、优先级标记
- 社交应用:显示粉丝数、评论数、点赞数
- 系统通知:显示系统通知数、警告数
扩展建议
- 支持徽章动画和过渡
- 实现徽章的实时更新
- 添加徽章的分组和分类
- 支持徽章的自定义颜色和样式
- 实现徽章的数据持久化
总结
徽章是展示重要信息的有效方式。通过合理的设计和实现,可以创建出功能完整、用户体验良好的徽章系统。在 PC 端应用中,充分利用屏幕空间、提供键盘导航和无障碍支持是关键。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐




所有评论(0)