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

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

相关推荐
m0_738185825 分钟前
Flutter 鸿蒙化实战:foundation_fluttify 适配 OpenHarmony,Fluttify 桥接层
flutter·华为·harmonyos·鸿蒙
m0_7381858219 分钟前
Flutter 鸿蒙化实战:http_proxy 适配 OpenHarmony,HTTP 代理
flutter·http·华为·harmonyos·鸿蒙
m0_7381858235 分钟前
Flutter 鸿蒙化实战:headset_connection_event 适配 OpenHarmony,耳机插拔监听
flutter·华为·harmonyos·鸿蒙
恋猫de小郭1 天前
Android CLI 支持 AI Agent 通过 Device Streaming 调试云真机
android·前端·flutter
m0_738185822 天前
Flutter 鸿蒙化实战:flutter_scankit 适配 OpenHarmony,华为 ScanKit 扫码
flutter·华为·harmonyos·鸿蒙
事圆则缓2 天前
Flutter 开发鸿蒙实战:从 OpenHarmony 适配版到 HAP 构建与插件接入
flutter·华为·harmonyos
天空之城--2 天前
Android一周动态:Android 18首次官宣、Compose Material3 1.4转正(5趋势+5资讯)
android·人工智能·flutter·架构·android jetpack
恋猫de小郭2 天前
Meta 分享怎么用 AI 迁移 Compose 项目不烧心
android·前端·flutter
EatFan2 天前
Flutter 鸿蒙适配实践 2026:从环境搭建、混合渲染到 OpenTelemetry 性能基建
flutter·华为·harmonyos·openharmony·opentelemetry·鸿蒙适配·impeller
m0_738185822 天前
Flutter 鸿蒙化实战:flutter_secure_storage 适配 OpenHarmony,敏感数据安全存储
安全·flutter·华为·harmonyos·鸿蒙