通过网盘分享的文件: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

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐