Flutter for OpenHarmony 微动漫App实战:角色列表实现
本文介绍了Flutter实现动漫角色列表页面的方法。通过ListTile组件构建信息丰富的角色卡片,包含头像、名字、日文名、角色类型和人气值等元素。重点讲解了异步数据加载处理(使用FutureBuilder)、图片容错显示(空状态和加载错误处理)以及ListTile的灵活布局技巧(leading、title、subtitle、trailing的合理运用)。文章还展示了如何设计美观实用的列表项,包括
通过网盘分享的文件:flutter1.zip
链接: https://pan.baidu.com/s/1jkLZ9mZXjNm0LgP6FTVRzw 提取码: 2t97
动漫角色是作品的灵魂,很多观众追番就是为了喜欢的角色。微动漫App的角色列表展示特定动漫的所有角色,包括头像、名字、日文名、角色类型和人气值。
这篇文章会实现角色列表页面,重点讲解 ListTile 组件的灵活运用、图片加载的容错处理,以及如何设计一个信息丰富但不杂乱的列表项。

角色列表的设计思路
角色列表从动漫详情页进入,展示该动漫的所有角色。每个角色的信息包括:
头像:角色的视觉标识,放在最左边。
名字:英文名或中文名,是主要信息。
日文名:原版名字,作为补充。
角色类型:主角、配角等,帮助用户了解角色重要性。
人气值:收藏数,反映角色受欢迎程度。
页面参数传递
import 'package:flutter/material.dart';
import '../services/api_service.dart';
import '../models/character.dart';
import '../widgets/shimmer_loading.dart';
class AnimeCharactersScreen extends StatefulWidget {
final int malId;
const AnimeCharactersScreen({super.key, required this.malId});
State<AnimeCharactersScreen> createState() => _AnimeCharactersScreenState();
}
和新闻页面一样,角色列表需要动漫的 malId 来获取对应的角色数据。
StatefulWidget 用于管理异步加载状态。
数据加载
class _AnimeCharactersScreenState extends State<AnimeCharactersScreen> {
late Future<List<Character>> _charactersFuture;
void initState() {
super.initState();
_charactersFuture = ApiService.getAnimeCharacters(widget.malId);
}
在 initState 里发起请求,把 Future 存到变量里。这样 FutureBuilder 只会请求一次,不会因为 rebuild 重复请求。
FutureBuilder 构建页面
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('角色')),
body: FutureBuilder<List<Character>>(
future: _charactersFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const ShimmerLoading(itemCount: 8, isGrid: false);
}
if (!snapshot.hasData || snapshot.data!.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.people, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'暂无角色信息',
style: TextStyle(color: Colors.grey[600], fontSize: 16),
),
],
),
);
}
final characters = snapshot.data!;
return ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: characters.length,
itemBuilder: (_, i) => _buildCharacterCard(characters[i]),
);
},
),
);
}
三种状态:加载中显示骨架屏,数据为空显示空状态,有数据显示列表。
空状态用 Icons.people 图标,和角色主题呼应。
角色卡片结构
Widget _buildCharacterCard(Character character) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: ListTile(
leading: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: 50,
height: 70,
child: _buildImage(character.imageUrl),
),
),
Card 包裹 ListTile,既有卡片的视觉效果,又有 ListTile 的便捷布局。
leading 放头像,用 ClipRRect 裁剪成圆角矩形。SizedBox 固定尺寸为 50x70,竖向的比例更适合人物头像。
ListTile 的布局
ListTile 是 Flutter 提供的列表项组件,内置了常用的布局:
ListTile(
leading: Widget, // 左侧图标或头像
title: Widget, // 主标题
subtitle: Widget, // 副标题
trailing: Widget, // 右侧内容
onTap: () {}, // 点击回调
)
这四个位置覆盖了大多数列表项的需求,不用自己写 Row 和 Column。
角色名字
title: Text(
character.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
名字是主要信息,用 title 属性。fontWeight.w600 半粗体,突出但不过分。
maxLines: 2 允许换行,有些角色名字比较长。TextOverflow.ellipsis 超出显示省略号。
副标题信息
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (character.nameKanji?.isNotEmpty ?? false)
Text(character.nameKanji!, style: const TextStyle(fontSize: 12)),
if (character.role?.isNotEmpty ?? false)
Text(character.role!, style: const TextStyle(fontSize: 12)),
],
),
subtitle 可以放多行信息,用 Column 垂直排列。
日文名和角色类型都是可选的,用 if 判断是否显示。?.isNotEmpty ?? false 是空安全的写法。
字号设为 12,比默认的小,作为辅助信息。
人气值显示
trailing: character.favorites != null
? Text('❤️ ${character.favorites}')
: null,
),
);
}
trailing 放在右侧,显示收藏数。用 ❤️ emoji 作为图标,简洁直观。
如果没有收藏数据,trailing 设为 null,不显示任何内容。
图片加载处理
Widget _buildImage(String? imageUrl) {
if (imageUrl == null || imageUrl.isEmpty) {
return Container(
color: Colors.grey[300],
child: const Icon(Icons.person),
);
}
return Image.network(
imageUrl,
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Container(color: Colors.grey[300]);
},
errorBuilder: (context, error, stackTrace) {
return Container(
color: Colors.grey[300],
child: const Icon(Icons.person),
);
},
);
}
图片加载要处理三种情况:
URL 为空:显示灰色背景 + 人物图标。
加载中:显示灰色占位。
加载失败:显示灰色背景 + 人物图标。
Icons.person 是人物轮廓图标,作为默认头像很合适。
图片加载的细节
Image.network(
imageUrl,
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Container(color: Colors.grey[300]);
},
errorBuilder: (context, error, stackTrace) {
return Container(
color: Colors.grey[300],
child: const Icon(Icons.person),
);
},
)
BoxFit.cover 让图片填满容器,可能会裁剪边缘,但不会变形。
loadingBuilder 的 loadingProgress 参数:null 表示加载完成,非 null 表示加载中。
errorBuilder 在图片加载失败时调用,比如网络错误或图片不存在。
添加点击事件
可以给角色卡片加点击事件,跳转到角色详情:
Widget _buildCharacterCard(Character character) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: ListTile(
onTap: () {
// 跳转到角色详情页
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => CharacterDetailScreen(
characterId: character.malId,
),
),
);
},
leading: // 头像
title: // 名字
subtitle: // 副标题
trailing: // 人气值
),
);
}
onTap 处理点击事件,跳转到角色详情页。ListTile 自带点击水波纹效果。
角色类型标签
可以用彩色标签显示角色类型:
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (character.nameKanji?.isNotEmpty ?? false)
Text(character.nameKanji!, style: const TextStyle(fontSize: 12)),
if (character.role?.isNotEmpty ?? false)
Container(
margin: const EdgeInsets.only(top: 4),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: _getRoleColor(character.role!),
borderRadius: BorderRadius.circular(10),
),
child: Text(
character.role!,
style: const TextStyle(fontSize: 10, color: Colors.white),
),
),
],
),
用 Container 做一个小标签,背景色根据角色类型变化。
Color _getRoleColor(String role) {
switch (role.toLowerCase()) {
case 'main':
return Colors.blue;
case 'supporting':
return Colors.green;
default:
return Colors.grey;
}
}
主角用蓝色,配角用绿色,其他用灰色。颜色区分让用户一眼就能识别角色重要性。
分组显示
可以按角色类型分组:
final mainCharacters = characters.where((c) => c.role == 'Main').toList();
final supportingCharacters = characters.where((c) => c.role == 'Supporting').toList();
return ListView(
padding: const EdgeInsets.all(8),
children: [
if (mainCharacters.isNotEmpty) ...[
const Padding(
padding: EdgeInsets.all(8),
child: Text(
'主要角色',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
...mainCharacters.map((c) => _buildCharacterCard(c)),
],
if (supportingCharacters.isNotEmpty) ...[
const Padding(
padding: EdgeInsets.all(8),
child: Text(
'配角',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
...supportingCharacters.map((c) => _buildCharacterCard(c)),
],
],
);
用 where 过滤不同类型的角色,分别显示在不同的分组下。
分组标题用粗体,和角色卡片区分开。
搜索功能
角色很多时可以加搜索:
class _AnimeCharactersScreenState extends State<AnimeCharactersScreen> {
List<Character> _characters = [];
List<Character> _filteredCharacters = [];
String _searchQuery = '';
bool _isLoading = true;
void _filterCharacters(String query) {
setState(() {
_searchQuery = query;
if (query.isEmpty) {
_filteredCharacters = _characters;
} else {
_filteredCharacters = _characters.where((c) {
final name = c.name.toLowerCase();
final kanji = c.nameKanji?.toLowerCase() ?? '';
return name.contains(query.toLowerCase()) ||
kanji.contains(query.toLowerCase());
}).toList();
}
});
}
搜索同时匹配英文名和日文名,用 toLowerCase 实现大小写不敏感。
AppBar(
title: TextField(
decoration: const InputDecoration(
hintText: '搜索角色...',
border: InputBorder.none,
),
onChanged: _filterCharacters,
),
)
把搜索框放在 AppBar 里,节省空间。
网格布局
角色列表也可以用网格展示:
return GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
childAspectRatio: 0.7,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: characters.length,
itemBuilder: (_, i) => _buildCharacterGridItem(characters[i]),
);
crossAxisCount: 3 每行 3 个。childAspectRatio: 0.7 设置宽高比。
Widget _buildCharacterGridItem(Character character) {
return Card(
child: Column(
children: [
Expanded(
child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
child: _buildImage(character.imageUrl),
),
),
Padding(
padding: const EdgeInsets.all(8),
child: Text(
character.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 12),
),
),
],
),
);
}
网格项用 Column 布局,图片在上,名字在下。Expanded 让图片占满剩余空间。
切换布局
可以让用户选择列表或网格:
class _AnimeCharactersScreenState extends State<AnimeCharactersScreen> {
bool _isGridView = false;
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('角色'),
actions: [
IconButton(
icon: Icon(_isGridView ? Icons.list : Icons.grid_view),
onPressed: () {
setState(() => _isGridView = !_isGridView);
},
),
],
),
body: _isGridView
? GridView.builder(...)
: ListView.builder(...),
);
}
}
AppBar 右侧加个切换按钮,图标根据当前状态变化。
深色模式适配
图片占位色需要适配深色模式:
Widget _buildImage(String? imageUrl) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final placeholderColor = isDark ? Colors.grey[800] : Colors.grey[300];
final iconColor = isDark ? Colors.grey[600] : Colors.grey[400];
if (imageUrl == null || imageUrl.isEmpty) {
return Container(
color: placeholderColor,
child: Icon(Icons.person, color: iconColor),
);
}
return Image.network(
imageUrl,
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Container(color: placeholderColor);
},
errorBuilder: (context, error, stackTrace) {
return Container(
color: placeholderColor,
child: Icon(Icons.person, color: iconColor),
);
},
);
}
深色模式下用深灰色占位,图标也用深灰色,和背景协调。
小结
角色列表页面涉及的技术点:FutureBuilder 异步加载、ListView.builder 列表、ListTile 列表项、Card 卡片、ClipRRect 圆角裁剪、Image.network 网络图片、GridView 网格布局。
ListTile 是构建列表项的利器,leading、title、subtitle、trailing 四个位置覆盖了大多数需求。
图片加载要处理好空值、加载中、加载失败三种情况,用户体验才会好。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐

所有评论(0)