通过网盘分享的文件:flutter1.zip
链接: https://pan.baidu.com/s/1jkLZ9mZXjNm0LgP6FTVRzw 提取码: 2t97

排行榜是动漫App的核心功能之一,用户想知道"现在什么番最火"、“评分最高的是哪部”,第一反应就是去看排行榜。这个功能看起来简单,不就是一个列表嘛,但要做好还是有不少讲究的。

这篇文章会实现一个完整的排行榜页面,顺便把动漫卡片组件也讲一讲,因为排行榜和卡片是配套使用的。代码都是项目里实际跑着的,可以直接参考。


请添加图片描述

排行榜页面的需求分析

在写代码之前,先想想排行榜要展示什么:

核心数据:动漫封面、标题、评分、排名。这四个信息缺一不可,用户一眼就能看到这部番的"江湖地位"。

交互:点击某一项跳转到详情页。这是最基本的交互,不需要太花哨。

加载状态:数据从网络获取,需要显示加载中的状态。用骨架屏比转圈圈体验好。

想清楚这些,代码写起来就有方向了。


页面的基本结构

排行榜需要管理加载状态和数据列表,用 StatefulWidget

import 'package:flutter/material.dart';
import '../services/api_service.dart';
import '../models/anime.dart';
import '../widgets/anime_list_tile.dart';
import '../widgets/shimmer_loading.dart';

引入的依赖不多:ApiService 负责网络请求,Anime 是数据模型,AnimeListTile 是列表项组件,ShimmerLoading 是骨架屏。

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

  
  State<RankingScreen> createState() => _RankingScreenState();
}

标准的 StatefulWidget 定义,没什么特别的。


状态变量的设计

class _RankingScreenState extends State<RankingScreen> {
  List<Anime> _topAnime = [];
  bool _isLoading = true;
  int _currentPage = 1;

三个状态变量各有用途:

_topAnime 存储排行榜数据,初始是空列表。

_isLoading 控制是否显示加载状态,初始为 true,因为页面一打开就要加载数据。

_currentPage 记录当前页码,为后续的分页加载预留。虽然现在只加载第一页,但把这个变量留着,以后加分页功能就方便了。


初始化时加载数据


void initState() {
  super.initState();
  _loadRanking();
}

initState 里调用 _loadRanking,页面一创建就开始加载数据。这是最常见的做法,用户不需要手动触发。


数据加载逻辑

Future<void> _loadRanking() async {
  setState(() => _isLoading = true);
  try {
    final animes = await ApiService.getTopAnime(page: _currentPage);
    setState(() {
      _topAnime = animes;
      _isLoading = false;
    });
  } catch (e) {
    setState(() => _isLoading = false);
  }
}

这段代码做了几件事:

开始加载时,把 _isLoading 设为 true,触发界面显示骨架屏。

调用接口ApiService.getTopAnime 返回排行榜数据,传入页码参数。

加载成功,更新 _topAnime 并把 _isLoading 设为 false。

加载失败,只把 _isLoading 设为 false,不做其他处理。实际项目中可以加个错误提示,这里为了简洁省略了。


页面的 UI 构建


Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(title: const Text('排行榜')),
    body: _isLoading
        ? const ShimmerLoading(itemCount: 8, isGrid: false)
        : ListView.builder(
            padding: const EdgeInsets.all(8),
            itemCount: _topAnime.length,
            itemBuilder: (_, i) => AnimeListTile(anime: _topAnime[i]),
          ),
  );
}

用三元表达式根据 _isLoading 决定显示什么:

加载中:显示 8 个骨架屏项,isGrid: false 表示用列表形式而不是网格。

加载完成:用 ListView.builder 展示数据,每一项是一个 AnimeListTile

ListView.builder 是懒加载的,只渲染屏幕上可见的项,即使有几百条数据也不会卡。


换个思路:用 FutureBuilder

上面的写法是手动管理加载状态,还有另一种写法是用 FutureBuilder,来看看热门角色页面是怎么做的:

class _TopCharactersScreenState extends State<TopCharactersScreen> {
  late Future<List<Character>> _charactersFuture;
  int _currentPage = 1;

  
  void initState() {
    super.initState();
    _charactersFuture = ApiService.getTopCharacters(page: _currentPage);
  }

把 Future 存成状态变量,在 initState 里赋值。注意用 late 关键字,因为赋值发生在构造函数之后。


Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(title: const Text('热门角色')),
    body: FutureBuilder<List<Character>>(
      future: _charactersFuture,
      builder: (context, snapshot) {

FutureBuilder 会自动监听 Future 的状态变化,根据不同状态返回不同的 Widget。

        if (snapshot.connectionState == ConnectionState.waiting) {
          return const ShimmerLoading(itemCount: 8, isGrid: false);
        }

ConnectionState.waiting 表示 Future 还没完成,显示骨架屏。

        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),
                const Text('暂无角色'),
              ],
            ),
          );
        }

数据为空时显示空状态提示,一个大图标加一行文字。

        final characters = snapshot.data!;
        return ListView.builder(
          padding: const EdgeInsets.all(8),
          itemCount: characters.length,
          itemBuilder: (_, i) => _buildCharacterCard(characters[i], i + 1),
        );
      },
    ),
  );
}

有数据时正常渲染列表。i + 1 是排名,因为索引从 0 开始,排名从 1 开始。

FutureBuilder vs 手动管理状态,哪个好?看情况。FutureBuilder 代码更简洁,但不太灵活,比如想实现下拉刷新就比较麻烦。手动管理状态代码多一点,但更灵活。


列表项带排名标签

热门角色的列表项有个排名标签,看看怎么实现的:

Widget _buildCharacterCard(Character character, int rank) {
  return Card(
    margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
    child: ListTile(
      leading: Stack(
        children: [
          ClipRRect(
            borderRadius: BorderRadius.circular(8),
            child: SizedBox(
              width: 50,
              height: 70,
              child: _buildImage(character.imageUrl),
            ),
          ),

Stack 可以让多个 Widget 叠加在一起。底层是角色头像,用 ClipRRect 加圆角。

          Positioned(
            top: 0,
            left: 0,
            child: Container(
              padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
              decoration: BoxDecoration(
                color: Theme.of(context).primaryColor,
                borderRadius: const BorderRadius.only(
                  bottomRight: Radius.circular(8),
                ),
              ),
              child: Text(
                '#$rank',
                style: const TextStyle(
                  color: Colors.white,
                  fontWeight: FontWeight.bold,
                  fontSize: 10,
                ),
              ),
            ),
          ),
        ],
      ),

Positioned 把排名标签定位到左上角。标签用主题色背景,只有右下角有圆角,形成一个"角标"的效果。


列表项的其他信息

      title: Text(
        character.name,
        maxLines: 2,
        overflow: TextOverflow.ellipsis,
        style: const TextStyle(fontWeight: FontWeight.w600),
      ),
      subtitle: character.nameKanji?.isNotEmpty ?? false
          ? Text(character.nameKanji!, style: const TextStyle(fontSize: 12))
          : null,

标题是角色名,最多两行,超出显示省略号。副标题是日文名,如果有的话才显示。

character.nameKanji?.isNotEmpty ?? false 这个写法处理了两种情况:nameKanji 为 null,或者 nameKanji 是空字符串。

      trailing: character.favorites != null
          ? Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                const Icon(Icons.favorite, color: Colors.red, size: 16),
                const SizedBox(height: 2),
                Text(
                  '${character.favorites}',
                  style: const TextStyle(fontSize: 11),
                ),
              ],
            )
          : null,
    ),
  );
}

右侧显示收藏数,一个红心图标加数字。如果没有收藏数据就不显示。


动漫卡片组件

排行榜用的是列表形式,但有些地方会用卡片形式展示动漫,比如首页的网格。来看看 AnimeCard 组件:

class AnimeCard extends StatelessWidget {
  final Anime anime;
  final bool showRank;

  const AnimeCard({super.key, required this.anime, this.showRank = false});

两个参数:anime 是必传的动漫数据,showRank 控制是否显示排名标签,默认不显示。


Widget build(BuildContext context) {
  return GestureDetector(
    onTap: () => Navigator.push(
      context,
      MaterialPageRoute(builder: (_) => AnimeDetailScreen(anime: anime)),
    ),

整个卡片可点击,点击后跳转到详情页。用 GestureDetector 包裹,比给每个子元素加 onTap 简洁。


卡片的容器样式

    child: Container(
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(12),
        boxShadow: [
          BoxShadow(
            color: Colors.black.withOpacity(0.1),
            blurRadius: 8,
            offset: const Offset(0, 4),
          ),
        ],
      ),

BoxDecoration 设置圆角和阴影。阴影用 10% 透明度的黑色,模糊半径 8,向下偏移 4 像素,看起来像是卡片浮在页面上。

      child: ClipRRect(
        borderRadius: BorderRadius.circular(12),
        child: Stack(
          fit: StackFit.expand,
          children: [
            _buildImage(),

ClipRRect 裁剪子元素,让图片也有圆角。Stack 叠加多个图层:底层是封面图,上层是信息遮罩和排名标签。


底部信息遮罩

            Positioned(
              bottom: 0,
              left: 0,
              right: 0,
              child: Container(
                decoration: BoxDecoration(
                  gradient: LinearGradient(
                    begin: Alignment.bottomCenter,
                    end: Alignment.topCenter,
                    colors: [
                      Colors.black.withOpacity(0.9),
                      Colors.transparent,
                    ],
                  ),
                ),

Positioned 把信息区域定位到底部。渐变遮罩从下往上,下面是 90% 透明度的黑色,上面是全透明。这样文字在任何颜色的封面上都能看清。

                padding: const EdgeInsets.all(8),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    Text(
                      anime.title,
                      maxLines: 2,
                      overflow: TextOverflow.ellipsis,
                      style: const TextStyle(
                        color: Colors.white,
                        fontWeight: FontWeight.bold,
                        fontSize: 12,
                      ),
                    ),

标题用白色粗体,最多两行。mainAxisSize: MainAxisSize.min 让 Column 高度自适应内容,不会撑满整个遮罩区域。


评分和集数信息

                    const SizedBox(height: 4),
                    Row(
                      children: [
                        if (anime.score != null) ...[
                          const Icon(Icons.star, color: Colors.amber, size: 14),
                          const SizedBox(width: 2),
                          Text(
                            anime.score!.toStringAsFixed(1),
                            style: const TextStyle(color: Colors.white, fontSize: 11),
                          ),
                        ],

评分用金色星星图标加数字,toStringAsFixed(1) 保留一位小数。

                        const Spacer(),
                        if (anime.episodes != null)
                          Text(
                            '${anime.episodes}集',
                            style: const TextStyle(color: Colors.white70, fontSize: 10),
                          ),
                      ],
                    ),
                  ],
                ),
              ),
            ),

Spacer 把评分和集数撑开,评分在左,集数在右。集数用 70% 透明度的白色,比标题淡一点,形成层次。


排名标签

            if (showRank && anime.rank != null)
              Positioned(
                top: 8,
                left: 8,
                child: Container(
                  padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
                  decoration: BoxDecoration(
                    color: Theme.of(context).primaryColor,
                    borderRadius: BorderRadius.circular(12),
                  ),
                  child: Text(
                    '#${anime.rank}',
                    style: const TextStyle(
                      color: Colors.white,
                      fontWeight: FontWeight.bold,
                      fontSize: 11,
                    ),
                  ),
                ),
              ),
          ],
        ),
      ),
    ),
  );
}

排名标签定位在左上角,只有 showRank 为 true 且有排名数据时才显示。用主题色背景,圆角 12,看起来像个小胶囊。


图片加载处理

Widget _buildImage() {
  final imageUrl = anime.imageUrl;
  if (imageUrl == null || imageUrl.isEmpty) {
    return Container(
      color: Colors.grey[300],
      child: const Center(child: Icon(Icons.movie, size: 40, color: Colors.grey)),
    );
  }

先检查 URL 是否有效,无效就显示占位图。这种防御性编程很重要,API 返回的数据不一定靠谱。

  return Image.network(
    imageUrl,
    fit: BoxFit.cover,
    loadingBuilder: (context, child, loadingProgress) {
      if (loadingProgress == null) return child;
      return Container(
        color: Colors.grey[300],
        child: Center(
          child: CircularProgressIndicator(
            value: loadingProgress.expectedTotalBytes != null
                ? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes!
                : null,
          ),
        ),
      );
    },

loadingBuilder 在图片加载过程中显示进度。loadingProgress.expectedTotalBytes 是总大小,cumulativeBytesLoaded 是已加载大小,两者相除就是进度百分比。

如果服务器没返回总大小,expectedTotalBytes 会是 null,这时候进度指示器显示为不确定状态(一直转圈)。

    errorBuilder: (context, error, stackTrace) {
      return Container(
        color: Colors.grey[300],
        child: const Center(child: Icon(Icons.broken_image, size: 40, color: Colors.grey)),
      );
    },
  );
}

errorBuilder 处理加载失败,显示一个破碎图片的图标。网络图片加载失败是常有的事,必须处理好,不然界面会很难看。


趋势页面的轮播图

趋势页面用了轮播图展示热门动漫,效果很炫:

CarouselSlider(
  options: CarouselOptions(
    height: 200,
    autoPlay: true,
    enlargeCenterPage: true,
    viewportFraction: 0.8,
  ),
  items: _trendingAnime.take(5).map((anime) {
    return AnimeCard(anime: anime, showRank: true);
  }).toList(),
),

CarouselSlider 是第三方轮播图组件,配置项很丰富:

height: 200 设置轮播图高度。

autoPlay: true 自动播放,不用用户手动滑动。

enlargeCenterPage: true 中间的卡片放大,两边的缩小,有层次感。

viewportFraction: 0.8 每个卡片占视口宽度的 80%,这样能看到两边卡片的一部分。

take(5) 只取前 5 个动漫做轮播,太多了用户也看不过来。


小结

排行榜功能涉及的知识点:列表数据加载和状态管理FutureBuilder 的使用Stack 实现图层叠加Positioned 定位元素渐变遮罩让文字更清晰图片加载的三种状态处理轮播图组件的使用

这些技术点在其他页面也会用到,掌握了排行榜的实现,首页、发现页、搜索结果页都能举一反三。

排行榜看起来简单,但细节很多。把每个细节都处理好,用户体验就上去了。


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

Logo

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

更多推荐