Flutter 中使用 Autocomplete 实现智能模糊搜索加历史记录提示
📋 项目背景
本项目为历史事件展示与搜索页面,需实现一个智能搜索功能,结合远程模糊搜索与本地历史搜索提示,以提升用户搜索体验。
🛠 技术框架
| 技术/库 | 版本 | 用途 |
|---|---|---|
| Flutter | 3.x+ | 跨平台UI框架 |
| GetX | 4.x+ | 状态管理与路由 |
| Autocomplete | Material组件 | 自动完成输入功能 |
🎨 UI效果


📝 实现代码
核心搜索框构建
dart
Widget _buildSearchField() {
return Container(
height: 40,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: AppColor.phone2Panel2,
),
child: Row(
children: [
const SizedBox(width: 10),
Phone2Icon.search,
const SizedBox(width: 6),
Expanded(
child: Autocomplete<String>(
// 输入框构建器
fieldViewBuilder: (context, textEditingController, focusNode, onFieldSubmitted) {
return TextField(
controller: textEditingController,
focusNode: focusNode,
decoration: InputDecoration(
hintText: "event.hint.search".tr,
border: InputBorder.none,
suffixIcon: ValueListenableBuilder<TextEditingValue>(
valueListenable: textEditingController,
builder: (context, value, child) {
// 当输入框有内容时显示清空按钮
if (value.text.isNotEmpty) {
return IconButton(
icon: Icon(Icons.clear, size: 20, color: AppColor.phone2Text),
onPressed: () {
// 清空输入框内容
textEditingController.clear();
// 同时清空controller中的搜索文本
controller.searchText = '';
// 刷新数据
controller.refreshData();
// 请求焦点,保持输入框激活状态
focusNode.requestFocus();
},
);
}
return const SizedBox.shrink(); // 无内容时不显示
},
),
),
onSubmitted: (value) {
// 处理键盘回车键提交
if (value.trim().isNotEmpty) {
// 保存搜索记录
Phone2Service.to.saveRecentSearch(value.trim());
// 设置搜索文本
controller.searchText = value.trim();
// 执行搜索
controller.getSearchResults(value.trim());
// 关闭键盘
focusNode.unfocus();
}
},
);
},
// 选项生成器 - 核心搜索逻辑
optionsBuilder: (textEditingValue) async {
// 1. 当输入值为空时,显示最近搜索数据
if (textEditingValue.text.isEmpty) {
var recentSearches = Phone2Service.to.getRecentSearches();
return recentSearches.take(5); // 限制最多显示5条
}
// 2. 检查缓存中是否有包含当前输入字符的记录
final recentSearches = Phone2Service.to.getRecentSearches().take(5).toList();
final matchedRecentSearches = recentSearches.where((recent) =>
recent.contains(textEditingValue.text)).toList();
// 3. 如果缓存中有匹配的记录,优先显示缓存数据
if (matchedRecentSearches.isNotEmpty) {
return matchedRecentSearches;
}
// 4. 如果没有匹配的缓存记录,则进行模糊搜索
var results = await DeviceApi.fuzzySearchDrones(textEditingValue.text);
return results;
},
// 选项视图构建器 - 自定义UI展示
optionsViewBuilder: (context, onSelected, options) {
final recentSearches = Phone2Service.to.getRecentSearches().take(5).toList();
// 判断是否为缓存数据:检查options是否完全来自缓存
bool isRecentSearches = options.isNotEmpty && options.every((option) => recentSearches.contains(option));
return Align(
alignment: Alignment.topLeft,
child: Transform.translate(
offset: const Offset(-33, 15),
child: Material(
borderRadius: BorderRadius.circular(10),
color: Colors.black.withOpacity(0.9),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 300, maxWidth: 286),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// 标题显示
if (isRecentSearches)
Text(
"event.search.recent".tr,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
if (isRecentSearches) const SizedBox(height: 8),
// 内容区域
Container(
constraints: BoxConstraints(
maxHeight: isRecentSearches && options.length <= 3
? 120.0
: 300.0,
),
child: isRecentSearches
? _buildRecentSearches(options, onSelected)
: _buildSearchResults(options, onSelected),
),
],
),
),
),
),
),
);
},
// 选项选择回调
onSelected: (suggestion) {
controller.searchText = suggestion.trim();
if (controller.searchText != '') {
Phone2Service.to.saveRecentSearch(suggestion.trim());
controller.getSearchResults(suggestion.trim());
}
},
),
),
],
),
);
}
// 构建历史搜索标签
Widget _buildRecentSearches(List<String> options, Function(String) onSelected) {
return options.length <= 3
? Wrap(
spacing: 8.0,
runSpacing: 8.0,
children: options.map((option) {
return _buildOptionItem(option, onSelected);
}).toList(),
)
: SingleChildScrollView(
scrollDirection: Axis.vertical,
child: Wrap(
spacing: 8.0,
runSpacing: 8.0,
children: options.map((option) {
return _buildOptionItem(option, onSelected);
}).toList(),
),
);
}
// 构建搜索结果列表
Widget _buildSearchResults(List<String> options, Function(String) onSelected) {
return ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: options.length <= 6,
physics: options.length > 6
? const AlwaysScrollableScrollPhysics()
: const NeverScrollableScrollPhysics(),
itemCount: options.length,
itemBuilder: (context, index) {
final option = options.elementAt(index);
return InkWell(
onTap: () => onSelected(option),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Text(
option,
style: const TextStyle(fontSize: 16, color: Colors.white),
),
),
);
},
);
}
// 构建选项项Widget
Widget _buildOptionItem(String option, Function(String) onSelected) {
return InkWell(
onTap: () => onSelected(option),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
option,
style: const TextStyle(fontSize: 14, color: Colors.white),
),
),
);
}
🔍 核心代码解析
1. Autocomplete 组件核心参数
Autocomplete 组件通过以下四个核心参数实现自定义功能:
dart
Autocomplete<String>(
fieldViewBuilder: (context, textEditingController, focusNode, onFieldSubmitted) {},
optionsBuilder: (textEditingValue) async {}, // 必需参数:选项生成器
optionsViewBuilder: (context, onSelected, options) {}, // 选项视图构建器
onSelected: (suggestion) {} // 选项选择回调
)
| 参数 | 用途 | 说明 |
|---|---|---|
fieldViewBuilder |
自定义输入框 | 允许完全控制TextField的外观和行为 |
optionsBuilder |
生成选项列表 | 根据输入值返回建议列表,可以是同步或异步 |
optionsViewBuilder |
自定义选项视图 | 定义选项列表的展示方式和样式 |
onSelected |
处理选项选择 | 用户选择某一项时触发的回调 |
2. 搜索逻辑流程
是
否
有匹配
无匹配
用户输入搜索文本
输入文本是否为空?
显示最近搜索历史
最多5条
检查历史记录中
是否有匹配项
优先显示历史记录匹配项
发起远程模糊搜索
显示搜索结果
用户选择某一项
保存到历史记录
执行实际搜索
3. 选项生成器 (optionsBuilder) 详解
选项生成器是搜索功能的核心,实现了智能推荐逻辑:
dart
optionsBuilder: (textEditingValue) async {
// 场景1: 输入为空时,显示历史记录
if (textEditingValue.text.isEmpty) {
return Phone2Service.to.getRecentSearches().take(5);
}
// 场景2: 检查历史记录中是否有匹配项
final recentSearches = Phone2Service.to.getRecentSearches().take(5).toList();
final matchedRecentSearches = recentSearches
.where((recent) => recent.contains(textEditingValue.text))
.toList();
if (matchedRecentSearches.isNotEmpty) {
return matchedRecentSearches; // 优先返回历史记录
}
// 场景3: 无历史记录匹配,发起远程搜索
return await DeviceApi.fuzzySearchDrones(textEditingValue.text);
}
4. 输入框自定义 (fieldViewBuilder)
通过 fieldViewBuilder 实现了输入框的完全自定义:
dart
fieldViewBuilder: (context, textEditingController, focusNode, onFieldSubmitted) {
return TextField(
controller: textEditingController,
focusNode: focusNode,
decoration: InputDecoration(
hintText: "event.hint.search".tr,
border: InputBorder.none,
suffixIcon: ValueListenableBuilder<TextEditingValue>(
valueListenable: textEditingController,
builder: (context, value, child) {
// 动态显示清除按钮
return value.text.isNotEmpty
? IconButton(
icon: Icon(Icons.clear, size: 20, color: AppColor.phone2Text),
onPressed: () {
textEditingController.clear();
controller.searchText = '';
controller.refreshData();
focusNode.requestFocus();
},
)
: const SizedBox.shrink();
},
),
),
onSubmitted: (value) {
// 处理键盘回车提交
if (value.trim().isNotEmpty) {
Phone2Service.to.saveRecentSearch(value.trim());
controller.searchText = value.trim();
controller.getSearchResults(value.trim());
focusNode.unfocus();
}
},
);
}
5. 选项视图差异化展示 (optionsViewBuilder)
根据选项来源(历史记录或远程搜索)采用不同的布局方式:
- 历史记录 : 使用流式布局 (
Wrap) 显示标签,标签高度自适应 - 搜索结果 : 使用列表布局 (
ListView) 显示结果,保持传统搜索界面
dart
// 判断选项来源
bool isRecentSearches = options.isNotEmpty &&
options.every((option) => recentSearches.contains(option));
if (isRecentSearches) {
// 历史记录:流式布局
return _buildRecentSearches(options, onSelected);
} else {
// 搜索结果:列表布局
return _buildSearchResults(options, onSelected);
}
🧭 数据持久化方案
历史搜索记录通过 shared_preferences 插件实现本地持久化存储:
dart
// 存储搜索记录
Future<void> saveRecentSearch(String searchTerm) async {
final prefs = await SharedPreferences.getInstance();
List<String> recentSearches = prefs.getStringList('recentSearches') ?? [];
// 去重并添加新搜索词
recentSearches.remove(searchTerm);
recentSearches.insert(0, searchTerm);
// 限制保存数量
if (recentSearches.length > 10) {
recentSearches = recentSearches.take(10).toList();
}
await prefs.setStringList('recentSearches', recentSearches);
}
// 获取搜索记录
List<String> getRecentSearches() {
final prefs = SharedPreferences.getInstance(); // 同步获取
return prefs.getStringList('recentSearches') ?? [];
}
📊 功能对比与优势
| 功能特性 | 传统搜索实现 | 本方案优势 |
|---|---|---|
| 智能提示 | 仅显示远程结果 | 优先显示历史记录,减少网络请求 |
| 用户体验 | 单一列表展示 | 区分历史记录与搜索结果,视觉更清晰 |
| 性能优化 | 每次都发起网络请求 | 历史记录本地匹配,减少延迟 |
| 功能丰富 | 基本搜索功能 | 支持清空、键盘操作、标签展示等 |
| 代码维护 | 分散在多个组件 | 逻辑集中,易于维护和扩展 |
🎯 最佳实践建议
-
防抖处理 :在远程搜索前添加防抖逻辑,避免频繁请求
dartTimer? _debounce; // 在optionsBuilder中 if (_debounce?.isActive ?? false) _debounce!.cancel(); _debounce = Timer(const Duration(milliseconds: 500), () async { // 发起搜索请求 var results = await DeviceApi.fuzzySearchDrones(textEditingValue.text); return results; }); -
加载状态提示 :添加加载指示器提升用户体验
dart// 在optionsViewBuilder中 child: options.isEmpty ? const Center(child: CircularProgressIndicator()) : _buildRecentSearches(options, onSelected) -
空状态处理 :为无结果的情况添加友好提示
dart// 在optionsBuilder中 if (results.isEmpty) { return ['无相关结果']; // 或使用Widget返回空状态UI } -
键盘操作支持 :增强键盘导航功能
dart// 在TextField中添加 keyboardType: TextInputType.text, textInputAction: TextInputAction.search,
🔧 常见问题解决
问题1:选项显示位置不准确
解决方案 :调整 Transform.translate 的偏移量
dart
offset: const Offset(-33, 15), // 根据实际UI布局调整
问题2:历史记录不更新
解决方案 :确保正确使用 ValueListenableBuilder 监听文本变化
dart
suffixIcon: ValueListenableBuilder<TextEditingValue>(
valueListenable: textEditingController, // 确保监听正确的controller
builder: (context, value, child) { ... }
)
问题3:远程搜索结果与历史记录混合显示
解决方案 :在 optionsBuilder 中明确判断选项来源
dart
// 明确判断是历史记录还是远程结果
bool isRecentSearches = options.isNotEmpty &&
options.every((option) => recentSearches.contains(option));
📚 参考资料与扩展阅读
✨ 总结
通过 Flutter 的 Autocomplete 组件,我们成功实现了一个功能丰富、用户体验良好的智能搜索框,结合了本地历史记录提示和远程模糊搜索功能。该方案不仅提供了良好的视觉反馈,还通过优先显示历史记录优化了性能,减少了不必要的网络请求。开发者可以根据实际需求进一步调整样式和逻辑,使其适应更多应用场景。
💡 提示:完整代码示例和更多实现细节可参考项目仓库。在实际项目中,建议将搜索相关逻辑封装为独立的组件或服务,便于复用和维护。