首页是用户打开App后看到的第一个界面,它的设计直接影响用户对整个应用的第一印象。说实话,做首页的时候我纠结了挺久,因为教育百科需要展示的内容实在太多了——热门图书、每日趣闻、快速入口、随机百科……怎么把这些东西塞进一个页面还不显得乱,确实需要花点心思。

最后我选择了CustomScrollView配合SliverAppBar的方案,滚动时头部会自动折叠,既节省空间又有不错的视觉效果。下面就来聊聊具体怎么实现的。


请添加图片描述

从状态管理说起

首页需要展示好几种不同的数据,所以状态变量定义得比较多:

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

  
  State<HomeTab> createState() => _HomeTabState();
}

这是标准的StatefulWidget写法,没什么特别的。关键在下面的State类里。

class _HomeTabState extends State<HomeTab> {
  List<dynamic> _trendingBooks = [];
  String _dailyFact = '';
  Map<String, dynamic>? _randomArticle;
  bool _isLoading = true;

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

这里定义了四个状态变量:

  • _trendingBooks 存储热门图书列表
  • _dailyFact 存储每日数学趣闻
  • _randomArticle 存储随机百科文章(可空类型,加载失败也不影响页面展示)
  • _isLoading 控制加载状态

为什么在initState里调用_loadData? 这是Flutter的标准做法。initState在Widget第一次插入到树中时调用,而且只调用一次,非常适合做数据初始化。如果放在build方法里,每次重建都会触发,那就乱套了。


并行加载的小技巧

首页要同时请求三个接口,如果一个一个串行请求,用户等待时间会很长。所以我用了并行请求的方式:

Future<void> _loadData() async {
  setState(() => _isLoading = true);
  try {
    final booksResult = ApiService.getTrendingBooks();
    final mathFactResult = ApiService.getRandomMathFact();
    final wikiResult = ApiService.getWikipediaRandom();

注意看,这三行代码故意没有加await。这样三个请求会同时发出去,而不是等第一个完成再发第二个。

    final books = await booksResult.catchError((e) => <dynamic>[]);
    final mathFact = await mathFactResult.catchError((e) => '数学是宇宙的语言,每一个数字都有它独特的故事。');
    final wiki = await wikiResult.catchError((e) => <dynamic>[]);

在这里统一等待结果。每个请求都套了catchError,这样即使某个接口挂了,也不会影响其他数据的展示。比如图书接口挂了,趣闻和百科还是能正常显示。

关于默认值的处理: 你可能好奇为什么mathFact的默认值是一句话而不是空字符串。这是因为每日趣闻卡片是首页的重要组成部分,如果显示空白会很难看。给一个兜底的文案,至少页面不会显得残缺。

    if (mounted) {
      setState(() {
        _trendingBooks = books;
        _dailyFact = mathFact.isEmpty ? '数学是宇宙的语言,每一个数字都有它独特的故事。' : mathFact;
        _randomArticle = wiki.isNotEmpty ? wiki[0] : null;
        _isLoading = false;
      });
    }

mounted检查很重要。想象一下,用户进入首页后立刻切换到其他Tab,这时候网络请求还没完成。等请求完成时,HomeTab可能已经被销毁了,如果这时候调用setState就会报错。mounted就是用来检查Widget是否还"活着"的。


可折叠头部的实现

SliverAppBar是Flutter里做可折叠头部的利器,配置项挺多的,我来一个个解释:

Widget _buildAppBar(BuildContext context, bool isDark) {
  return SliverAppBar(
    expandedHeight: 180,
    floating: false,
    pinned: true,
    stretch: true,

参数说明:

  • expandedHeight: 180 — 头部完全展开时的高度,180是试了好几个值之后觉得最合适的
  • floating: false — 设为true的话,向下滚动一点点头部就会立刻出现,体验不太好
  • pinned: true — 这个很关键,设为true后头部收起时标题栏会固定在顶部,而不是完全消失
  • stretch: true — 允许过度滚动时头部拉伸,有种弹性的感觉
    flexibleSpace: FlexibleSpaceBar(
      title: const Text(
        '教育百科',
        style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20),
      ),
      centerTitle: false,
      titlePadding: const EdgeInsets.only(left: 20, bottom: 16),

FlexibleSpaceBar负责头部的内容。标题放在左下角而不是居中,这样看起来更有设计感。


渐变背景的配色

背景用了渐变色,而且深色模式和浅色模式用了不同的配色:

      background: Container(
        decoration: BoxDecoration(
          gradient: LinearGradient(
            begin: Alignment.topLeft,
            end: Alignment.bottomRight,
            colors: isDark
                ? [const Color(0xFF1a237e), const Color(0xFF4a148c)]
                : [const Color(0xFF667eea), const Color(0xFF764ba2)],
          ),
        ),

浅色模式用的是偏紫色的渐变,看起来比较活泼。深色模式用的是深蓝到深紫的渐变,不会太刺眼。


头部装饰元素

光有渐变背景还不够,我还加了一些装饰元素让头部更有层次感:

        child: Stack(
          children: [
            Positioned(
              right: -30,
              top: 20,
              child: Icon(Icons.school, size: 150, color: Colors.white.withOpacity(0.1)),
            ),

右上角放了一个超大的学校图标,透明度只有10%,若隐若现的感觉。故意让它超出边界一部分(right: -30),这样看起来更自然。

            Positioned(
              left: 20,
              bottom: 60,
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    '探索知识的海洋',
                    style: TextStyle(color: Colors.white.withOpacity(0.9), fontSize: 14),
                  ),
                ],
              ),
            ),
          ],
        ),

左下角放了一句引导语,位置在标题上方,和标题形成呼应。透明度设为0.9而不是1,是为了让它看起来柔和一些。


快速访问入口的设计

快速访问区域是首页最重要的部分,用户通过这里可以快速进入各个功能模块:

Widget _buildQuickAccess(bool isDark) {
  final items = [
    {'icon': Icons.menu_book_rounded, 'label': '图书', 'gradient': [const Color(0xFF4facfe), const Color(0xFF00f2fe)], 'screen': const BookListScreen()},
    {'icon': Icons.public_rounded, 'label': '国家', 'gradient': [const Color(0xFF43e97b), const Color(0xFF38f9d7)], 'screen': const CountryListScreen()},
    {'icon': Icons.school_rounded, 'label': '大学', 'gradient': [const Color(0xFFfa709a), const Color(0xFFfee140)], 'screen': const UniversityListScreen()},
    {'icon': Icons.auto_stories_rounded, 'label': '百科', 'gradient': [const Color(0xFFa18cd1), const Color(0xFFfbc2eb)], 'screen': const WikipediaScreen()},
    {'icon': Icons.tag_rounded, 'label': '数字', 'gradient': [const Color(0xFFff9a9e), const Color(0xFFfecfef)], 'screen': const NumbersScreen()},
  ];

用Map来组织每个入口的数据,包括图标、标签、渐变色和目标页面。这样做的好处是后续遍历生成UI很方便,而且要加新入口只需要往数组里加一项就行。

每个入口的渐变色都不一样,这是故意的。蓝色系给图书,绿色系给国家,粉色系给大学……通过颜色区分功能,用户一眼就能找到想要的入口。

Widget _buildQuickAccessItem({
  required IconData icon,
  required String label,
  required List<Color> gradient,
  required VoidCallback onTap,
}) {
  return GestureDetector(
    onTap: onTap,
    child: Column(
      children: [
        Container(
          width: 56,
          height: 56,
          decoration: BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topLeft,
              end: Alignment.bottomRight,
              colors: gradient,
            ),
            borderRadius: BorderRadius.circular(16),
            boxShadow: [
              BoxShadow(
                color: gradient[0].withOpacity(0.4),
                blurRadius: 8,
                offset: const Offset(0, 4),
              ),
            ],
          ),
          child: Icon(icon, color: Colors.white, size: 26),
        ),

单个入口的尺寸定为56x56,这个大小在手机上点击起来比较舒服。这里有个小细节:阴影的颜色用的是渐变色的第一个颜色,这样阴影和按钮本身的颜色是协调的,看起来更自然。

        const SizedBox(height: 8),
        Text(label, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500)),
      ],
    ),
  );
}

图标下方是文字标签,字号12刚好,再大就显得拥挤了。


每日趣闻卡片

每日趣闻是我个人很喜欢的一个模块,每次打开App都能看到一条有趣的数学知识:

Widget _buildDailyFact(bool isDark) {
  return Padding(
    padding: const EdgeInsets.symmetric(horizontal: 16),
    child: GestureDetector(
      onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const NumbersScreen())),
      child: Container(
        padding: const EdgeInsets.all(20),
        decoration: BoxDecoration(
          gradient: LinearGradient(
            begin: Alignment.topLeft,
            end: Alignment.bottomRight,
            colors: isDark
                ? [const Color(0xFF434343), const Color(0xFF000000)]
                : [const Color(0xFF667eea), const Color(0xFF764ba2)],
          ),
          borderRadius: BorderRadius.circular(20),

整个卡片是可点击的,点击后跳转到数字趣闻页面。圆角设为20,比一般的卡片更大一些,让卡片看起来更圆润可爱。

          boxShadow: [
            BoxShadow(
              color: (isDark ? Colors.black : const Color(0xFF667eea)).withOpacity(0.3),
              blurRadius: 15,
              offset: const Offset(0, 8),
            ),
          ],
        ),

阴影的offset是(0, 8),意思是阴影往下偏移8像素。这样卡片看起来像是悬浮在页面上的,有立体感。


卡片内容的布局

        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                Container(
                  padding: const EdgeInsets.all(8),
                  decoration: BoxDecoration(
                    color: Colors.white.withOpacity(0.2),
                    borderRadius: BorderRadius.circular(10),
                  ),
                  child: const Icon(Icons.lightbulb_rounded, color: Colors.amber, size: 22),
                ),
                const SizedBox(width: 12),
                const Text(
                  '每日趣闻',
                  style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16),
                ),
                const Spacer(),
                Icon(Icons.arrow_forward_ios_rounded, color: Colors.white.withOpacity(0.7), size: 16),
              ],
            ),

灯泡图标用琥珀色(amber),在紫色背景上特别醒目。右侧的小箭头提示用户这个卡片可以点击。

            const SizedBox(height: 16),
            Text(
              _dailyFact,
              style: TextStyle(color: Colors.white.withOpacity(0.95), fontSize: 15, height: 1.5),
            ),
          ],
        ),

趣闻文字的行高设为1.5,这样多行文字读起来不会太挤。


热门图书模块

热门图书用横向滚动的列表展示,这种设计在很多App里都能看到:

Widget _buildTrendingBooks(bool isDark) {
  return Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Padding(
        padding: const EdgeInsets.symmetric(horizontal: 16),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            Row(
              children: [
                Container(
                  width: 4,
                  height: 20,
                  decoration: BoxDecoration(
                    color: Theme.of(context).colorScheme.primary,
                    borderRadius: BorderRadius.circular(2),
                  ),
                ),
                const SizedBox(width: 8),
                Text('热门图书', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
              ],
            ),

标题前面有一个小竖条,这是很常见的设计手法,用来强调这是一个独立的区块。


图书卡片的渐变封面

由于Open Library的封面图片在某些网络环境下加载很慢,我决定用渐变色来代替:

Widget _buildBookCard(dynamic book, bool isDark) {
  final title = book['title'] ?? '未知标题';
  final authors = (book['author_name'] as List?)?.join(', ') ?? '未知作者';
  
  final colorSets = [
    [const Color(0xFF667eea), const Color(0xFF764ba2)],
    [const Color(0xFF4facfe), const Color(0xFF00f2fe)],
    [const Color(0xFF43e97b), const Color(0xFF38f9d7)],
    [const Color(0xFFfa709a), const Color(0xFFfee140)],
  ];
  final colorIndex = title.hashCode.abs() % colorSets.length;

这里用了一个小技巧:根据书名的hashCode来选择颜色。这样同一本书每次显示的颜色都是一样的,不会每次刷新都变。

Container(
  height: 160,
  width: double.infinity,
  decoration: BoxDecoration(
    gradient: LinearGradient(
      begin: Alignment.topLeft,
      end: Alignment.bottomRight,
      colors: gradientColors,
    ),
  ),
  child: Stack(
    children: [
      Positioned(
        right: -20,
        bottom: -20,
        child: Icon(Icons.auto_stories, size: 100, color: Colors.white.withOpacity(0.15)),
      ),
      Center(
        child: Icon(Icons.menu_book_rounded, color: Colors.white.withOpacity(0.9), size: 40),
      ),
    ],
  ),
),

封面区域高度160,中间放一个图书图标,右下角放一个大的装饰图标。这种设计即使没有真实的封面图片也能保持美观。


写在最后

首页的设计花了我不少时间,主要是在平衡信息量和美观度。太多内容会显得杂乱,太少又显得空洞。最后的方案是:头部用大面积的渐变色吸引眼球,快速入口让用户能快速找到想要的功能,每日趣闻增加一点趣味性,热门图书展示核心内容。

渐变色的运用是这个首页的一大特点,几乎每个模块都用到了。但要注意的是,渐变色用多了容易显得花哨,所以我尽量让不同模块的渐变色有所区分,同时整体色调保持统一。

下一篇我们来看探索页的实现,那里会有更多的功能入口和分类展示。


本文是Flutter for OpenHarmony教育百科实战系列的第一篇,后续会持续更新更多内容。

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

Logo

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

更多推荐