Flutter for OpenHarmony 教育百科实战:首页
首页设计实现解析 本文详细介绍了教育百科App首页的实现过程,主要包含以下关键点: 数据加载优化:采用并行请求三个接口的方式减少等待时间,并设置合理的默认值保证页面完整性 状态管理:使用StatefulWidget管理四种不同数据状态,注意处理Widget销毁时的异步回调 视觉设计: 使用SliverAppBar实现可折叠头部 为深浅色模式配置不同渐变背景 添加装饰性元素增强视觉层次 快速访问入口

首页是用户打开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
更多推荐



所有评论(0)