【开源鸿蒙跨平台开发先锋训练营】DAY8~DAY13 底部选项卡&首页功能实现
目录
[【开源鸿蒙跨平台开发先锋训练营】DAY8~DAY13 底部选项卡&首页功能实现](#【开源鸿蒙跨平台开发先锋训练营】DAY8~DAY13 底部选项卡&首页功能实现)
[摘 要](#摘 要)
[1 概述](#1 概述)
[1.1 开发背景](#1.1 开发背景)
[1.2 开发目标](#1.2 开发目标)
[1.3 核心技术栈](#1.3 核心技术栈)
[2 开发环境与准备](#2 开发环境与准备)
[2.1 环境配置](#2.1 环境配置)
[2.2 依赖安装(pubspec.yaml)](#2.2 依赖安装(pubspec.yaml))
[2.3 项目目录结构](#2.3 项目目录结构)
[3 底部选项卡开发](#3 底部选项卡开发)
[3.1 底部选项卡设计规范](#3.1 底部选项卡设计规范)
[3.2 底部导航组件实现](#3.2 底部导航组件实现)
[3.3 项目入口配置](#3.3 项目入口配置)
[4 首页功能实现](#4 首页功能实现)
[4.1 首页主页面实现](#4.1 首页主页面实现)
[4.2 首页子组件实现](#4.2 首页子组件实现)
[4.2.1 搜索栏](#4.2.1 搜索栏)
[4.2.2 轮播推荐区](#4.2.2 轮播推荐区)
[4.2.3 分类标签](#4.2.3 分类标签)
[4.2.4 美食卡片](#4.2.4 美食卡片)
[4.3 数据模型与接口实现](#4.3 数据模型与接口实现)
[4.3.1 美食模型](#4.3.1 美食模型)
[4.3.2 美食数据接口](#4.3.2 美食数据接口)
[5 各选项卡页面](#5 各选项卡页面)
[5.1 美食页面](#5.1 美食页面)
[5.2 动态页面](#5.2 动态页面)
[5.3 推荐页面](#5.3 推荐页面)
[5.4 我的页面](#5.4 我的页面)
[6 运行验证与代码提交](#6 运行验证与代码提交)
[6.1 开源鸿蒙终端运行验证](#6.1 开源鸿蒙终端运行验证)
[6.2 Git 代码提交规范(AtomGit)](#6.2 Git 代码提交规范(AtomGit))
[7 总结与拓展](#7 总结与拓展)
[7.1 任务总结](#7.1 任务总结)
[7.2 拓展方向](#7.2 拓展方向)
摘 要
本次开源鸿蒙跨平台开发先锋训练营DAY8~DAY13阶段,以Flutter为核心跨平台技术栈,基于开源鸿蒙设备适配要求,完成了美食类应用底部选项卡及首页核心页面的开发与优化。开发过程严格遵循底部选项卡开发规范,实现了首页、美食、动态、推荐、我的5个核心选项卡设计,完成首页选项卡默认/选中的视觉区分、页面平滑切换及状态保留优化,解决了页面重复加载问题;重点实现首页搜索栏、轮播推荐、分类标签、美食卡片列表的功能闭环,接入下拉刷新、上拉分页加载交互,同时完善各选项卡页面的初识布局搭建,为后续开发搭好基础。最终工程代码按Git提交规范拆分粒度、标注清晰的commit message,推送至AtomGit本人个人公开仓库,实现代码可直接拉取复现。本次开发丰富了应用的交互维度与服务能力,搭建了清晰的Flutter+开源鸿蒙项目模块化架构,实现了跨平台应用底部选项卡基础框架及首页核心功能的实现。
1 概述
1.1 开发背景
在开源鸿蒙(OpenHarmony)跨平台应用开发中,底部选项卡是核心交互组件,能清晰划分应用功能模块、提升用户操作效率;而首页作为应用入口,需整合核心服务(如推荐、搜索、分类导航),形成完整的用户体验闭环。本次DAY8~DAY13 任务,聚焦底部选项卡开发规范落地、首页功能完善及各选项卡页面实现,同时完成开源鸿蒙终端模拟器的运行验证,最终将代码规范提交至AtomGit仓库。
1.2 开发目标
✅ 1. 搭建5个底部选项卡(首页、美食、动态、推荐、我的),实现完整交互状态(默认/选中)与平滑切换,保留页面状态避免重复加载;
✅ 2. 完善首页核心功能(搜索栏、轮播推荐、分类标签、美食卡片列表),接入数据加载、下拉刷新、上拉加载等交互;
✅ 3. 实现各选项卡对应页面的完整布局、核心功能与异常处理(空数据、加载失败兜底);
✅ 4. 遵循Git规范提交代码至AtomGit个人公开仓库,确保工程可直接拉取复现。
1.3 核心技术栈
⚡ 1. 跨平台框架:Flutter(兼容OpenHarmony)
🔧 2. UI 组件:Flutter原生组件+鸿蒙兼容三方库(carousel_slider、pull_to_refresh、infinite_scroll_pagination等)
📊 3. 数据处理:dio(网络请求)、json_annotation(数据解析)、shared_preferences(本地存储)
💻 4. 开发工具:VS Code、DevEco Studio 6.0.0 Release
2 开发环境与准备
2.1 环境配置
🚀 1. Flutter环境:安装 Flutter 3.27.5版本,配置flutter-OHOS-1.0.1(鸿蒙适配分支)分支(兼容 OpenHarmony);
📱 2. 鸿蒙环境:安装DevEco Studio 6.0.0 Release,配置 OpenHarmony SDK(API Version 20(6.0.0.47)),创建鸿蒙模拟器;
📂 3. 项目初始化:基于现有flutter_harmonyos工程,调整目录结构,安装依赖。
2.2 依赖安装(pubspec.yaml)
在项目根目录pubspec.yaml中添加鸿蒙兼容的三方库,执行flutter pub get安装:
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
http: ^1.6.0 # 网络请求核心库(鸿蒙兼容)
cached_network_image: ^3.4.1 # 图片缓存(解决鸿蒙设备图片重复加载卡顿)
pull_to_refresh: ^2.0.0 # 下拉刷新(适配鸿蒙触控交互)
flutter_rating_bar: ^4.0.1 # 美食评分组件(鸿蒙UI适配)
dio: 5.0.0 # 网络请求封装
json_annotation: ^4.9.0 # 数据解析
infinite_scroll_pagination: 4.0.0 # 专注上拉分页(适配鸿蒙异步逻辑)
easy_refresh: ^3.4.0 # 轻量化刷新(适配鸿蒙触控交互)
carousel_slider: ^5.1.1 # 轮播图组件
shared_preferences: ^2.5.3 # 用于本地存储历史记录
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
json_serializable: ^6.9.5
build_runner: ^2.4.15
2.3 项目目录结构
lib/
├── main.dart # 项目入口(根页面为底部导航)
├── api/ # API接口层
│ └── food_api.dart # 美食相关接口
├── components/ # 全局公共组件
│ └── bottom_nav_bar.dart # 底部选项卡核心组件
├── core/ # 核心工具层
│ └── http/ # 网络请求封装
│ ├── api_config.dart # API配置(基础URL、请求头)
│ ├── api_response.dart # 响应数据模型
│ └── http_client.dart # 网络请求客户端封装
├── models/ # 数据模型层
│ ├── collect_model.dart # 收藏数据模型
│ ├── food_detail_model.dart # 美食详情数据模型
│ ├── food_model.dart # 美食基础数据模型
│ ├── food_model.g.dart # 美食模型生成文件(如json_serializable)
│ └── search_model.dart # 搜索相关数据模型
├── pages/ # 页面层(按功能模块划分)
│ ├── home/ # 首页(对应底部"首页"选项卡)
│ │ ├── components/ # 首页子组件
│ │ │ ├── banner_section.dart # 轮播推荐区
│ │ │ ├── category_tab.dart # 早餐/午餐等分类标签
│ │ │ ├── food_card.dart # 美食卡片(首页专用)
│ │ │ └── search_bar.dart # 顶部搜索栏
│ │ └── home_page.dart # 首页主页面
│ ├── food/ # 美食详情相关页面
│ │ ├── components/
│ │ │ └── food_card.dart # 美食卡片(复用组件)
│ │ └── food_detail_page.dart # 美食详情页
│ ├── food_note/ # 动态页面(对应底部"动态"选项卡)
│ │ └── food_note_page.dart # 动态主页面
│ ├── food_show/ # 美食展示页面(对应底部"美食"选项卡)
│ │ └── food_show_page.dart # 美食展示主页面
│ ├── eat_what/ # 推荐页面(对应底部"推荐"选项卡)
│ │ └── eat_what_page.dart # 推荐主页面
│ ├── mine/ # 我的页面(对应底部"我的"选项卡)
│ │ └── mine_page.dart # 我的主页面
│ └── search/ # 搜索相关页面
│ ├── components/
│ │ ├── hot_search.dart # 热门搜索组件
│ │ └── search_history.dart # 搜索历史组件
│ ├── search_page.dart # 搜索主页面
│ └── search_result_page.dart # 搜索结果页
├── utils/ # 工具类层
│ └── collect_util.dart # 收藏相关工具类
└── ohos/ # 鸿蒙平台适配代码(原生交互、平台配置)
3 底部选项卡开发
3.1 底部选项卡设计规范
✅ 1. 选项卡数量:5 个(首页、美食、动态、推荐、我的),覆盖核心服务场景;
✅ 2. 交互状态:默认状态(灰色图标 + 文字)、选中状态(蓝色高亮图标 + 文字),视觉区分明确;
✅ 3. 切换逻辑:平滑切换,保留各页面状态(如列表滚动位置、输入框内容),避免重复请求数据。
3.2 底部导航组件实现
底部导航组件实现(lib/components/bottom_nav_bar.dart)。
// components/ # 公共组件
// 底部导航栏:实现底部导航栏 bottom_nav_bar.dart
import 'package:flutter/material.dart';
// 1. 修复导入路径:去掉空格,用下划线,确保文件名匹配
import '../pages/home/home_page.dart';
import '../pages/food_show/food_show_page.dart';
import '../pages/food_note/food_note_page.dart';
import '../pages/eat_what/eat_what_page.dart';
import '../pages/mine/mine_page.dart';
// import 'package:flutter_harmonyos/models/food_model.dart';
class BottomNavBar extends StatefulWidget {
const BottomNavBar({super.key});
@override
State<BottomNavBar> createState() => _BottomNavBarState();
}
class _BottomNavBarState extends State<BottomNavBar> {
int _currentIndex = 0; // 默认选中首页
// 2. 去掉const,避免StatefulWidget构造函数不匹配
final List<Widget> _pages = [
const HomePage(), // 如果HomePage构造函数是const,加const;否则去掉
const FoodShowPage(), // 同理
const FoodNotePage(),
const EatWhatPage(),
const MinePage(),
];
// 3. 优化标签文字:标题+图标+样式全优化
final List<BottomNavigationBarItem> _navItems = const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: '首页'), // 🏠 房子图标
BottomNavigationBarItem(icon: Icon(Icons.food_bank), label: '美食'), // 🍽️ 食物图标
BottomNavigationBarItem(icon: Icon(Icons.note_alt), label: '动态'), // 📝 笔记图标 美食笔记
BottomNavigationBarItem(icon: Icon(Icons.restaurant), label: '推荐'), // 🍴 餐厅图标
BottomNavigationBarItem(icon: Icon(Icons.person), label: '我的'), // 👤 人物图标
];
void _onTabTapped(int index) {
setState(() => _currentIndex = index);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: _pages[_currentIndex],
bottomNavigationBar: BottomNavigationBar(
items: _navItems,
currentIndex: _currentIndex,
onTap: _onTabTapped,
type: BottomNavigationBarType.fixed,
selectedItemColor: Colors.blue,
unselectedItemColor: Colors.grey,
),
);
}
}
实现效果:

3.3 项目入口配置
项目入口配置(lib/main.dart)
将底部导航作为应用根页面,替换原有入口:
// 项目入口(重构)
import 'package:flutter/material.dart';
import 'components/bottom_nav_bar.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '本地美食清单',
theme: ThemeData(
primarySwatch: Colors.blue,
// visualDensity 是可选的适配优化,保留/删除都可以,不影响核心功能
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: const BottomNavBar(), // 底部导航作为根页面
debugShowCheckedModeBanner: false,
);
}
}
4 首页功能实现
首页作为应用核心入口,需实现搜索栏、轮播推荐、分类标签、美食卡片列表四大模块,同时接入数据加载、下拉刷新、上拉加载交互,匹配设计图效果。
4.1 首页主页面实现
首页主页面实现(lib/pages/home/home_page.dart)
核心逻辑:数据加载 + 布局整合
// pages/ # 页面目录
// pages/home/home_page.dart # 首页主页面
// 整合搜索栏、轮播、分类标签、美食卡片:
import 'package:flutter/material.dart';
import 'components/search_bar.dart' as custom;
import 'components/banner_section.dart';
import 'components/category_tab.dart';
import 'components/food_card.dart';
import '../../models/food_model.dart';
import 'package:flutter_harmonyos/pages/search/search_page.dart';
// 分页常量(可根据需求调整)
const int _pageSize = 4; // 每页加载数量
const int _maxPages = 3; // 最大页数(模拟无更多数据)
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
List<FoodModel> _foodList = [];
bool _isLoading = true; // 初始加载状态
// 新增:上拉加载相关变量
final ScrollController _scrollController = ScrollController();
int _currentPage = 1; // 当前页码
bool _isLoadingMore = false; // 是否正在加载更多
bool _hasMoreData = true; // 是否还有更多数据
// 统一的跳转搜索页方法
void _navigateToSearchPage() {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SearchPage()),
);
}
@override
void initState() {
super.initState();
_loadFoodData(_currentPage);
// 新增:监听滚动事件(上拉加载核心)
_scrollController.addListener(() {
// 当滚动到距离底部100px以内,且不在加载中、还有更多数据时,触发加载更多
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 100 &&
!_isLoadingMore &&
_hasMoreData) {
_loadMore();
}
});
}
@override
void dispose() {
// 新增:销毁滚动控制器,避免内存泄漏
_scrollController.dispose();
super.dispose();
}
// 修改:支持分页加载数据(传入页码)
Future<void> _loadFoodData(int page) async {
// 初始加载/刷新时显示加载状态,上拉加载时标记_isLoadingMore
if (page == 1) {
setState(() => _isLoading = true);
} else {
setState(() => _isLoadingMore = true);
}
// 模拟网络请求延迟
await Future.delayed(const Duration(seconds: 1));
// 模拟不同页码返回不同测试数据
final List<String> foodNames = [
'溜肉段', '白玉木耳炒西蓝花', '糖醋排骨', '一品红烧肉',
'鱼香肉丝', '宫保鸡丁', '麻婆豆腐', '水煮鱼',
'回锅肉', '东坡肉', '辣子鸡', '酸菜鱼'
];
final List<String> foodSources = [
'吃货品天下', '白玉木耳滑嫩脆弹,西蓝花清甜脆爽', '甜甜蜜蜜的糖醋排骨', '一碗直达灵魂的红烧肉,肥而不腻,入口即化',
'酸甜适口,超级下饭', '外酥里嫩,酸甜微辣', '麻辣鲜香,豆腐嫩滑', '麻辣过瘾,鱼肉鲜嫩',
'肥而不腻,酱香浓郁', '肥而不腻,入口即化', '麻辣鲜香,外酥里嫩', '酸辣开胃,鱼肉鲜嫩'
];
// 计算当前页的起始/结束索引
final startIndex = (page - 1) * _pageSize;
final endIndex = startIndex + _pageSize;
final newItems = <FoodModel>[];
for (int i = startIndex; i < endIndex && i < foodNames.length; i++) {
newItems.add(FoodModel(
name: foodNames[i],
image: 'assets/images/food${(i % 4) + 1}.jpg', // 复用4张图片
source: foodSources[i],
score: 4.5 + (i % 4) * 0.1,
));
}
setState(() {
if (page == 1) {
// 第一页/刷新:替换数据
_foodList = newItems;
_isLoading = false;
} else {
// 非第一页/上拉加载:追加数据
_foodList.addAll(newItems);
_isLoadingMore = false;
}
// 判断是否还有更多数据
_hasMoreData = page < _maxPages;
});
}
// 下拉刷新核心方法
Future<void> _onRefresh() async {
// 刷新时重置分页状态
setState(() {
_currentPage = 1;
_hasMoreData = true;
});
await _loadFoodData(1);
// 刷新完成提示
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('数据刷新成功!')),
);
}
}
// 新增:上拉加载更多方法
Future<void> _loadMore() async {
// 避免重复加载
if (_isLoadingMore || !_hasMoreData) return;
// 页码+1
_currentPage++;
// 加载对应页码数据
await _loadFoodData(_currentPage);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('本地美食清单'),
centerTitle: true,
actions: [
IconButton(
icon: const Icon(Icons.search),
onPressed: _navigateToSearchPage,
),
IconButton(icon: const Icon(Icons.mail), onPressed: () {}),
],
),
// 下拉刷新
body: RefreshIndicator(
onRefresh: _onRefresh,
color: Colors.blue,
// 绑定滚动控制器到可滚动组件
child: _isLoading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
controller: _scrollController, // 新增:绑定滚动控制器
padding: const EdgeInsets.all(16),
physics: const AlwaysScrollableScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
GestureDetector(
onTap: _navigateToSearchPage,
child: custom.SearchBar(),
),
const SizedBox(height: 16),
const BannerSection(),
const SizedBox(height: 16),
const CategoryTab(),
const SizedBox(height: 16),
GestureDetector(
onTap: _navigateToSearchPage,
child: Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(20),
),
child: const Row(
children: [
Icon(Icons.search, color: Colors.grey),
SizedBox(width: 8),
Text('搜索百万免费菜谱', style: TextStyle(color: Colors.grey)),
],
),
),
),
// 美食卡片网格
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 16,
childAspectRatio: 0.9,
),
itemCount: _foodList.length,
itemBuilder: (_, index) => FoodCard(food: _foodList[index]),
),
// 新增:上拉加载提示(加载中/无更多数据)
if (_isLoadingMore)
const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: CircularProgressIndicator()),
)
else if (!_hasMoreData)
const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: Text(
'已加载全部美食',
style: TextStyle(color: Colors.grey, fontSize: 14),
),
),
),
],
),
),
),
);
}
}
实现效果:

上拉加载功能实现:


下拉刷新功能实现:

4.2 首页子组件实现
4.2.1 搜索栏
搜索栏(lib/pages/home/components/search_bar.dart):
// components/ # 首页子组件
// lib/pages/home/components/search_bar.dart # 顶部搜索栏
import 'package:flutter/material.dart';
import 'package:flutter_harmonyos/pages/search/search_page.dart'; // 导入搜索页
class SearchBar extends StatelessWidget {
const SearchBar({super.key});
@override
Widget build(BuildContext context) {
return TextField(
// 禁止输入,仅点击跳转
readOnly: true,
// 点击时跳转到搜索页
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SearchPage()),
);
},
decoration: InputDecoration(
hintText: '搜索百万免费菜谱',
prefixIcon: const Icon(Icons.search, color: Colors.grey),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide.none,
),
filled: true,
fillColor: Colors.grey[100],
contentPadding: const EdgeInsets.symmetric(vertical: 10),
),
);
}
}
首页搜索栏区域:

搜索栏实现效果:

4.2.2 轮播推荐区
轮播推荐区(lib/pages/home/components/banner_section.dart):
// lib/pages/home/components/banner_section.dart # 推荐轮播区
import 'package:flutter/material.dart';
import 'package:carousel_slider/carousel_slider.dart';
class BannerSection extends StatefulWidget {
final Function(int)? onBannerTap;
const BannerSection({
super.key,
this.onBannerTap,
});
@override
State<BannerSection> createState() => _BannerSectionState();
}
class _BannerSectionState extends State<BannerSection> {
// 修复:改用 CarouselSliderController
final CarouselSliderController _carouselController = CarouselSliderController();
int _currentBannerIndex = 0;
final List<String> _bannerImages = const [
'assets/images/banner1.jpg',
'assets/images/banner2.jpg',
'assets/images/banner3.jpg',
];
@override
Widget build(BuildContext context) {
return Column(
children: [
CarouselSlider(
carouselController: _carouselController,
options: CarouselOptions(
height: 200, // 修复:aspectRatio 与 height 冲突,只保留 height
autoPlay: true,
autoPlayInterval: const Duration(seconds: 3),
autoPlayAnimationDuration: const Duration(milliseconds: 800),
autoPlayCurve: Curves.fastOutSlowIn,
enlargeCenterPage: true,
viewportFraction: 0.9,
// aspectRatio: 16/9,
onPageChanged: (index, reason) {
setState(() {
_currentBannerIndex = index;
});
},
),
items: _bannerImages.asMap().entries.map((entry) {
final index = entry.key;
final imagePath = entry.value;
return Builder(
builder: (BuildContext context) {
return GestureDetector(
onTap: () {
if (widget.onBannerTap != null) {
widget.onBannerTap!(index);
}
},
child: Container(
width: MediaQuery.of(context).size.width,
margin: const EdgeInsets.symmetric(horizontal: 5),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.asset(
imagePath,
fit: BoxFit.cover,
),
),
),
);
},
);
}).toList(),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: _bannerImages.asMap().entries.map((entry) {
final index = entry.key;
return Container(
width: 8,
height: 8,
margin: const EdgeInsets.symmetric(horizontal: 3),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _currentBannerIndex == index
? Colors.orange
: Colors.grey[300],
),
);
}).toList(),
),
],
);
}
}
实现效果:

4.2.3 分类标签
分类标签(lib/pages/home/components/category_tab.dart):
// lib/pages/home/components/category_tab.dart # 早餐/午餐等分类标签
import 'package:flutter/material.dart';
// 1. StatefulWidget(需要管理选中状态)
class CategoryTab extends StatefulWidget {
// 2. 添加回调函数参数:父组件可通过该回调获取选中的分类
final Function(String)? onCategorySelected;
// 构造函数保持 const,新增回调参数(可选,默认null)
const CategoryTab({
super.key,
this.onCategorySelected,
});
@override
State<CategoryTab> createState() => _CategoryTabState();
}
class _CategoryTabState extends State<CategoryTab> {
// 3. 定义选中状态变量,初始选中第一个(索引0)
int _selectedIndex = 0;
// 分类列表保持不变,移到State类中(或仍放Widget类,二选一)
final List<String> categories = const [
'早餐',
'午餐',
'下午茶',
'晚餐',
'夜宵',
];
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal, // 横向滚动
child: Row(
children: categories
.asMap()
.entries
.map((entry) {
final index = entry.key;
final category = entry.value;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(
category,
style: TextStyle(
fontSize: 12,
// 4. 动态判断选中状态:选中的标签文字为白色,未选中为深灰
color: _selectedIndex == index ? Colors.white : Colors.grey[700],
),
),
// 5. 动态选中状态:当前索引等于选中索引则选中
selected: _selectedIndex == index,
selectedColor: Colors.orange,
backgroundColor: Colors.grey[200],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
// 6. 点击标签时更新选中状态,并触发父组件回调
onSelected: (selected) {
if (selected) {
setState(() {
_selectedIndex = index; // 更新选中索引
});
// 调用父组件的回调,传递选中的分类名称
if (widget.onCategorySelected != null) {
widget.onCategorySelected!(category);
}
}
},
),
);
})
.toList(),
),
);
}
}
实现效果:

4.2.4 美食卡片
美食卡片(lib/pages/home/components/food_card.dart):
import 'package:flutter/material.dart';
import 'package:flutter_harmonyos/models/food_model.dart';
// 修复:改用相对路径导入,避免 URI 错误
import 'package:flutter_harmonyos/pages/food_detail/food_detail_page.dart';
class FoodCard extends StatelessWidget {
final FoodModel food;
final Function()? onCardTap;
final Function()? onCardLongPress;
const FoodCard({
super.key,
required this.food,
this.onCardTap,
this.onCardLongPress,
});
// 长按底部菜单
void _showBottomMenu(BuildContext context) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
),
isDismissible: true,
builder: (context) => Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.favorite_border, color: Colors.orange),
title: const Text('收藏'),
onTap: () {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('已收藏「${food.name ?? '未知美食'}」')),
);
},
),
ListTile(
leading: const Icon(Icons.share, color: Colors.blue),
title: const Text('分享'),
onTap: () {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('正在分享「${food.name ?? '未知美食'}」')),
);
},
),
ListTile(
leading: const Icon(Icons.report_problem, color: Colors.red),
title: const Text('举报', style: TextStyle(color: Colors.red)),
onTap: () {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请选择举报原因')),
);
},
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onCardTap ?? () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => FoodDetailPage(food: food),
),
);
},
onLongPress: onCardLongPress ?? () => _showBottomMenu(context),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.grey[200]!,
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: food.isLocalImage
? Image.asset(
food.effectiveImagePath,
height: 120,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const Icon(Icons.broken_image),
)
: Image.network(
food.effectiveImagePath,
height: 120,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const Icon(Icons.broken_image),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
food.name ?? '未知美食',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Row(
children: [
const Icon(Icons.star, color: Colors.orange, size: 14),
Text(' ${food.score ?? 0.0}', style: const TextStyle(fontSize: 12)),
const SizedBox(width: 8),
Expanded(
child: Text(
food.source ?? '未知来源',
style: const TextStyle(fontSize: 12, color: Colors.grey),
maxLines: 1,
overflow: TextOverflow.ellipsis,
softWrap: false,
),
),
],
),
],
),
),
],
),
),
);
}
}
实现效果:

4.3 数据模型与接口实现
4.3.1 美食模型
美食模型(lib/models/food_model.dart):
// models/food_model.dart 美食数据模型
import 'package:json_annotation/json_annotation.dart';
part 'food_model.g.dart'; // 关联自动生成的解析代码文件
@JsonSerializable()
class FoodModel {
final int? id;
final String? name; // 美食名称
// final String? desc; // 美食描述
final String? desc;
final String? image; // 本地assets图片路径(兼容本地)
final String? imageUrl; // 网络图片URL(兼容接口)
final String? source; // 美食来源(新增,对应图片方案)
final double? score; // 美食评分
final String? category;
// 新增美食详情字段
final List<String>? ingredients; // 食材列表
final List<String>? steps; // 制作步骤
final String? cookTime; // 烹饪时长(如"30分钟")
final String? difficulty; // 难度(如"简单/中等/困难")
final String? calories; // 热量(如"250大卡")
// 构造函数:包含所有字段
FoodModel({
this.id,
this.name,
this.desc,
this.image,
this.imageUrl,
this.source,
this.score,
this.category,
this.ingredients,
this.steps,
this.cookTime,
this.difficulty,
this.calories,
});
// 自动生成的JSON转模型方法
factory FoodModel.fromJson(Map<String, dynamic> json) => _$FoodModelFromJson(json);
// 自动生成的模型转JSON方法
Map<String, dynamic> toJson() => _$FoodModelToJson(this);
// 辅助方法1:判断是否为本地assets图片
// 计算属性:判断是否为本地图片(忽略序列化,避免 JSON 报错)
@JsonKey(ignore: true)
bool get isLocalImage => image?.startsWith('assets/') ?? false;
// 辅助方法2:统一获取图片路径(优先本地,否则网络)
// 计算属性:统一获取图片路径(忽略序列化,修复空指针风险)
@JsonKey(ignore: true)
String get effectiveImagePath => isLocalImage ? (image ?? '') : (imageUrl ?? '');
}
// 美食列表模型(适配接口返回的列表数据)
class FoodListModel {
final List<FoodModel> foodList;
const FoodListModel({required this.foodList});
factory FoodListModel.fromJson(Map<String, dynamic> json) {
List<FoodModel> list = <FoodModel>[];
if (json['foodList'] != null && json['foodList'] is List) {
list = (json['foodList'] as List)
.where((e) => e is Map<String, dynamic>)
.map((e) => FoodModel.fromJson(e))
.toList();
}
return FoodListModel(foodList: list);
}
}
执行flutter pub run build_runner build生成food_model.g.dart解析文件。
4.3.2 美食数据接口
美食数据接口(lib/api/food_api.dart):
// api/ # 数据接口
// food_api.dart # 模拟美食数据
// 先导入依赖包
import 'package:dio/dio.dart';
import '../core/http/api_config.dart'; // 对应api_config的路径
// 定义FoodApi类
class FoodApi {
// 改为类的静态方法,并接收分页参数page/pageSize
static Future<dynamic> getFoodList({
required int page,
required int pageSize,
}) async {
try {
Dio dio = Dio();
// 构造分页请求参数(传递给接口的query参数)
final queryParams = {
"page": page,
"pageSize": pageSize,
};
print("请求接口:${ApiConfig.food_list_url},参数:$queryParams"); // 打印请求地址
// 发起请求时携带分页参数,调用api_config中配置的接口地址
Response response = await dio.get(
ApiConfig.food_list_url,
queryParameters: queryParams, // 关键:把分页参数传给后端接口
);
print("接口返回:${response.data}"); // 打印返回数据
return response.data; // 提取接口返回的美食列表数据
} catch (e) {
print("接口请求失败:$e"); // 打印错误
throw e;
}
}
}
5 各选项卡页面
说明:当前阶段各选项卡页面仅完成基础框架搭建(页面标题、底部导航栏联动、基础布局骨架),未实现完整功能模块;原定的功能将在后续博客章节中逐步开发完善,而当前仅搭建的页面基础框架,为后续功能开发奠定基础。
5.1 美食页面
美食页面(lib/pages/food_show/food_show_page.dart):
基础框架搭建:完成页面基础布局,功能模块后续迭代完善。
// food_show/food_show_page.dart # 美食页面
// lib/pages/food_show/food_show_page.dart(美食)
import 'package:flutter/material.dart';
class FoodShowPage extends StatelessWidget {
const FoodShowPage({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(child: Text('美食页面')),
);
}
}
实现效果:

5.2 动态页面
动态页面(lib/pages/food_note/food_note_page.dart):
基础框架搭建:完成页面基础布局,功能模块(动态列表、点赞评论交互、加载状态)后续迭代完善。
// food_note/ # 动态页面
// lib/pages/food_note/food_note_page.dart(动态)
import 'package:flutter/material.dart';
class FoodNotePage extends StatelessWidget {
const FoodNotePage({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(child: Text('动态页面')),
);
}
}
实现效果:

5.3 推荐页面
推荐页面(lib/pages/eat_what/eat_what_page.dart):
基础框架搭建:完成页面基础布局,功能模块(随机推荐、按钮交互)后续迭代完善。
// eat_what/ # 推荐页面
// lib/pages/eat_what/eat_what_page.dart(推荐)
import 'package:flutter/material.dart';
class EatWhatPage extends StatelessWidget {
const EatWhatPage({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(child: Text('推荐页面')),
);
}
}
实现效果:

5.4 我的页面
我的页面(lib/pages/mine/mine_page.dart):
基础框架搭建:完成页面基础布局,功能模块(用户信息、收藏、设置)后续迭代完善。
// mine/ # 我的页面
// lib/pages/mine/mine_page.dart(我的)
import 'package:flutter/material.dart';
class MinePage extends StatelessWidget {
const MinePage({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(child: Text('我的页面')),
);
}
}
实现效果:

6 运行验证与代码提交
6.1 开源鸿蒙终端运行验证
✅ 模拟器运行:
打开 DevEco Studio,启动鸿蒙OpenHarmony SDK(API Version 20(6.0.0.47))、模拟器,点击右上角运行按钮;
或者通过VS Code / DevEco Studio 终端执行 flutter run 命令运行模拟器;
✅ 验证功能:底部选项卡切换流畅、首页数据加载正常、各页面交互无异常。

6.2 Git 代码提交规范(AtomGit)
-
提交粒度:按功能模块拆分提交(如feat: 底部选项卡实现、feat: 首页轮播+搜索栏开发、fix: 页面状态保留优化);
-
Commit Message 规范:
⚡ feat: 新增功能(如底部选项卡、首页组件)
🔧 fix: 修复bug(如页面重复加载、布局错乱)
📊 docs: 文档更新(如 README、注释)
💻 refactor: 代码重构(如目录结构优化、组件复用,没有新增功能或修复Bug)
- 提交步骤
所需全部命令:
# 1. 查看修改
git status
# 2. 添加修改文件
git add .
# 3. 提交(规范Commit Message)
git commit -m "feat: 完成底部选项卡+首页功能实现"
# 4. 推送到AtomGit远程仓库
git push origin main
✅ (1)查看本地修改(确认要提交的内容):
在 VS Code 终端执行命令,查看当前修改的文件:
git status

✅ (2)暂存本地修改
将所有修改的文件暂存到 Git 缓存区(若只想提交部分文件,可替换.为具体文件名,如git add lib/pages/home/home_page.dart):
git add .

✅ (3)提交本地修改(写清楚更新内容):
按照 Git 提交规范,编写清晰的 commit message(说明本次更新了什么):
git commit -m "feat: 完成底部选项卡+首页功能实现"

✅ (4)拉取远程仓库最新代码
先拉取 AtomGit 上的最新代码(防止远程有更新导致推送冲突):
git pull origin main

✅ (5)推送更新到 AtomGit 仓库
将本地最新提交推送到 AtomGit 的main分支:
git push origin main

✅ (6)验证推送结果
打开 AtomGit 仓库页面(在 AtomGit 创建的个人公开仓库),刷新页面:
确认仓库的"提交记录"中出现刚写的commit message;
刷新后的页面也能看到最新的提交记录和更新时间。

- 仓库要求:确保工程包含
pubspec.yaml、lib/源码、assets/资源、调试日志,可直接拉取复现运行效果。
7 总结与拓展
7.1 任务总结
本次 DAY8~DAY13 阶段完成了底部选项卡的规范开发(5 个选项卡、状态保留、平滑切换)与首页核心功能实现(搜索、轮播、分类、美食列表),同时搭建美食、动态、推荐、我的四大选项卡页面基本框架,配合后续功能实现;最终在开源鸿蒙终端完成运行验证并规范提交代码。
核心成果包括:
✅ 搭建了清晰的 Flutter + 鸿蒙项目目录结构,实现组件复用与模块化开发;
✅ 掌握了底部选项卡的状态管理、页面状态保留、交互优化技巧;
✅ 实现了首页的完整功能闭环,接入了下拉刷新、上拉加载等交互。
7.2 拓展方向
说明:当前项目已完成首页核心功能与选项卡基础框架,后续将继续完善其他页面功能、补充完整模块与异常处理,持续优化项目体验。
后续将继续完善项目:
🔧 完善其他选项卡页面功能(美食、动态、推荐、我的页面);
📊 补充完整功能模块与异常处理,优化整体体验。
参考资料
参考前文:
https://blog.csdn.net/m0_74451345/article/details/156915775?spm=1001.2014.3001.5501
https://blog.csdn.net/m0_74451345/article/details/157024056?spm=1001.2014.3001.5501
https://blog.csdn.net/m0_74451345/article/details/157032531?spm=1001.2014.3001.5501
参考三方库:
OpenHarmony兼容的三方库:https://gitcode.com/openharmony-tpc/flutter_packages
最后,
欢迎加入开源鸿蒙跨平台社区: