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

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

相关推荐
lbb 小魔仙2 小时前
【Harmonyos】开源鸿蒙跨平台训练营DAY8:Flutter鸿蒙开发指南获取轮播图数据
flutter·华为·harmonyos
2501_944396193 小时前
Flutter for OpenHarmony 视力保护提醒App实战 - 性能优化技巧
android·flutter·性能优化
子春一3 小时前
Flutter for OpenHarmony:构建一个专业级 Flutter 节拍器,深入解析定时器、状态同步与音乐节奏交互设计
javascript·flutter·交互
kirk_wang3 小时前
Flutter艺术探索-Flutter插件开发:自定义Plugin实战指南
flutter·移动开发·flutter教程·移动开发教程
向哆哆3 小时前
跨端开发实践:Flutter × OpenHarmony 构建垃圾回收分类知识区域
flutter·开源·鸿蒙·openharmony
kirk_wang3 小时前
Flutter艺术探索-EventChannel使用:原生事件流与Flutter交互
flutter·移动开发·flutter教程·移动开发教程
晚霞的不甘4 小时前
Flutter for OpenHarmony《智慧字典》中的沉浸式学习:成语测试与填空练习等功能详解
学习·flutter·ui·信息可视化·前端框架·鸿蒙
花卷HJ4 小时前
Flutter加载弹窗使用问题及解决方案
flutter
ujainu4 小时前
#Flutter + OpenHarmony高保真秒表 App 实现:主副表盘联动、计次记录与主题适配全解析
flutter