在这里插入图片描述

前言

搜索结果页面是一个独立的结果展示页,跟搜索页面里的实时搜索结果不太一样。这个页面主要用在从其他入口(比如热门搜索标签)跳转过来的场景,进来就直接显示搜索结果,不用再输入。

本文将详细介绍如何在Flutter for OpenHarmony环境下实现一个完整的搜索结果页面,包括路由参数传递、搜索逻辑实现、空结果处理、结果列表展示以及分类标签的颜色设计等核心技术点。

技术要点概览

在开始实现之前,让我们先了解本页面涉及的核心技术点:

  • GetX路由传参:通过Get.arguments接收搜索关键词
  • ListView.builder:高效的列表渲染
  • 空结果设计:友好的无结果提示
  • 颜色映射:根据垃圾类型显示对应颜色标签
  • 搜索算法:关键词匹配的实现策略
  • Material Design:Card和ListTile的组合使用

接收搜索关键词

页面通过路由参数接收要搜索的关键词,这是GetX路由传参的标准方式:

class SearchResultPage extends StatelessWidget {
  const SearchResultPage({super.key});

  
  Widget build(BuildContext context) {
    // 从路由参数获取搜索关键词
    // 如果没有传参,使用空字符串作为默认值
    final String keyword = Get.arguments ?? '';
    // 执行搜索,获取匹配的结果列表
    final results = GarbageData.searchItems(keyword);

路由跳转方式

Get.arguments是GetX路由传参的方式,跳转的时候这样写:

// 方式1:使用命名路由
Get.toNamed(Routes.searchResult, arguments: '塑料瓶');

// 方式2:使用Get.to
Get.to(() => const SearchResultPage(), arguments: '塑料瓶');

// 方式3:传递复杂参数
Get.toNamed(Routes.searchResult, arguments: {
  'keyword': '塑料瓶',
  'category': 'recyclable',
});

拿到关键词后直接调用GarbageData.searchItems进行搜索。这个方法是我们封装好的,传入关键词返回匹配的垃圾列表。

为啥不用Controller:这个页面比较简单,就是展示一下搜索结果,没有复杂的状态管理需求。直接在build方法里搜索就行,不用专门搞个Controller。这种设计遵循了"简单问题简单解决"的原则。

何时需要Controller

虽然这个页面不需要Controller,但以下情况建议使用:

  1. 需要分页加载:结果数量很大,需要分页
  2. 需要筛选排序:用户可以对结果进行筛选或排序
  3. 需要缓存结果:避免重复搜索
  4. 需要状态管理:如加载状态、错误状态等
// 如果需要更复杂的功能,可以使用Controller
class SearchResultController extends GetxController {
  final keyword = ''.obs;
  final results = <GarbageItem>[].obs;
  final isLoading = false.obs;
  final errorMessage = ''.obs;
  
  
  void onInit() {
    super.onInit();
    keyword.value = Get.arguments ?? '';
    search();
  }
  
  Future<void> search() async {
    isLoading.value = true;
    errorMessage.value = '';
    try {
      results.value = await GarbageData.searchItems(keyword.value);
    } catch (e) {
      errorMessage.value = '搜索失败,请重试';
    } finally {
      isLoading.value = false;
    }
  }
}

AppBar显示搜索词

让用户知道当前搜的是什么,这是良好用户体验的基本要求:

    return Scaffold(
      appBar: AppBar(
        title: Text('搜索: $keyword'),
        // 可以添加更多操作按钮
        actions: [
          // 重新搜索按钮
          IconButton(
            icon: const Icon(Icons.search),
            onPressed: () => Get.toNamed(Routes.search),
          ),
        ],
      ),

标题直接显示"搜索: xxx",简单明了。用户一眼就能知道当前页面展示的是什么关键词的搜索结果。

AppBar增强设计

可以在AppBar中添加更多功能:

AppBar(
  title: Text('搜索: $keyword'),
  actions: [
    // 结果数量提示
    Center(
      child: Padding(
        padding: EdgeInsets.only(right: 8.w),
        child: Text(
          '${results.length}个结果',
          style: TextStyle(fontSize: 14.sp),
        ),
      ),
    ),
    // 筛选按钮
    IconButton(
      icon: const Icon(Icons.filter_list),
      onPressed: () => _showFilterDialog(context),
    ),
  ],
)

空结果的处理

搜不到东西的时候,得给用户一个友好的提示:

      body: results.isEmpty
          ? Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Icon(Icons.search_off, size: 64.sp, color: Colors.grey),
                  SizedBox(height: 16.h),
                  Text('未找到相关结果', style: TextStyle(fontSize: 16.sp, color: Colors.grey)),
                  SizedBox(height: 8.h),
                  Text('试试其他关键词', style: TextStyle(fontSize: 14.sp, color: Colors.grey)),
                ],
              ),
            )

空结果设计原则

用了Icons.search_off这个图标,一个带斜杠的放大镜,表示"搜不到"。下面两行文字,一行说明情况,一行给建议,比光秃秃一个空白页面好多了。

设计原则:空结果页面应该做到三点:

  1. 说明情况:告诉用户没有找到结果
  2. 给出建议:提示用户可以尝试其他关键词
  3. 提供操作:可以添加按钮让用户快速返回搜索

增强版空结果设计

Widget _buildEmptyResult(String keyword) {
  return Center(
    child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        // 使用更大的图标
        Icon(Icons.search_off, size: 80.sp, color: Colors.grey.shade300),
        SizedBox(height: 24.h),
        Text(
          '未找到"$keyword"相关结果',
          style: TextStyle(
            fontSize: 18.sp,
            fontWeight: FontWeight.w500,
            color: Colors.grey.shade600,
          ),
        ),
        SizedBox(height: 8.h),
        Text(
          '请尝试以下操作:',
          style: TextStyle(fontSize: 14.sp, color: Colors.grey.shade400),
        ),
        SizedBox(height: 16.h),
        // 建议列表
        _buildSuggestionItem('检查关键词是否有错别字'),
        _buildSuggestionItem('尝试使用更简短的关键词'),
        _buildSuggestionItem('尝试使用物品的别名'),
        SizedBox(height: 24.h),
        // 操作按钮
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            OutlinedButton.icon(
              onPressed: () => Get.back(),
              icon: const Icon(Icons.arrow_back),
              label: const Text('返回'),
            ),
            SizedBox(width: 16.w),
            ElevatedButton.icon(
              onPressed: () => Get.toNamed(Routes.search),
              icon: const Icon(Icons.search),
              label: const Text('重新搜索'),
            ),
          ],
        ),
      ],
    ),
  );
}

Widget _buildSuggestionItem(String text) {
  return Padding(
    padding: EdgeInsets.symmetric(vertical: 4.h),
    child: Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        Icon(Icons.lightbulb_outline, size: 16.sp, color: Colors.amber),
        SizedBox(width: 8.w),
        Text(text, style: TextStyle(fontSize: 14.sp, color: Colors.grey)),
      ],
    ),
  );
}

结果列表的展示

有结果时用ListView展示:

          : ListView.builder(
              padding: EdgeInsets.all(16.w),
              itemCount: results.length,
              itemBuilder: (context, index) {
                final item = results[index];
                return Card(
                  margin: EdgeInsets.only(bottom: 8.h),
                  child: ListTile(
                    leading: Text(item.icon, style: TextStyle(fontSize: 28.sp)),
                    title: Text(item.name),
                    subtitle: Text(item.description),

列表项内容说明

每个结果项显示:

  • 图标:emoji表情,直观易识别
  • 名称:垃圾的名字,主要信息
  • 描述:简短的说明,辅助信息

关键词高亮显示

为了让用户更容易找到匹配的内容,可以对关键词进行高亮显示:

Widget _buildHighlightedText(String text, String keyword) {
  if (keyword.isEmpty) {
    return Text(text);
  }
  
  final spans = <TextSpan>[];
  final lowerText = text.toLowerCase();
  final lowerKeyword = keyword.toLowerCase();
  
  int start = 0;
  int index = lowerText.indexOf(lowerKeyword);
  
  while (index != -1) {
    // 添加匹配前的文本
    if (index > start) {
      spans.add(TextSpan(text: text.substring(start, index)));
    }
    // 添加高亮的匹配文本
    spans.add(TextSpan(
      text: text.substring(index, index + keyword.length),
      style: TextStyle(
        color: AppTheme.primaryColor,
        fontWeight: FontWeight.bold,
        backgroundColor: AppTheme.primaryColor.withOpacity(0.1),
      ),
    ));
    start = index + keyword.length;
    index = lowerText.indexOf(lowerKeyword, start);
  }
  
  // 添加剩余文本
  if (start < text.length) {
    spans.add(TextSpan(text: text.substring(start)));
  }
  
  return RichText(
    text: TextSpan(
      style: TextStyle(fontSize: 14.sp, color: Colors.black),
      children: spans,
    ),
  );
}

分类标签的设计

列表项右边显示分类标签,用对应的颜色:

                    trailing: Container(
                      padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h),
                      decoration: BoxDecoration(
                        color: _getTypeColor(item.type).withOpacity(0.2),
                        borderRadius: BorderRadius.circular(4.r),
                      ),
                      child: Text(
                        item.typeName,
                        style: TextStyle(fontSize: 12.sp, color: _getTypeColor(item.type)),
                      ),
                    ),
                    onTap: () => Get.toNamed(Routes.itemDetail, arguments: item),
                  ),
                );
              },
            ),
    );
  }

颜色设计原则

颜色设计:标签背景用分类颜色的20%透明度版本,文字用分类颜色。这样既能区分不同分类,又不会太刺眼。可回收物是蓝色标签,有害垃圾是红色标签,一眼就能看出来。

点击结果跳转到物品详情页,把完整的物品对象传过去。

标签组件封装

可以将标签封装为独立组件,方便复用:

class GarbageTypeTag extends StatelessWidget {
  final GarbageType type;
  final String typeName;
  
  const GarbageTypeTag({
    super.key,
    required this.type,
    required this.typeName,
  });
  
  
  Widget build(BuildContext context) {
    final color = _getTypeColor(type);
    return Container(
      padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h),
      decoration: BoxDecoration(
        color: color.withOpacity(0.2),
        borderRadius: BorderRadius.circular(4.r),
        border: Border.all(color: color.withOpacity(0.3)),
      ),
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          // 可以添加小图标
          Container(
            width: 8.w,
            height: 8.w,
            decoration: BoxDecoration(
              color: color,
              shape: BoxShape.circle,
            ),
          ),
          SizedBox(width: 4.w),
          Text(
            typeName,
            style: TextStyle(
              fontSize: 12.sp,
              color: color,
              fontWeight: FontWeight.w500,
            ),
          ),
        ],
      ),
    );
  }
  
  Color _getTypeColor(GarbageType type) {
    switch (type) {
      case GarbageType.recyclable:
        return AppTheme.recyclableColor;
      case GarbageType.hazardous:
        return AppTheme.hazardousColor;
      case GarbageType.kitchen:
        return AppTheme.kitchenColor;
      default:
        return AppTheme.otherColor;
    }
  }
}

颜色映射方法

根据垃圾类型返回对应颜色:

  Color _getTypeColor(type) {
    switch (type.toString()) {
      case 'GarbageType.recyclable':
        return AppTheme.recyclableColor;
      case 'GarbageType.hazardous':
        return AppTheme.hazardousColor;
      case 'GarbageType.kitchen':
        return AppTheme.kitchenColor;
      default:
        return AppTheme.otherColor;
    }
  }
}

四种垃圾分类颜色

四种类型四种颜色:

  • 可回收物 → 蓝色(#1E88E5)
  • 有害垃圾 → 红色(#E53935)
  • 厨余垃圾 → 绿色(#43A047)
  • 其他垃圾 → 灰色(#757575)

这个配色跟现实中垃圾桶的颜色一致,用户不用学就能记住。这种设计遵循了"符合用户心智模型"的原则。

颜色常量定义

建议在主题文件中统一定义颜色常量:

class AppTheme {
  // 垃圾分类颜色
  static const Color recyclableColor = Color(0xFF1E88E5);  // 蓝色 - 可回收物
  static const Color hazardousColor = Color(0xFFE53935);   // 红色 - 有害垃圾
  static const Color kitchenColor = Color(0xFF43A047);     // 绿色 - 厨余垃圾
  static const Color otherColor = Color(0xFF757575);       // 灰色 - 其他垃圾
  
  // 获取颜色的便捷方法
  static Color getGarbageTypeColor(GarbageType type) {
    switch (type) {
      case GarbageType.recyclable:
        return recyclableColor;
      case GarbageType.hazardous:
        return hazardousColor;
      case GarbageType.kitchen:
        return kitchenColor;
      default:
        return otherColor;
    }
  }
}

搜索逻辑的实现

GarbageData.searchItems方法是这样实现的:

class GarbageData {
  static List<GarbageItem> searchItems(String keyword) {
    // 空关键词返回空列表
    if (keyword.isEmpty) return [];
    
    // 转换为小写进行不区分大小写的搜索
    final lowerKeyword = keyword.toLowerCase();
    
    return allItems.where((item) => 
      item.name.toLowerCase().contains(lowerKeyword) || 
      item.description.toLowerCase().contains(lowerKeyword)
    ).toList();
  }
}

搜索会同时匹配名称和描述。比如用户搜"瓶",能找到"塑料瓶"、“玻璃瓶”;搜"饮料",也能找到描述里包含"饮料"的物品。

搜索策略:这里用的是简单的包含匹配。如果数据量大或者需要更智能的搜索(比如拼音搜索、模糊匹配),可以考虑用专门的搜索库或者后端搜索服务。

增强版搜索实现

class GarbageData {
  /// 增强版搜索方法
  /// 支持多关键词、别名匹配、相关度排序
  static List<GarbageItem> searchItemsAdvanced(String keyword) {
    if (keyword.isEmpty) return [];
    
    final lowerKeyword = keyword.toLowerCase().trim();
    final keywords = lowerKeyword.split(' ').where((k) => k.isNotEmpty).toList();
    
    // 计算每个物品的匹配分数
    final scoredItems = <MapEntry<GarbageItem, int>>[];
    
    for (var item in allItems) {
      int score = 0;
      
      for (var kw in keywords) {
        // 名称完全匹配,高分
        if (item.name.toLowerCase() == kw) {
          score += 100;
        }
        // 名称包含关键词
        else if (item.name.toLowerCase().contains(kw)) {
          score += 50;
        }
        // 别名匹配
        if (item.aliases.any((alias) => alias.toLowerCase().contains(kw))) {
          score += 30;
        }
        // 描述包含关键词
        if (item.description.toLowerCase().contains(kw)) {
          score += 10;
        }
      }
      
      if (score > 0) {
        scoredItems.add(MapEntry(item, score));
      }
    }
    
    // 按分数降序排序
    scoredItems.sort((a, b) => b.value.compareTo(a.value));
    
    return scoredItems.map((e) => e.key).toList();
  }
  
  /// 拼音搜索支持
  static List<GarbageItem> searchByPinyin(String keyword) {
    if (keyword.isEmpty) return [];
    
    final lowerKeyword = keyword.toLowerCase();
    
    return allItems.where((item) {
      // 匹配名称
      if (item.name.toLowerCase().contains(lowerKeyword)) return true;
      // 匹配拼音全拼
      if (item.pinyinFull.contains(lowerKeyword)) return true;
      // 匹配拼音首字母
      if (item.pinyinInitials.contains(lowerKeyword)) return true;
      return false;
    }).toList();
  }
}

这个页面的使用场景

搜索结果页面主要用在这些场景:

1. 热门搜索

用户点击热门标签,直接跳到这个页面显示结果:

// 热门搜索标签点击
GestureDetector(
  onTap: () => Get.toNamed(Routes.searchResult, arguments: '塑料瓶'),
  child: Chip(label: Text('塑料瓶')),
)

2. 推荐搜索

App推荐某个关键词,用户点击后进入:

// 推荐搜索卡片点击
Card(
  child: ListTile(
    title: Text('今日推荐:电池'),
    onTap: () => Get.toNamed(Routes.searchResult, arguments: '电池'),
  ),
)

3. 外部跳转

从其他App或者网页跳转过来搜索:

// 处理深度链接
void handleDeepLink(Uri uri) {
  if (uri.path == '/search') {
    final keyword = uri.queryParameters['keyword'];
    if (keyword != null) {
      Get.toNamed(Routes.searchResult, arguments: keyword);
    }
  }
}

与搜索页面的区别

跟搜索页面的实时搜索相比,这个页面更适合"一次性"的搜索场景,进来就看结果,不需要再输入或修改关键词。

特性 搜索页面 搜索结果页面
输入框 有,可编辑
实时搜索 支持 不支持
搜索历史 显示 不显示
使用场景 主动搜索 跳转搜索

性能优化建议

1. 使用const构造函数

const Icon(Icons.search_off, size: 64, color: Colors.grey)
const Icon(Icons.arrow_forward_ios, size: 16)

2. 列表项使用Key

return Card(
  key: ValueKey(item.id),
  // ...
);

3. 搜索结果缓存

class SearchCache {
  static final Map<String, List<GarbageItem>> _cache = {};
  
  static List<GarbageItem> search(String keyword) {
    if (_cache.containsKey(keyword)) {
      return _cache[keyword]!;
    }
    final results = GarbageData.searchItems(keyword);
    _cache[keyword] = results;
    return results;
  }
  
  static void clear() {
    _cache.clear();
  }
}

4. 使用RepaintBoundary

RepaintBoundary(
  child: Card(
    // 复杂的卡片内容
  ),
)

可扩展方向

1. 筛选功能

按垃圾类型筛选结果:

final selectedTypes = <GarbageType>{}.obs;

List<GarbageItem> get filteredResults {
  if (selectedTypes.isEmpty) return results;
  return results.where((item) => selectedTypes.contains(item.type)).toList();
}

2. 排序功能

按名称、相关度排序:

enum SortType { relevance, name, type }

final sortType = SortType.relevance.obs;

List<GarbageItem> get sortedResults {
  final list = List<GarbageItem>.from(filteredResults);
  switch (sortType.value) {
    case SortType.name:
      list.sort((a, b) => a.name.compareTo(b.name));
      break;
    case SortType.type:
      list.sort((a, b) => a.typeName.compareTo(b.typeName));
      break;
    default:
      break;
  }
  return list;
}

3. 分页加载

结果数量很大时使用分页:

class SearchResultController extends GetxController {
  final results = <GarbageItem>[].obs;
  final isLoading = false.obs;
  final hasMore = true.obs;
  int _page = 1;
  final int _pageSize = 20;
  
  Future<void> loadMore() async {
    if (isLoading.value || !hasMore.value) return;
    
    isLoading.value = true;
    final newItems = await _searchPage(_page, _pageSize);
    if (newItems.length < _pageSize) {
      hasMore.value = false;
    }
    results.addAll(newItems);
    _page++;
    isLoading.value = false;
  }
}

总结

搜索结果页面是一个简单但重要的页面,它为用户提供了快速查看搜索结果的入口。本文介绍的实现方案包括:

  1. 路由参数传递:使用GetX的Get.arguments接收搜索关键词
  2. 空结果处理:友好的无结果提示和建议
  3. 结果列表展示:使用ListView.builder高效渲染
  4. 分类标签设计:颜色与垃圾分类标准一致
  5. 搜索逻辑实现:支持名称和描述的匹配

通过合理的页面设计和良好的用户体验,搜索结果页面可以帮助用户快速找到所需的垃圾分类信息。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐