鸿蒙Flutter实战:自定义SearchDelegate应用内搜索

前言

几十条备忘录中找一条特定的记录,靠滚动列表是不够的。搜索功能是信息管理类应用的标配,而 Flutter Material 组件库内置了一套完整的搜索交互框架------SearchDelegate

SearchDelegate 是什么?它是一个可以直接跳转到的"搜索页面",自带搜索输入框、返回按钮、清除按钮,以及搜索建议和搜索结果的两个显示区域。开发者只需继承它并实现 4 个方法,就能获得一整套搜索体验。

鸿蒙 Flutter 备忘录应用使用 SearchDelegate 实现了备忘录的标题+内容全文搜索,支持中文搜索和实时结果更新。

项目仓库:todo_flutter_harmony

SearchDelegate 生命周期

当用户点击搜索按钮,调用 showSearch 后:

dart 复制代码
showSearch(context: context, delegate: MemoSearchDelegate());

Flutter 会执行以下流程:

  1. 顶部出现搜索输入框(带有返回箭头和清除按钮)
  2. 键盘自动弹出,输入框获得焦点
  3. 每当用户输入字符 → buildSuggestions 被调用
  4. 用户点击搜索/按回车 → buildResults 被调用
  5. 用户点击返回 → delegate 被关闭

必须实现的 4 个方法

dart 复制代码
class MemoSearchDelegate extends SearchDelegate<String> {
  // 泛型参数 String 表示搜索结果返回值的类型

  @override
  String get searchFieldLabel => '搜索备忘录...';

  // 1. 搜索框左侧的动作按钮(返回/汉堡菜单)
  @override
  List<Widget> buildActions(BuildContext context) { ... }

  // 2. 搜索框左侧的返回按钮
  @override
  Widget? buildLeading(BuildContext context) { ... }

  // 3. 用户输入过程中的搜索建议
  @override
  Widget buildSuggestions(BuildContext context) { ... }

  // 4. 用户提交搜索后的搜索结果
  @override
  Widget buildResults(BuildContext context) { ... }
}

完整实现

buildLeading:返回按钮

dart 复制代码
@override
Widget? buildLeading(BuildContext context) {
  return IconButton(
    icon: const Icon(Icons.arrow_back),
    onPressed: () {
      if (query.isEmpty) {
        close(context, '');  // 无输入内容,直接关闭
      } else {
        query = '';          // 有输入内容,先清空再显示建议
        showSuggestions(context);
      }
    },
  );
}

这个小细节很重要:如果用户已经有输入内容,点返回应该先清空搜索词(相当于"撤销搜索"),再点一次才是关闭搜索页。

buildActions:清除按钮

dart 复制代码
@override
List<Widget> buildActions(BuildContext context) {
  if (query.isEmpty) return [];

  return [
    IconButton(
      icon: const Icon(Icons.clear),
      onPressed: () {
        query = '';
        showSuggestions(context);
      },
    ),
  ];
}

query 为空时不显示任何按钮,保持搜索框右侧干净。

buildSuggestions:输入过程中的建议

dart 复制代码
@override
Widget buildSuggestions(BuildContext context) {
  final provider = context.read<MemoProvider>();

  // 实时过滤
  final suggestions = query.isEmpty
      ? provider.memos  // 空查询显示全部
      : provider.memos.where((memo) {
          final q = query.toLowerCase();
          return memo.title.toLowerCase().contains(q) ||
                 memo.content.toLowerCase().contains(q);
        }).toList();

  if (suggestions.isEmpty && query.isNotEmpty) {
    return Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Icon(Icons.search_off, size: 64, color: Colors.grey.shade300),
          const SizedBox(height: 12),
          Text(
            '未找到 "$query" 相关备忘录',
            style: TextStyle(color: Colors.grey.shade500),
          ),
        ],
      ),
    );
  }

  return ListView.builder(
    itemCount: suggestions.length,
    itemBuilder: (context, index) {
      final memo = suggestions[index];
      return ListTile(
        leading: Icon(
          memo.isPinned ? Icons.push_pin : Icons.note_outlined,
          color: const Color(0xFF4DB6AC),
        ),
        title: _buildHighlightedText(memo.title, query),
        subtitle: _buildHighlightedText(
          memo.content.length > 80
              ? '${memo.content.substring(0, 80)}...'
              : memo.content,
          query,
        ),
        trailing: Text(
          _formatDate(memo.createdAt),
          style: TextStyle(fontSize: 12, color: Colors.grey.shade400),
        ),
        onTap: () {
          close(context, '');  // 关闭搜索,返回结果
          Navigator.pushNamed(context, '/memo/edit', arguments: memo.id);
        },
      );
    },
  );
}

buildResults:提交后的搜索结果

大部分场景中,buildResultsbuildSuggestions 的内容是一样的------用户每输入一个字都实时过滤,不需要等到"提交"才显示结果。所以可以直接复用:

dart 复制代码
@override
Widget buildResults(BuildContext context) {
  return buildSuggestions(context);
}

搜索关键词高亮

搜索结果中高亮显示匹配的关键词,让用户一眼看到为什么这条结果被匹配:

dart 复制代码
Widget _buildHighlightedText(String text, String query) {
  if (query.isEmpty) return Text(text);

  final lowerText = text.toLowerCase();
  final lowerQuery = query.toLowerCase();
  final startIndex = lowerText.indexOf(lowerQuery);

  if (startIndex == -1) return Text(text);

  final endIndex = startIndex + query.length;

  return RichText(
    maxLines: 2,
    overflow: TextOverflow.ellipsis,
    text: TextSpan(
      style: DefaultTextStyle.of(context).style,
      children: [
        if (startIndex > 0)
          TextSpan(text: text.substring(0, startIndex)),
        TextSpan(
          text: text.substring(startIndex, endIndex),
          style: TextStyle(
            backgroundColor: const Color(0xFF4DB6AC).withOpacity(0.3),
            fontWeight: FontWeight.bold,
            color: const Color(0xFF00796B),
          ),
        ),
        if (endIndex < text.length)
          TextSpan(text: text.substring(endIndex)),
      ],
    ),
  );
}

在 AppBar 中触发搜索

在备忘录列表页的 AppBar 中添加搜索图标:

dart 复制代码
AppBar(
  title: const Text('备忘录'),
  actions: [
    IconButton(
      icon: const Icon(Icons.search),
      onPressed: () {
        showSearch(
          context: context,
          delegate: MemoSearchDelegate(),
        );
      },
    ),
    // 更多操作...
  ],
)

showSearch 是 Flutter Material 库提供的全局方法,它会:

  1. 在当前页面之上覆蓋一个新路由
  2. 渲染 SearchDelegate 定义的搜索界面
  3. 关闭时返回 delegate 中 close(context, result) 的 result 值

搜索历史记录

搜索体验的进阶功能------保存用户的搜索历史,下次打开搜索时优先展示:

dart 复制代码
class SearchHistory {
  static const _key = 'search_history';
  static const _maxItems = 10;

  static Future<List<String>> getHistory() async {
    final prefs = await SharedPreferences.getInstance();
    return prefs.getStringList(_key) ?? [];
  }

  static Future<void> addSearch(String query) async {
    if (query.trim().isEmpty) return;
    final prefs = await SharedPreferences.getInstance();
    final history = await getHistory();
    history.remove(query);     // 去重
    history.insert(0, query);  // 最新搜索排第一
    if (history.length > _maxItems) {
      history.removeLast();    // 最多保留 10 条
    }
    await prefs.setStringList(_key, history);
  }

  static Future<void> clearHistory() async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.remove(_key);
  }
}

buildSuggestions 中,当 query 为空时显示搜索历史:

dart 复制代码
@override
Widget buildSuggestions(BuildContext context) {
  if (query.isEmpty) {
    return _buildSearchHistory();
  }
  // ... 实时搜索逻辑
}

Widget _buildSearchHistory() {
  return FutureBuilder<List<String>>(
    future: SearchHistory.getHistory(),
    builder: (context, snapshot) {
      final history = snapshot.data ?? [];
      if (history.isEmpty) {
        return Center(
          child: Text('输入关键词搜索', style: TextStyle(color: Colors.grey.shade400)),
        );
      }
      return Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Padding(
            padding: const EdgeInsets.all(16),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                const Text('搜索历史', style: TextStyle(fontWeight: FontWeight.w600)),
                GestureDetector(
                  onTap: () => SearchHistory.clearHistory(),
                  child: Text('清空', style: TextStyle(color: Colors.grey.shade500, fontSize: 13)),
                ),
              ],
            ),
          ),
          ...history.map((term) => ListTile(
            leading: const Icon(Icons.history, size: 20),
            title: Text(term),
            onTap: () {
              query = term;
              showResults(context);
            },
          )),
        ],
      );
    },
  );
}

鸿蒙兼容性

SearchDelegate 是 Flutter Material 库的内置类,完全在框架层实现。所有搜索交互(输入框弹出、键盘唤起、页面转场动画)都由 Flutter 引擎管理,不依赖原生平台搜索组件。在鸿蒙 OHOS 上的表现与 Android/iOS 一致。

SharedPreferences 用于存储搜索历史------在鸿蒙上它底层使用的是 OHOS 的 preferences API(通过 shared_preferences 包适配),需要注意检查 shared_preferences 是否对鸿蒙有良好支持。如果不确定,可以改用项目自带的 JSON 文件存储方案。

总结

Flutter SearchDelegate 为我们处理了搜索体验中最复杂的部分(键盘管理、页面转场、输入焦点),开发者只需专注于数据和 UI:

  1. buildLeading / buildActions:定义搜索框左右两侧的按钮
  2. buildSuggestions:处理输入过程中的实时结果
  3. buildResults:处理提交后的最终结果
  4. 关键词高亮RichText + TextSpan 组合实现匹配文本的高亮标记

整个搜索体验大约 150 行代码,却提供了媲美原生应用的搜索交互。

完整项目代码见:todo_flutter_harmony

相关推荐
韩曙亮13 小时前
【错误记录】Flutter 编译 Android APK 文件安装包报错 ( 国内镜像源设置 )
android·flutter
李宏伟~13 小时前
flutter实现支付宝支付
flutter
云_杰13 小时前
鸿蒙中实现果壳风格液态TabBar
harmonyos·ui kit
FrameNotWork13 小时前
HarmonyOS 新手引导扫光动画实现:打造炫酷的首次体验
华为·交互·harmonyos
●VON13 小时前
鸿蒙Flutter实战:待办事项三态筛选器
flutter·华为·harmonyos·鸿蒙
李宏伟~14 小时前
flutter实现直播推流端
flutter
●VON14 小时前
鸿蒙Flutter实战:多选批量删除模式的实现
flutter·华为·harmonyos·鸿蒙
枫叶丹414 小时前
【HarmonyOS 6.0】Live View Kit 实况支持显示夕阳和赏月背景的技术解读与实践
开发语言·华为·harmonyos
不羁的木木14 小时前
ArkUI实战演练03-常用组件与布局实战
harmonyos