在这里插入图片描述

新闻卡片是首页的核心组件,负责展示新闻的图片、标题、摘要、来源和时间。虽然看起来简单,但要做好需要考虑很多细节——图片加载、文本处理、时间格式化、点击交互等。

新闻卡片展示内容

在动手写代码之前,先分析一下新闻卡片需要展示哪些信息:

新闻图片 - 吸引眼球的配图,支持网络加载、缓存、占位图、错误处理
新闻标题 - 简洁有力的标题,最多显示两行,超出部分用省略号
新闻摘要 - 简短的内容概述,同样最多两行
新闻来源 - 告诉用户这条新闻来自哪里,增加可信度
发布时间 - 显示相对时间(比如"2小时前"),更直观
点击交互 - 整个卡片可点击,有点击反馈

导入依赖

创建lib/widgets/news_card.dart

import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:intl/intl.dart';
import '../models/news_article.dart';
import '../screens/news_detail/news_detail_screen.dart';

代码解析

  • cached_network_image - 图片缓存库,自动缓存网络图片,提升加载速度
  • intl - 日期格式化库,用于时间格式化
  • news_article - 新闻数据模型
  • news_detail_screen - 详情页

为什么放在widgets目录:因为这是个可复用的组件,不仅首页要用,搜索页、收藏页也会用到。

定义组件结构

class NewsCard extends StatelessWidget {
  final NewsArticle article;

  const NewsCard({super.key, required this.article});

代码解析

  • 使用StatelessWidget,只负责展示数据,不需要管理状态
  • article参数必需,包含新闻的所有信息
  • required关键字明确表达这个参数是必需的

为什么用StatelessWidget:卡片只是展示数据,数据由外部传入,组件本身不需要维护状态。用StatelessWidget性能更好,因为Flutter可以更好地优化它。

构建卡片整体结构

  
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.only(bottom: 16),
      child: InkWell(
        onTap: () {
          Navigator.push(
            context,
            MaterialPageRoute(
              builder: (_) => NewsDetailScreen(article: article),
            ),
          );
        },
        borderRadius: BorderRadius.circular(12),

代码解析

  • Card - Flutter提供的卡片组件,自带阴影、圆角、背景色等Material Design效果
  • InkWell - 提供水波纹点击效果,比GestureDetector体验更好
  • borderRadius - 圆角要和Card一致(12),否则水波纹会超出边界
  • 点击跳转到详情页,传递article数据

InkWell vs GestureDetector:两者都能处理点击事件,但InkWell有水波纹效果,更符合Material Design规范。点击时会有一圈圈扩散的动画,用户体验更好。

构建卡片内容

        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            if (article.imageUrl != null) _buildImage(),
            Padding(
              padding: const EdgeInsets.all(12),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  _buildTitle(),
                  const SizedBox(height: 8),
                  _buildSummary(),
                  const SizedBox(height: 12),
                  _buildMetadata(context),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }

代码解析

  • crossAxisAlignment.start - 所有子Widget左对齐,如果不设置,默认是居中
  • if (article.imageUrl != null) - 条件渲染,有图片才显示,这是Dart 2.3引入的collection if语法
  • 图片占满宽度,文字部分四周padding 12
  • SizedBox控制间距,8和12是经过调整的最佳值

实现图片加载

  Widget _buildImage() {
    return ClipRRect(
      borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
      child: AspectRatio(
        aspectRatio: 16 / 9,
        child: article.imageUrl != null
            ? CachedNetworkImage(
                imageUrl: article.imageUrl!,
                fit: BoxFit.cover,
                placeholder: (context, url) => Container(
                  color: Colors.grey[300],
                  child: const Center(child: CircularProgressIndicator()),
                ),
                errorWidget: (context, url, error) => _buildPlaceholder(),
              )
            : _buildPlaceholder(),
      ),
    );
  }

代码解析

  • ClipRRect - 裁剪子Widget,实现圆角,Image组件本身不支持borderRadius
  • BorderRadius.vertical(top:) - 只设置顶部圆角,因为图片在卡片顶部
  • AspectRatio(16/9) - 固定宽高比,保证所有卡片图片高度一致,列表看起来更整齐
  • CachedNetworkImage - 自动缓存图片,第二次加载直接从缓存读取,速度快很多
  • BoxFit.cover - 填满区域,超出部分裁剪,不变形
  • placeholder - 加载中显示灰色背景和转圈动画
  • errorWidget - 加载失败显示占位图

为什么固定宽高比:如果不固定,图片按原始尺寸显示,每个卡片高度不一样,列表很乱。固定16:9后,所有图片高度一致,而且16:9是视频和图片的标准比例。

创建占位图

  Widget _buildPlaceholder() {
    return Container(
      color: Colors.grey[200],
      child: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(
              Icons.article_outlined,
              size: 48,
              color: Colors.grey[400],
            ),
            const SizedBox(height: 8),
            Text(
              '新闻图片',
              style: TextStyle(
                fontSize: 14,
                color: Colors.grey[500],
              ),
            ),
          ],
        ),
      ),
    );
  }

代码解析

  • 浅灰色背景,中间显示文章图标和文字
  • 用灰色系表示这是占位内容,不是真实图片
  • 图标大小48,在16:9区域显示刚好

为什么要有占位图:如果图片加载失败就显示空白,用户会以为卡片坏了。有个占位图,用户知道这里本来应该有图片,只是加载失败了。这是个很重要的用户体验细节。

实现标题和摘要

  Widget _buildTitle() {
    return Text(
      article.title,
      style: const TextStyle(
        fontSize: 16,
        fontWeight: FontWeight.bold,
        height: 1.3,
      ),
      maxLines: 2,
      overflow: TextOverflow.ellipsis,
    );
  }

  Widget _buildSummary() {
    return Text(
      article.summary,
      style: TextStyle(
        fontSize: 14,
        color: Colors.grey[600],
        height: 1.4,
      ),
      maxLines: 2,
      overflow: TextOverflow.ellipsis,
    );
  }

代码解析

  • 标题字号16,加粗显示,让标题更醒目
  • 摘要字号14,灰色文字,形成层次感
  • height - 行高,标题1.3,摘要1.4,多行文字不会挤在一起
  • maxLines: 2 - 最多显示两行,一行太少,三行太多
  • overflow: TextOverflow.ellipsis - 超出部分显示省略号

为什么是两行:一行太少,很多标题显示不完;三行太多,占用空间大。两行是个平衡点,既能显示足够信息,又不会太占空间。

实现元数据显示

  Widget _buildMetadata(BuildContext context) {
    final publishedDate = DateTime.tryParse(article.publishedAt);
    final dateStr = publishedDate != null
        ? _formatDate(publishedDate)
        : '未知时间';

    return Row(
      children: [
        Container(
          padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
          decoration: BoxDecoration(
            color: Theme.of(context).colorScheme.primaryContainer,
            borderRadius: BorderRadius.circular(4),
          ),
          child: Text(
            article.source,
            style: TextStyle(
              fontSize: 12,
              color: Theme.of(context).colorScheme.onPrimaryContainer,
            ),
          ),
        ),
        const SizedBox(width: 8),
        Icon(Icons.access_time, size: 14, color: Colors.grey[600]),
        const SizedBox(width: 4),
        Text(
          dateStr,
          style: TextStyle(
            fontSize: 12,
            color: Colors.grey[600],
          ),
        ),
      ],
    );
  }

代码解析

  • DateTime.tryParse - 尝试解析日期,失败返回null而不抛异常,比DateTime.parse安全
  • 来源标签用Container包裹,加背景色和圆角,看起来像个标签
  • 使用主题颜色,自动适配深色模式
  • 时钟图标表示时间,通用设计语言,用户一看就懂
  • 字号12,元数据是次要信息,字体要小一些

实现时间格式化

  String _formatDate(DateTime date) {
    final now = DateTime.now();
    final difference = now.difference(date);

    if (difference.inMinutes < 60) {
      return '${difference.inMinutes}分钟前';
    } else if (difference.inHours < 24) {
      return '${difference.inHours}小时前';
    } else if (difference.inDays < 7) {
      return '${difference.inDays}天前';
    } else {
      return DateFormat('MM-dd').format(date);
    }
  }
}

代码解析

  • 计算当前时间和发布时间的差值
  • 1小时内显示分钟,24小时内显示小时,7天内显示天数
  • 超过7天显示具体日期(月-日)
  • 相对时间更直观,符合用户习惯

为什么这样划分:这是参考了微信、微博等应用的做法。1小时内的新闻很新鲜,要精确到分钟;超过一周的新闻不算新了,显示具体日期就行。

图片加载优化

使用cached_network_image

  • 自动缓存图片到本地
  • 第二次加载直接从缓存读取
  • 大大提升加载速度

设置固定宽高比

  • 用AspectRatio固定16:9比例
  • 避免图片加载完成后卡片高度突变
  • 保证列表整齐,滚动流畅

提供占位图

  • 加载中显示占位符
  • 加载失败显示占位图
  • 用户体验更好

文本处理技巧

限制行数 - 标题和摘要都限制2行,保证卡片高度一致
处理溢出 - 使用TextOverflow.ellipsis,超出部分显示省略号
设置行高 - 标题行高1.3,摘要行高1.4,提升可读性
字体大小层次 - 标题16号,摘要14号,元数据12号,信息主次分明

颜色和主题

使用主题颜色 - 来源标签使用primaryContaineronPrimaryContainer,自动适配深色模式

灰色层次 - 摘要、时间、占位图使用不同深度的灰色,形成视觉层次

对比度考虑 - 文字颜色要和背景有足够的对比度,否则看不清

交互反馈

InkWell水波纹 - 点击时有扩散动画,符合Material Design规范

borderRadius一致 - 水波纹圆角和Card一致,不会超出边界

导航动画 - 跳转详情页有滑动动画,MaterialPageRoute自带

组件设计原则

职责单一 - 新闻卡片只负责展示数据和处理点击,不负责数据加载

可复用 - 只要传入NewsArticle数据,就能正常显示,不依赖特定上下文

易于维护 - 拆分成多个方法,每个方法职责明确

注重细节 - 图片的圆角、文字的行高、颜色的深浅、间距的大小,这些细节决定了组件的质量


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

在这里你可以找到更多Flutter开发资源,与其他开发者交流经验,共同进步。

Logo

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

更多推荐