Flutter for OpenHarmony二手物品置换App实战 - 搜索功能实现

搜索是二手交易App的核心功能之一,用户想找特定的商品,第一反应就是搜索。今天我们来实现"闲置换"的搜索功能,包括搜索历史记录、热门搜索推荐、搜索结果展示这几个部分。做好搜索功能能大大提升用户找到心仪商品的效率,也是衡量一个电商类App体验好坏的重要指标。
搜索功能的设计思路
一个好用的搜索功能应该具备这些特点:输入框自动获取焦点方便用户直接输入、展示搜索历史方便用户快速重复搜索、提供热门搜索词引导用户发现热门商品、搜索结果用网格展示和首页保持一致的体验。我们还要考虑搜索历史的本地存储,让用户下次打开App还能看到之前的搜索记录。
搜索页面完整实现
先看页面的基础结构和状态定义,这部分代码定义了搜索页面需要管理的所有状态变量:
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../product/product_detail_page.dart';
class SearchPage extends StatefulWidget {
const SearchPage({super.key});
State<SearchPage> createState() => _SearchPageState();
}
导入必要的包是第一步,GetX用于页面跳转,它比Flutter原生的Navigator更简洁,代码量更少。商品详情页用于点击搜索结果后跳转,让用户能查看商品的完整信息。SearchPage用StatefulWidget是因为要管理搜索框的输入状态、搜索历史、搜索结果等多个状态,这些状态会随着用户操作而变化,需要动态更新UI。
class _SearchPageState extends State<SearchPage> {
final TextEditingController _searchController = TextEditingController();
final List<String> _hotSearches = ['iPhone', '笔记本', '相机', '耳机', '手表', '包包'];
final List<String> _historySearches = ['二手手机', '电脑', '书籍'];
List<Map<String, dynamic>> _searchResults = [];
bool _isSearching = false;
void dispose() {
_searchController.dispose();
super.dispose();
}
这里定义了几个关键的状态变量,每个都有特定的用途。_searchController控制搜索输入框,可以获取用户输入的内容,也可以程序化地设置输入框的值,是Flutter表单处理的标准做法。_hotSearches是热门搜索词列表,实际项目中这个数据应该从后端获取,可以根据搜索热度动态调整,运营人员也可以手动配置推广某些关键词。_historySearches存储用户的搜索历史,真实项目要持久化到本地存储,这样用户下次打开App还能看到。_isSearching标记当前是否在展示搜索结果,用来切换显示搜索建议还是搜索结果。dispose方法里记得释放TextEditingController,这是Flutter开发的好习惯,避免内存泄漏。
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
titleSpacing: 0,
title: Container(
height: 36,
margin: const EdgeInsets.only(right: 16),
child: TextField(
controller: _searchController,
autofocus: true,
decoration: InputDecoration(
hintText: '搜索你想要的宝贝',
hintStyle: TextStyle(color: Colors.grey[400], fontSize: 14),
prefixIcon: const Icon(Icons.search, color: Colors.grey, size: 20),
filled: true,
fillColor: const Color(0xFFF5F5F5),
contentPadding: EdgeInsets.zero,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide.none,
),
),
onSubmitted: _search,
),
),
搜索框放在AppBar的title位置,这样可以充分利用顶部空间,让搜索框尽可能宽,用户输入长关键词时也能完整显示。titleSpacing: 0去掉默认的左边距,让搜索框更宽。autofocus: true让页面打开时输入框自动获取焦点,键盘自动弹出,用户可以直接输入,不需要再点一下搜索框,这个小细节能提升用户体验。输入框样式用圆角矩形,浅灰色背景,左边放搜索图标,整体风格简洁现代。contentPadding: EdgeInsets.zero配合prefixIcon可以让图标和文字垂直居中。onSubmitted在用户按下键盘回车时触发搜索,这是移动端搜索的标准交互方式。
actions: [
TextButton(
onPressed: () => _search(_searchController.text),
child: const Text('搜索', style: TextStyle(color: Color(0xFF07C160))),
),
],
),
body: _isSearching ? _buildSearchResults() : _buildSearchSuggestions(),
);
}
右边放一个搜索按钮,点击也能触发搜索,方便不想用键盘回车的用户,有些用户习惯点击按钮而不是按回车,提供多种操作方式能覆盖更多用户习惯。按钮用主题绿色0xFF07C160,和整体风格统一,也能引导用户注意到这个操作入口。body部分根据_isSearching状态决定显示什么内容:还没搜索时显示搜索建议(历史+热门),搜索后显示搜索结果。这种条件渲染的方式让代码结构清晰,两种状态的UI完全分开,互不干扰,后期维护也方便。
Widget _buildSearchSuggestions() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_historySearches.isNotEmpty) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('搜索历史', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
GestureDetector(
onTap: () => setState(() => _historySearches.clear()),
child: const Icon(Icons.delete_outline, color: Colors.grey, size: 20),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 10,
runSpacing: 10,
children: _historySearches.map((item) => _buildSearchTag(item)).toList(),
),
const SizedBox(height: 24),
],
搜索建议页面用SingleChildScrollView包裹,内容多了可以滚动,避免内容超出屏幕。搜索历史部分只在有历史记录时才显示,用if配合展开运算符...[]实现条件渲染,这是Dart语言的一个很实用的特性,可以在列表中条件性地添加元素,比传统的if-else更简洁。标题栏右边放一个删除图标,点击清空所有历史记录,给用户一个快速清理的入口,保护用户隐私。Wrap组件让标签自动换行,spacing控制水平间距,runSpacing控制行间距,这样不管有多少个历史记录,都能整齐地排列,不会超出屏幕。
const Text('热门搜索', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
Wrap(
spacing: 10,
runSpacing: 10,
children: _hotSearches.map((item) => _buildSearchTag(item, isHot: true)).toList(),
),
],
),
);
}
热门搜索部分始终显示,因为这是引导用户发现商品的重要入口,即使用户没有搜索历史也能看到热门内容,帮助新用户快速上手。用同样的Wrap布局保持视觉一致性,用户不需要适应不同的布局方式。传入isHot: true参数让热门标签有不同的样式,和历史记录区分开,用户一眼就能分辨哪些是自己搜过的,哪些是平台推荐的热门词汇。
Widget _buildSearchTag(String text, {bool isHot = false}) {
return GestureDetector(
onTap: () {
_searchController.text = text;
_search(text);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: isHot ? const Color(0xFF07C160).withOpacity(0.1) : Colors.grey[100],
borderRadius: BorderRadius.circular(16),
),
child: Text(
text,
style: TextStyle(
color: isHot ? const Color(0xFF07C160) : Colors.black87,
fontSize: 14,
),
),
),
);
}
搜索标签是一个可点击的圆角容器,封装成独立方法方便复用,历史标签和热门标签都用这个方法生成。点击时先把文字填入搜索框,然后执行搜索,这样用户能看到搜索框里显示了什么关键词,体验更清晰透明。热门标签用浅绿色背景和绿色文字,历史标签用灰色背景和黑色文字,视觉上有明显区分。withOpacity(0.1)让背景色很淡,不会太抢眼,但又能和灰色标签区分开。圆角用16像素,看起来像胶囊形状,这是现在很流行的标签设计风格,比方角看起来更柔和友好。
Widget _buildSearchResults() {
if (_searchResults.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search_off, size: 80, color: Colors.grey[300]),
const SizedBox(height: 16),
Text('未找到相关商品', style: TextStyle(color: Colors.grey[500])),
],
),
);
}
return GridView.builder(
padding: const EdgeInsets.all(12),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.65,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
),
itemCount: _searchResults.length,
itemBuilder: (context, index) => _buildProductCard(_searchResults[index]),
);
}
搜索结果页面先判断是否有结果,没有的话显示空状态提示,用一个大图标和文字告诉用户没找到商品,这比什么都不显示要友好得多,用户知道搜索已经执行了只是没有匹配的结果。有结果就用GridView.builder展示,两列网格布局,和首页商品列表保持一致的样式,用户不需要适应新的布局。childAspectRatio: 0.65控制卡片的宽高比,让卡片有足够的高度展示图片和信息。builder模式只会创建可见区域的卡片,性能更好,特别是搜索结果很多的时候不会卡顿。
Widget _buildProductCard(Map<String, dynamic> product) {
return GestureDetector(
onTap: () => Get.to(() => ProductDetailPage(productId: product['id'])),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
color: Colors.grey[200],
),
child: Center(child: Icon(Icons.image, size: 60, color: Colors.grey[400])),
),
),
商品卡片的结构和首页一样,保持一致性很重要,用户在不同页面看到相同的卡片样式会感觉更熟悉,降低认知负担。点击跳转到商品详情页,传入商品id让详情页知道要显示哪个商品的信息。图片区域占卡片的大部分空间,用flex: 3控制比例,图片是吸引用户点击的关键元素所以要足够大。这里用占位图标,实际项目要换成网络图片,可以用CachedNetworkImage实现图片缓存。
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(product['title'], style: const TextStyle(fontSize: 14), maxLines: 2, overflow: TextOverflow.ellipsis),
const Spacer(),
Text('¥${product['price']}', style: const TextStyle(color: Color(0xFFFF4D4F), fontSize: 16, fontWeight: FontWeight.bold)),
],
),
),
),
],
),
),
);
}
信息区域展示商品标题和价格,这是用户最关心的两个信息,其他信息放到详情页展示。标题最多两行,超出显示省略号,避免标题太长把卡片撑变形,保持卡片高度一致。价格用红色0xFFFF4D4F突出显示,这是电商App的通用做法,红色能吸引用户注意力,让用户快速扫描到价格信息做出购买决策。Spacer把标题和价格撑开,让布局更稳定,不管标题是一行还是两行,价格始终在底部。
void _search(String keyword) {
if (keyword.isEmpty) return;
setState(() {
_isSearching = true;
_searchResults = List.generate(10, (index) => {
'id': index,
'title': '$keyword 相关商品 ${index + 1}',
'price': (index + 1) * 100,
});
if (!_historySearches.contains(keyword)) {
_historySearches.insert(0, keyword);
if (_historySearches.length > 10) _historySearches.removeLast();
}
});
}
}
搜索方法先检查关键词是否为空,空的话直接返回,避免无意义的搜索请求。然后设置_isSearching为true切换到结果页面,生成模拟的搜索结果,实际项目中这里要调用后端API获取真实数据。搜索历史的处理逻辑很重要:如果关键词不在历史记录里,就插入到最前面,这样最近搜索的词总是在最前面方便用户找到;如果历史记录超过10条,就删除最后一条,避免历史记录无限增长占用太多空间。这种处理方式在各大App中都很常见,是经过验证的最佳实践。
小结
这篇实现了"闲置换"App的搜索功能,包括带自动聚焦的搜索框、搜索历史管理、热门搜索展示、搜索结果网格列表。搜索是用户找商品的主要入口,做好搜索体验能显著提升用户满意度。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐
所有评论(0)