Flutter for OpenHarmony 教育百科实战:图书搜索
图书搜索功能是教育百科App的核心模块,设计时需兼顾多种状态管理。本文介绍了搜索页面的关键实现细节:1) 通过状态变量区分未搜索/无结果/加载中等状态;2) 支持初始搜索词自动填充和搜索;3) 实现搜索框的智能交互(清除按钮、回车触发);4) 分页加载逻辑处理;5) 不同状态下的UI展示(引导提示、骨架屏、空状态)。这些细节共同构建了流畅的搜索体验,包括智能的状态切换、友好的用户引导和高效的数据加
图书搜索是教育百科App中使用频率很高的功能,用户可以通过关键词快速找到想要的书籍。这个页面比图书列表复杂一些,因为要处理用户输入、搜索请求、结果展示,还要支持分页加载。
做搜索功能的时候,我特别注意了几个细节:搜索前的引导提示、搜索中的加载状态、无结果时的友好提示、以及加载更多的交互。这些细节加起来,才能让搜索体验变得顺畅。

状态变量设计
搜索页面需要管理的状态比较多:
class BookSearchScreen extends StatefulWidget {
final String? initialQuery;
final String? title;
const BookSearchScreen({super.key, this.initialQuery, this.title});
State<BookSearchScreen> createState() => _BookSearchScreenState();
}
class _BookSearchScreenState extends State<BookSearchScreen> {
final _searchController = TextEditingController();
List<dynamic> _results = [];
bool _isLoading = false;
bool _hasSearched = false;
int _currentPage = 1;
int _totalResults = 0;
}
initialQuery支持从外部传入初始搜索词,比如从首页的搜索入口跳转过来时可以带上用户输入的关键词。_hasSearched用来区分"还没搜索"和"搜索结果为空"两种状态,这个很重要。
为什么需要_hasSearched? 想象一下,用户刚进入搜索页面,还没输入任何内容。这时候显示"没有找到相关图书"就很奇怪,应该显示"搜索你感兴趣的图书"这样的引导提示。_hasSearched就是用来区分这两种情况的。
初始化处理
如果有初始搜索词,自动执行搜索:
void initState() {
super.initState();
if (widget.initialQuery != null) {
_searchController.text = widget.initialQuery!;
_search();
}
}
把初始搜索词填入输入框,然后调用搜索方法。这样用户从其他页面跳转过来时可以直接看到搜索结果,不需要再手动点击搜索。
搜索方法实现
支持首次搜索和加载更多:
Future<void> _search({bool loadMore = false}) async {
if (_searchController.text.isEmpty) return;
setState(() {
_isLoading = true;
if (!loadMore) {
_results = [];
_currentPage = 1;
}
});
loadMore参数区分是新搜索还是加载更多。新搜索时清空结果并重置页码,加载更多时保留现有结果。
try {
final data = await ApiService.searchBooks(_searchController.text, page: _currentPage);
setState(() {
if (loadMore) {
_results.addAll(data['docs'] ?? []);
} else {
_results = data['docs'] ?? [];
}
_totalResults = data['numFound'] ?? 0;
_hasSearched = true;
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
}
}
加载更多时用
addAll把新数据追加到现有列表,新搜索时直接替换整个列表。numFound是API返回的总结果数,用于判断是否还有更多数据可以加载。
搜索输入框
页面顶部的搜索框:
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title ?? '搜索图书'),
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: '输入书名、作者...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() {
_results = [];
_hasSearched = false;
});
},
)
: null,
),
onSubmitted: (_) => _search(),
onChanged: (_) => setState(() {}),
),
),
prefixIcon显示搜索图标,suffixIcon在有输入内容时显示清除按钮。onSubmitted在用户按回车时触发搜索,onChanged触发setState更新清除按钮的显示状态。
为什么清除按钮要用条件判断? 输入框为空时显示清除按钮没有意义,而且会让界面显得杂乱。只有在有内容时才显示,用户体验更好。
搜索结果数量提示
显示找到多少本图书:
if (_hasSearched && !_isLoading)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Text('找到 $_totalResults 本图书', style: TextStyle(color: Colors.grey[600])),
],
),
),
Expanded(child: _buildResults()),
],
),
);
}
只有在搜索完成且不在加载中时才显示结果数量。这个提示帮助用户了解搜索结果的规模,如果结果很多,用户可能会考虑换个更精确的关键词。
结果区域构建
根据不同状态显示不同内容:
Widget _buildResults() {
if (!_hasSearched) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search, size: 80, color: Colors.grey[300]),
const SizedBox(height: 16),
Text('搜索你感兴趣的图书', style: TextStyle(color: Colors.grey[500])),
],
),
);
}
未搜索时显示引导提示,一个大的搜索图标加一句引导语。这比显示空白页面友好多了。
if (_isLoading && _results.isEmpty) {
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: 5,
itemBuilder: (context, index) => const Padding(
padding: EdgeInsets.only(bottom: 12),
child: LoadingShimmer(height: 100),
),
);
}
if (_results.isEmpty) {
return const EmptyWidget(message: '没有找到相关图书', icon: Icons.menu_book);
}
首次加载时显示骨架屏,无结果时显示空状态提示。注意
_isLoading && _results.isEmpty这个条件,加载更多时不应该显示骨架屏,因为已经有结果在显示了。
结果列表和加载更多
有结果时显示列表和加载更多按钮:
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _results.length + (_results.length < _totalResults ? 1 : 0),
itemBuilder: (context, index) {
if (index == _results.length) {
return Padding(
padding: const EdgeInsets.all(16),
child: ElevatedButton(
onPressed: _isLoading ? null : () {
_currentPage++;
_search(loadMore: true);
},
child: _isLoading ? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
) : const Text('加载更多'),
),
);
}
return _buildBookItem(_results[index]);
},
);
}
itemCount多加1是为了在列表末尾显示加载更多按钮。只有当已加载的结果少于总数时才显示按钮,如果已经加载完了就不显示。
按钮的状态处理: 加载中时按钮禁用(onPressed: null)并显示小型加载动画,避免用户重复点击。加载动画用SizedBox限制大小,strokeWidth: 2让圆环更细,适合在按钮里显示。
图书项展示
每本图书用横向布局展示:
Widget _buildBookItem(Map<String, dynamic> book) {
final coverId = book['cover_i'];
final coverUrl = coverId != null ? 'https://covers.openlibrary.org/b/id/$coverId-M.jpg' : null;
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: InkWell(
onTap: () {
final key = book['key'];
if (key != null) {
Navigator.push(context, MaterialPageRoute(builder: (_) => BookDetailScreen(bookKey: key)));
}
},
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
NetworkImageWidget(
imageUrl: coverUrl,
width: 70,
height: 100,
),
const SizedBox(width: 12),
左侧是封面图片,固定70x100的尺寸,接近书籍封面的比例。
InkWell的borderRadius要和Card的圆角一致,否则水波纹效果会超出边界。
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
book['title'] ?? '未知标题',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Text(
(book['author_name'] as List?)?.join(', ') ?? '未知作者',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: Colors.grey[600], fontSize: 13),
),
const SizedBox(height: 4),
if (book['first_publish_year'] != null)
Text(
'首次出版: ${book['first_publish_year']}年',
style: TextStyle(color: Colors.grey[500], fontSize: 12),
),
if (book['publisher'] != null)
Text(
'出版社: ${(book['publisher'] as List).first}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: Colors.grey[500], fontSize: 12),
),
],
),
),
],
),
),
),
);
}
搜索结果比列表页显示更多信息,包括出版年份和出版社。
publisher是数组,取第一个元素显示。Expanded让右侧的文字区域占据剩余空间。
资源释放
页面销毁时释放TextEditingController:
void dispose() {
_searchController.dispose();
super.dispose();
}
TextEditingController需要手动释放,否则会造成内存泄漏。这是Flutter开发中的常见模式,每次用到Controller都要记得在dispose里释放。
搜索体验优化的思考
目前的实现是用户按回车或点击搜索按钮才触发搜索。还有一种做法是实时搜索——用户每输入一个字符就自动搜索。但这样会产生大量请求,对服务器压力大,而且用户还没输完就开始搜索,结果可能不是用户想要的。
如果要做实时搜索,可以加一个防抖(debounce)机制:用户停止输入一段时间(比如500毫秒)后才触发搜索。这样既能提供实时反馈,又不会产生太多无效请求。
小结
图书搜索页面展示了如何处理用户输入和分页加载。通过_hasSearched状态区分不同的显示场景,让用户在各种情况下都能得到合适的反馈。加载更多按钮的实现让用户可以按需加载数据,避免一次性加载过多内容。
下一篇我们来看图书详情页面的实现,了解如何展示一本书的完整信息。
本文是Flutter for OpenHarmony教育百科实战系列的第七篇。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐
所有评论(0)