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

动漫新闻让用户了解喜欢的动漫的最新动态。微动漫App的新闻功能展示特定动漫的相关新闻,点击后跳转到原文链接。

这篇文章会实现动漫新闻页面,涉及 FutureBuilder 异步数据加载、卡片式新闻列表、图片加载处理、外部链接跳转等技术点。


请添加图片描述

新闻页面的设计思路

新闻页面是从动漫详情页进入的,展示该动漫的相关新闻。每条新闻包含标题、描述、图片、日期,点击后打开原文。

列表形式:新闻数量不定,用 ListView 展示最合适。

卡片设计:每条新闻一张卡片,图片在上,文字在下,层次分明。

外部跳转:新闻原文在外部网站,点击后用浏览器打开。


页面参数传递

新闻页面需要知道是哪个动漫的新闻:

import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/api_service.dart';
import '../models/character.dart';
import '../widgets/shimmer_loading.dart';

class AnimeNewsScreen extends StatefulWidget {
  final int malId;

  const AnimeNewsScreen({super.key, required this.malId});

  
  State<AnimeNewsScreen> createState() => _AnimeNewsScreenState();
}

malId 是动漫的唯一标识,通过构造函数传入。required 关键字表示这个参数必须提供。

从详情页跳转时传入:

Navigator.push(
  context,
  MaterialPageRoute(
    builder: (context) => AnimeNewsScreen(malId: anime.malId),
  ),
);

FutureBuilder 加载数据

class _AnimeNewsScreenState extends State<AnimeNewsScreen> {
  late Future<List<AnimeNews>> _newsFuture;

  
  void initState() {
    super.initState();
    _newsFuture = ApiService.getAnimeNews(widget.malId);
  }

late 关键字表示变量会在使用前初始化。在 initState 里调用 API 获取新闻数据。

widget.malId 访问 StatefulWidget 的属性,这是 State 类访问 Widget 参数的方式。


FutureBuilder 的使用


Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(title: const Text('新闻')),
    body: FutureBuilder<List<AnimeNews>>(
      future: _newsFuture,
      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.newspaper, size: 64, color: Colors.grey[400]),
                const SizedBox(height: 16),
                Text(
                  '暂无新闻',
                  style: TextStyle(color: Colors.grey[600], fontSize: 16),
                ),
              ],
            ),
          );
        }

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

FutureBuilder 根据 Future 的状态自动重建 UI。snapshot.connectionState 表示当前状态:waiting 是加载中,done 是完成。

snapshot.hasData 检查是否有数据,snapshot.data 获取数据。注意要处理数据为空的情况。


加载状态处理

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

加载中显示骨架屏,8 个占位项,列表形式。骨架屏比转圈圈更友好,用户能预期内容的布局。


空状态处理

if (!snapshot.hasData || snapshot.data!.isEmpty) {
  return Center(
    child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Icon(Icons.newspaper, size: 64, color: Colors.grey[400]),
        const SizedBox(height: 16),
        Text(
          '暂无新闻',
          style: TextStyle(color: Colors.grey[600], fontSize: 16),
        ),
      ],
    ),
  );
}

没有新闻时显示空状态。Icons.newspaper 是报纸图标,和新闻主题呼应。

空状态要居中显示,图标 + 文字的组合比单独的文字更友好。


新闻列表

final news = snapshot.data!;
return ListView.builder(
  padding: const EdgeInsets.all(8),
  itemCount: news.length,
  itemBuilder: (_, i) => _buildNewsCard(news[i]),
);

ListView.builder 懒加载列表,只构建可见的项。padding 设置列表的内边距。

itemBuilder 的第一个参数是 context,这里用 _ 表示不使用。


新闻卡片结构

Widget _buildNewsCard(AnimeNews news) {
  return Card(
    margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
    child: InkWell(
      onTap: () async {
        if (news.link != null) {
          try {
            await launchUrl(Uri.parse(news.link!));
          } catch (e) {
            print('❌ Error launching URL: $e');
          }
        }
      },

Card 提供卡片样式,自带阴影和圆角。margin 设置卡片之间的间距。

InkWell 让卡片可点击,点击时有水波纹效果。onTap 里调用 launchUrl 打开外部链接。


外部链接跳转

onTap: () async {
  if (news.link != null) {
    try {
      await launchUrl(Uri.parse(news.link!));
    } catch (e) {
      print('❌ Error launching URL: $e');
    }
  }
},

先检查链接是否存在,然后用 launchUrl 打开。Uri.parse 把字符串转成 Uri 对象。

try-catch 捕获可能的异常,比如链接格式错误或设备无法打开链接。


卡片内容布局

      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          if (news.imageUrl?.isNotEmpty ?? false)
            ClipRRect(
              borderRadius: const BorderRadius.only(
                topLeft: Radius.circular(12),
                topRight: Radius.circular(12),
              ),
              child: _buildImage(news.imageUrl!),
            ),

Column 垂直排列图片和文字。crossAxisAlignment.start 让内容左对齐。

图片用 ClipRRect 裁剪,只有顶部两个角是圆角,和 Card 的圆角对齐。

news.imageUrl?.isNotEmpty ?? false 是空安全的写法:如果 imageUrl 为 null 或空字符串,就不显示图片。


图片加载组件

Widget _buildImage(String imageUrl) {
  return SizedBox(
    height: 150,
    width: double.infinity,
    child: 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]);
      },
    ),
  );
}

SizedBox 固定图片高度为 150,宽度撑满。BoxFit.cover 让图片填满容器,可能会裁剪。

loadingBuilder 处理加载中状态,显示灰色占位。loadingProgress == null 表示加载完成。

errorBuilder 处理加载失败,也显示灰色占位,不会显示破图标。


新闻标题

          Padding(
            padding: const EdgeInsets.all(12),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  news.title,
                  maxLines: 2,
                  overflow: TextOverflow.ellipsis,
                  style: const TextStyle(
                    fontWeight: FontWeight.bold,
                    fontSize: 14,
                  ),
                ),

标题用粗体,最多显示 2 行。TextOverflow.ellipsis 超出部分显示省略号。

Padding 给文字区域加内边距,和图片区分开。


新闻描述

                if (news.description?.isNotEmpty ?? false) ...[
                  const SizedBox(height: 8),
                  Text(
                    news.description!,
                    maxLines: 2,
                    overflow: TextOverflow.ellipsis,
                    style: TextStyle(color: Colors.grey[600], fontSize: 12),
                  ),
                ],

描述是可选的,用 if 判断是否显示。…[] 是展开操作符,把列表里的元素展开到外层列表。

描述用灰色小字,和标题形成层次。同样限制 2 行,保持卡片高度一致。


新闻日期

                if (news.date?.isNotEmpty ?? false) ...[
                  const SizedBox(height: 8),
                  Text(
                    news.date!,
                    style: TextStyle(color: Colors.grey[500], fontSize: 11),
                  ),
                ],
              ],
            ),
          ),
        ],
      ),
    ),
  );
}

日期也是可选的,用更浅的灰色和更小的字号,作为辅助信息。


下拉刷新

可以加上下拉刷新功能:

body: FutureBuilder<List<AnimeNews>>(
  future: _newsFuture,
  builder: (context, snapshot) {
    // 加载和空状态处理
    
    final news = snapshot.data!;
    return RefreshIndicator(
      onRefresh: () async {
        setState(() {
          _newsFuture = ApiService.getAnimeNews(widget.malId);
        });
      },
      child: ListView.builder(
        padding: const EdgeInsets.all(8),
        itemCount: news.length,
        itemBuilder: (_, i) => _buildNewsCard(news[i]),
      ),
    );
  },
),

RefreshIndicator 包裹 ListView,下拉时显示刷新指示器。onRefresh 里重新请求数据。

注意要用 setState 更新 _newsFuture,这样 FutureBuilder 才会重新构建。


错误处理增强

FutureBuilder 还可以处理错误状态:

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

  if (snapshot.hasError) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(Icons.error_outline, size: 64, color: Colors.red[300]),
          const SizedBox(height: 16),
          Text(
            '加载失败',
            style: TextStyle(color: Colors.grey[600], fontSize: 16),
          ),
          const SizedBox(height: 16),
          ElevatedButton(
            onPressed: () {
              setState(() {
                _newsFuture = ApiService.getAnimeNews(widget.malId);
              });
            },
            child: const Text('重试'),
          ),
        ],
      ),
    );
  }

  // 正常数据处理
}

snapshot.hasError 检查是否有错误。错误状态显示错误图标和重试按钮。


新闻卡片动画

给卡片加入场动画:

return ListView.builder(
  padding: const EdgeInsets.all(8),
  itemCount: news.length,
  itemBuilder: (_, i) {
    return TweenAnimationBuilder<double>(
      tween: Tween(begin: 0, end: 1),
      duration: Duration(milliseconds: 300 + i * 50),
      builder: (context, value, child) {
        return Opacity(
          opacity: value,
          child: Transform.translate(
            offset: Offset(0, 20 * (1 - value)),
            child: child,
          ),
        );
      },
      child: _buildNewsCard(news[i]),
    );
  },
);

TweenAnimationBuilder 创建补间动画。每个卡片的动画延迟不同(i * 50),形成依次入场的效果。

Opacity 控制透明度,Transform.translate 控制位移,组合起来就是从下往上淡入的效果。


分享新闻

长按卡片可以分享:

InkWell(
  onTap: () async {
    // 打开链接
  },
  onLongPress: () {
    showModalBottomSheet(
      context: context,
      builder: (context) => Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          ListTile(
            leading: const Icon(Icons.share),
            title: const Text('分享'),
            onTap: () {
              Navigator.pop(context);
              // 调用分享功能
            },
          ),
          ListTile(
            leading: const Icon(Icons.copy),
            title: const Text('复制链接'),
            onTap: () {
              Navigator.pop(context);
              Clipboard.setData(ClipboardData(text: news.link ?? ''));
              ScaffoldMessenger.of(context).showSnackBar(
                const SnackBar(content: Text('链接已复制')),
              );
            },
          ),
        ],
      ),
    );
  },
  // 卡片内容
)

onLongPress 处理长按事件,弹出底部菜单。showModalBottomSheet 显示底部弹窗。

菜单里提供分享和复制链接两个选项,满足不同需求。


深色模式适配

卡片在深色模式下自动适配,但图片占位色可以调整:

Widget _buildImage(String imageUrl) {
  final isDark = Theme.of(context).brightness == Brightness.dark;
  final placeholderColor = isDark ? Colors.grey[800] : Colors.grey[300];
  
  return SizedBox(
    height: 150,
    width: double.infinity,
    child: 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);
      },
    ),
  );
}

深色模式下用深灰色占位,浅色模式下用浅灰色,和背景协调。


小结

动漫新闻页面涉及的技术点:FutureBuilder 异步加载ListView.builder 列表Card 卡片组件InkWell 点击效果url_launcher 外部链接Image.network 网络图片ClipRRect 圆角裁剪

页面结构清晰:参数传递 → 数据加载 → 状态处理 → 列表渲染 → 卡片构建。

新闻卡片的设计要点:图片在上吸引眼球,标题粗体突出重点,描述和日期作为补充信息。点击跳转外部链接,长按可以分享或复制。


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

Logo

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

更多推荐