Flutter 中使用 Autocomplete 实现智能模糊搜索加历史记录提示

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') ?? [];
}

📊 功能对比与优势

功能特性 传统搜索实现 本方案优势
智能提示 仅显示远程结果 优先显示历史记录,减少网络请求
用户体验 单一列表展示 区分历史记录与搜索结果,视觉更清晰
性能优化 每次都发起网络请求 历史记录本地匹配,减少延迟
功能丰富 基本搜索功能 支持清空、键盘操作、标签展示等
代码维护 分散在多个组件 逻辑集中,易于维护和扩展

🎯 最佳实践建议

  1. 防抖处理 :在远程搜索前添加防抖逻辑,避免频繁请求

    dart 复制代码
    Timer? _debounce;
    
    // 在optionsBuilder中
    if (_debounce?.isActive ?? false) _debounce!.cancel();
    _debounce = Timer(const Duration(milliseconds: 500), () async {
      // 发起搜索请求
      var results = await DeviceApi.fuzzySearchDrones(textEditingValue.text);
      return results;
    });
  2. 加载状态提示 :添加加载指示器提升用户体验

    dart 复制代码
    // 在optionsViewBuilder中
    child: options.isEmpty 
      ? const Center(child: CircularProgressIndicator()) 
      : _buildRecentSearches(options, onSelected)
  3. 空状态处理 :为无结果的情况添加友好提示

    dart 复制代码
    // 在optionsBuilder中
    if (results.isEmpty) {
      return ['无相关结果'];  // 或使用Widget返回空状态UI
    }
  4. 键盘操作支持 :增强键盘导航功能

    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 组件,我们成功实现了一个功能丰富、用户体验良好的智能搜索框,结合了本地历史记录提示和远程模糊搜索功能。该方案不仅提供了良好的视觉反馈,还通过优先显示历史记录优化了性能,减少了不必要的网络请求。开发者可以根据实际需求进一步调整样式和逻辑,使其适应更多应用场景。

💡 提示:完整代码示例和更多实现细节可参考项目仓库。在实际项目中,建议将搜索相关逻辑封装为独立的组件或服务,便于复用和维护。

相关推荐
一起养小猫13 小时前
Flutter for OpenHarmony 实战:扫雷游戏完整开发指南
flutter·harmonyos
晚烛13 小时前
CANN 赋能智慧医疗:构建合规、高效、可靠的医学影像 AI 推理系统
人工智能·flutter·零售
晚霞的不甘13 小时前
揭秘 CANN 内存管理:如何让大模型在小设备上“轻装上阵”?
前端·数据库·经验分享·flutter·3d
小哥Mark15 小时前
Flutter开发鸿蒙年味 + 实用实战应用|绿色烟花:电子烟花 + 手持烟花
flutter·华为·harmonyos
一只大侠的侠16 小时前
Flutter开源鸿蒙跨平台训练营 Day 3
flutter·开源·harmonyos
一只大侠的侠17 小时前
【Harmonyos】Flutter开源鸿蒙跨平台训练营 Day 2 鸿蒙跨平台开发环境搭建与工程实践
flutter·开源·harmonyos
微祎_18 小时前
Flutter for OpenHarmony:构建一个 Flutter 平衡球游戏,深入解析动画控制器、实时物理模拟与手势驱动交互
flutter·游戏·交互
ZH154558913119 小时前
Flutter for OpenHarmony Python学习助手实战:面向对象编程实战的实现
python·学习·flutter
renke336420 小时前
Flutter for OpenHarmony:构建一个 Flutter 色彩调和师游戏,RGB 空间探索、感知色差计算与视觉认知训练的工程实现
flutter·游戏
王码码203520 小时前
Flutter for OpenHarmony 实战之基础组件:第三十一篇 Chip 系列组件 — 灵活的标签化交互
android·flutter·交互·harmonyos