瑞克和莫蒂是一部经典的科幻喜剧动画,拥有众多有趣的角色。这篇文章我们来实现一个完整的角色列表页面,包括网格布局、状态筛选、搜索功能、以及无限滚动加载。这个功能展示了如何构建一个功能丰富的内容浏览界面
请添加图片描述

页面的整体架构

RickMortyScreen是一个Tab页面,包含三个子页面:角色、地点、剧集。我们先看主页面的结构:

class RickMortyScreen extends StatefulWidget {
  const RickMortyScreen({super.key});

  
  State<RickMortyScreen> createState() => _RickMortyScreenState();
}

class _RickMortyScreenState extends State<RickMortyScreen> with SingleTickerProviderStateMixin {
  late TabController _tabController;

  
  void initState() {
    super.initState();
    _tabController = TabController(length: 3, vsync: this);
  }

  
  void dispose() {
    _tabController.dispose();
    super.dispose();
  }

使用SingleTickerProviderStateMixin来提供TabController所需的TickerProvider。

在dispose中释放TabController,这是重要的资源管理

角色列表页面

CharactersTab是一个有状态的Widget,需要管理多个状态:

class CharactersTab extends StatefulWidget {
  const CharactersTab({super.key});

  
  State<CharactersTab> createState() => _CharactersTabState();
}

class _CharactersTabState extends State<CharactersTab> with AutomaticKeepAliveClientMixin {
  final RickMortyApi _api = RickMortyApi();
  final ScrollController _scrollController = ScrollController();
  List<dynamic> _characters = [];
  bool _isLoading = true;
  bool _isLoadingMore = false;
  int _page = 1;
  int _totalPages = 1;
  String? _statusFilter;
  String? _searchQuery;

  
  bool get wantKeepAlive => true;

使用AutomaticKeepAliveClientMixin来保持Tab页面的状态。这样当用户切换Tab后再切回来时,页面不会重新加载。

_statusFilter用来存储用户选择的状态筛选(存活、死亡、未知)。

_searchQuery用来存储用户输入的搜索关键词。

初始化和滚动监听

initState中初始化数据和添加滚动监听:

  
  void initState() {
    super.initState();
    _loadCharacters();
    _scrollController.addListener(_onScroll);
  }

  
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  void _onScroll() {
    if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) {
      _loadMore();
    }
  }

当用户滚动到距离底部200像素时,自动加载更多角色。

数据加载

_loadCharacters方法加载初始数据:

  Future<void> _loadCharacters() async {
    setState(() {
      _isLoading = true;
      _page = 1;
    });
    try {
      final data = await _api.getCharacters(page: 1, status: _statusFilter, name: _searchQuery);
      setState(() {
        _characters = data['results'] ?? [];
        _totalPages = data['info']?['pages'] ?? 1;
        _isLoading = false;
      });
    } catch (e) {
      setState(() => _isLoading = false);
    }
  }

调用API时传入status和name参数,支持按状态和名称筛选。

从返回的data中提取results和pages信息。

_loadMore方法加载下一页:

  Future<void> _loadMore() async {
    if (_isLoadingMore || _page >= _totalPages) return;
    setState(() => _isLoadingMore = true);
    try {
      _page++;
      final data = await _api.getCharacters(page: _page, status: _statusFilter, name: _searchQuery);
      setState(() {
        _characters.addAll(data['results'] ?? []);
        _isLoadingMore = false;
      });
    } catch (e) {
      setState(() => _isLoadingMore = false);
    }
  }

先检查是否已经在加载或已经到达最后一页。

页码加1后调用API获取下一页数据。

状态颜色映射

_getStatusColor方法根据角色状态返回对应的颜色:

  Color _getStatusColor(String status) {
    switch (status.toLowerCase()) {
      case 'alive':
        return Colors.green;
      case 'dead':
        return Colors.red;
      default:
        return Colors.grey;
    }
  }

存活的角色用绿色,死亡的用红色,未知的用灰色。这样能直观地表达角色的状态

页面的UI结构

页面用Column包装,包含搜索框、筛选栏和角色网格:

  
  Widget build(BuildContext context) {
    super.build(context);
    return Column(
      children: [
        Padding(
          padding: const EdgeInsets.all(12),
          child: TextField(
            decoration: const InputDecoration(hintText: '搜索角色...', prefixIcon: Icon(Icons.search)),
            onSubmitted: (value) {
              _searchQuery = value.isEmpty ? null : value;
              _loadCharacters();
            },
          ),
        ),

搜索框用TextField实现。当用户提交搜索时,更新_searchQuery并重新加载数据。

状态筛选栏

筛选栏用水平的FilterChip列表实现:

        SizedBox(
          height: 40,
          child: ListView(
            scrollDirection: Axis.horizontal,
            padding: const EdgeInsets.symmetric(horizontal: 12),
            children: [
              _buildFilterChip('全部', null),
              _buildFilterChip('存活', 'alive'),
              _buildFilterChip('死亡', 'dead'),
              _buildFilterChip('未知', 'unknown'),
            ],
          ),
        ),

用水平的ListView展示四个筛选选项。

_buildFilterChip方法创建FilterChip:

  Widget _buildFilterChip(String label, String? status) {
    return Padding(
      padding: const EdgeInsets.only(right: 8),
      child: FilterChip(
        label: Text(label),
        selected: _statusFilter == status,
        onSelected: (selected) {
          setState(() => _statusFilter = status);
          _loadCharacters();
        },
      ),
    );
  }

当用户点击chip时,更新_statusFilter并重新加载数据。

角色网格

角色用GridView展示,每行两列:

        Expanded(
          child: _isLoading
              ? const LoadingWidget()
              : RefreshIndicator(
                  onRefresh: _loadCharacters,
                  child: GridView.builder(
                    controller: _scrollController,
                    padding: const EdgeInsets.all(12),
                    gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
                      crossAxisCount: 2,
                      childAspectRatio: 0.75,
                      crossAxisSpacing: 12,
                      mainAxisSpacing: 12,
                    ),
                    itemCount: _characters.length + (_isLoadingMore ? 1 : 0),
                    itemBuilder: (context, index) {
                      if (index >= _characters.length) {
                        return const Center(child: CircularProgressIndicator());
                      }
                      final character = _characters[index];

GridView用SliverGridDelegateWithFixedCrossAxisCount来定义网格布局。

crossAxisCount: 2表示每行两列。

childAspectRatio: 0.75表示卡片的宽高比为4:3。

角色卡片

每个角色用一个Card展示,包含头像和基本信息:

                      return Card(
                        clipBehavior: Clip.antiAlias,
                        child: InkWell(
                          onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => CharacterDetailScreen(character: character))),
                          child: Column(
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: [
                              Expanded(
                                flex: 3,
                                child: Stack(
                                  fit: StackFit.expand,
                                  children: [
                                    AppNetworkImage(imageUrl: character['image'] ?? '', fit: BoxFit.cover, borderRadius: BorderRadius.zero),
                                    Positioned(
                                      top: 8,
                                      right: 8,
                                      child: Container(
                                        width: 12,
                                        height: 12,
                                        decoration: BoxDecoration(
                                          color: _getStatusColor(character['status'] ?? ''),
                                          shape: BoxShape.circle,
                                          border: Border.all(color: Colors.white, width: 2),
                                        ),
                                      ),
                                    ),
                                  ],
                                ),
                              ),

卡片用Column布局,上面是头像,下面是角色信息。

头像用Stack实现,背景是角色图片,右上角有一个状态指示圆点

圆点的颜色根据角色状态变化,用Border.all添加白色边框使其更显眼。

角色信息部分:

                              Expanded(
                                flex: 1,
                                child: Padding(
                                  padding: const EdgeInsets.all(8),
                                  child: Column(
                                    crossAxisAlignment: CrossAxisAlignment.start,
                                    mainAxisAlignment: MainAxisAlignment.center,
                                    children: [
                                      Text(character['name'] ?? '', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12), maxLines: 1, overflow: TextOverflow.ellipsis),
                                      Text(character['species'] ?? '', style: TextStyle(fontSize: 10, color: Colors.grey[600]), maxLines: 1),
                                    ],
                                  ),
                                ),
                              ),

显示角色名称和物种。用maxLines: 1和overflow: TextOverflow.ellipsis来防止文字过长

总结

这篇文章我们实现了一个功能完整的角色列表页面。涉及到的知识点包括:

  • Tab页面管理 - 使用TabController和SingleTickerProviderStateMixin
  • 页面状态保持 - 使用AutomaticKeepAliveClientMixin保持Tab页面状态
  • 网格布局 - 使用GridView.builder实现高效的网格渲染
  • 多条件筛选 - 支持按状态和名称筛选
  • 无限滚动加载 - 通过ScrollController监听滚动事件
  • 视觉设计 - 使用颜色、图标、布局来表达信息

角色列表页面展示了如何构建一个功能丰富、交互流畅的内容浏览界面。通过合理的布局、清晰的视觉设计、以及完善的交互,我们能为用户提供一个高效的浏览体验


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

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

更多推荐