请添加图片描述

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

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

从状态管理说起

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

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

@override
State createState() => _HomeTabState();
}

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

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

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

这里我定义了四个状态变量,分别用来存储热门图书列表、每日数学趣闻、随机百科文章和加载状态。你可能注意到_randomArticle用的是可空类型,因为这个数据不是必须的,加载失败了也不影响页面展示。

为什么在initState里调用_loadData?

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

并行加载的小技巧

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

Future _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 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

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐