在这里插入图片描述

搜索功能可以说是这个垃圾分类App的核心了。用户不知道某个东西该扔哪个桶,打开App搜一下就知道了。所以这块的体验必须做好,响应要快,结果要准。

搜索状态管理

搜索涉及到的状态挺多的:输入的关键词、搜索结果、是否正在搜索、搜索历史。我们用GetX的响应式变量来管理:

class SearchPageController extends GetxController {
  // 当前搜索的关键词
  final searchText = ''.obs;
  
  // 搜索结果列表
  final searchResults = <GarbageItem>[].obs;
  
  // 是否正在搜索(用于控制显示搜索结果还是搜索历史)
  final isSearching = false.obs;
  
  // 搜索历史(关键词列表)
  final searchHistory = <String>[].obs;

为啥用.obs:加了.obs的变量就变成响应式的了,值一变,界面上用Obx包着的地方就会自动刷新。不用手动调setState,代码会简洁很多。

.obs是GetX提供的响应式扩展,它的原理是:

  1. 创建一个Rx<T>类型的包装对象
  2. 当你读取.value时,GetX会记录哪个Obx在使用这个值
  3. 当你修改.value时,GetX会通知所有使用这个值的Obx重建

这种机制比Flutter原生的setState更精准,因为它只会重建真正依赖这个值的Widget,而不是整个页面。

搜索方法的实现:

  /// 执行搜索
  /// [keyword] 搜索关键词
  void search(String keyword) {
    // 更新搜索关键词
    searchText.value = keyword;
    
    // 如果关键词为空,清空结果并退出搜索状态
    if (keyword.isEmpty) {
      searchResults.clear();
      isSearching.value = false;
      return;
    }
    
    // 进入搜索状态
    isSearching.value = true;
    
    // 调用数据层的搜索方法
    searchResults.value = GarbageData.searchItems(keyword);
  }

逻辑很简单:关键词为空就清空结果,不为空就去数据里搜。GarbageData.searchItems是我们封装的搜索方法,后面会讲到。

这个方法的设计遵循了几个原则:

  1. 单一职责:只负责搜索,不处理UI
  2. 防御性编程:先检查空值
  3. 状态同步:搜索状态和结果同时更新

搜索历史的处理

用户搜过的关键词要记下来,方便下次快速搜索:

  /// 添加关键词到搜索历史
  /// [keyword] 要添加的关键词
  void addToHistory(String keyword) {
    // 空关键词不添加
    if (keyword.isEmpty) return;
    
    // 如果已存在,先删除旧的(避免重复)
    searchHistory.removeWhere((h) => h == keyword);
    
    // 插入到列表最前面(最近搜的排第一)
    searchHistory.insert(0, keyword);
    
    // 限制历史记录数量,最多20条
    if (searchHistory.length > 20) {
      searchHistory.removeLast();
    }
    
    // 持久化存储(可选)
    _saveHistory();
  }

这段代码有几个细节:

  • 先把已存在的相同关键词删掉,再插入到最前面,这样最近搜的永远排第一
  • 限制最多存20条,太多了也没意义,还占空间
  • 可以加上持久化存储,这样App重启后历史记录还在

为什么要先删后加?假设用户的搜索历史是['电池', '塑料瓶', '纸巾'],用户又搜了一次"电池"。如果直接插入,列表会变成['电池', '电池', '塑料瓶', '纸巾'],有重复。先删后加的话,列表会变成['电池', '塑料瓶', '纸巾'],"电池"移到了最前面,没有重复。

清空和删除单条历史:

  /// 清空所有搜索历史
  void clearHistory() {
    searchHistory.clear();
    _saveHistory();
  }

  /// 删除单条搜索历史
  /// [keyword] 要删除的关键词
  void removeFromHistory(String keyword) {
    searchHistory.remove(keyword);
    _saveHistory();
  }
  
  /// 持久化存储搜索历史
  void _saveHistory() {
    // 使用GetStorage或SharedPreferences保存
    final storage = GetStorage();
    storage.write('searchHistory', searchHistory.toList());
  }
  
  /// 加载搜索历史
  void _loadHistory() {
    final storage = GetStorage();
    final history = storage.read<List>('searchHistory');
    if (history != null) {
      searchHistory.value = history.cast<String>();
    }
  }
  
  
  void onInit() {
    super.onInit();
    _loadHistory();  // 控制器初始化时加载历史记录
  }

搜索页面的布局

搜索页面分两部分:上面是输入框,下面根据状态显示搜索结果或者搜索历史:

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

  
  Widget build(BuildContext context) {
    final controller = Get.find<SearchPageController>();
    final homeController = Get.find<HomeController>();

    return Scaffold(
      appBar: AppBar(title: const Text('搜索')),
      body: Column(
        children: [
          // 搜索输入框
          _buildSearchInput(controller),
          // 搜索结果或搜索历史
          Expanded(
            child: Obx(() {
              // 根据搜索状态显示不同内容
              if (controller.isSearching.value) {
                return _buildSearchResults(controller, homeController);
              }
              return _buildSearchHistory(controller);
            }),
          ),
        ],
      ),
    );
  }

Obx的妙用isSearching变了,Obx里面的内容就会重新build。正在搜索就显示结果列表,没在搜索就显示历史记录。这种条件渲染在Flutter里很常见。

页面结构说明:

  • Column把页面分成上下两部分
  • 搜索输入框固定在顶部
  • Expanded让下面的内容占据剩余空间
  • Obx监听isSearching的变化,动态切换显示内容

搜索输入框

输入框要支持实时搜索,用户每输入一个字就触发一次搜索:

Widget _buildSearchInput(SearchPageController controller) {
  return Container(
    margin: EdgeInsets.all(16.w),
    child: TextField(
      // 每次输入变化都触发搜索
      onChanged: controller.search,
      // 用户按回车或点键盘搜索按钮时触发
      onSubmitted: (value) {
        controller.addToHistory(value);
      },

onChanged绑定搜索方法,实现实时搜索。onSubmitted是用户按回车或者点键盘上的搜索按钮时触发,这时候把关键词加到历史记录里。

为什么要区分onChangedonSubmitted

  • onChanged:每次输入变化都触发,用于实时搜索
  • onSubmitted:用户明确表示"我要搜索这个"时触发,用于记录历史

这样设计的好处是:用户输入过程中可以看到实时结果,但只有明确提交的关键词才会被记录到历史。

输入框的装饰:

      decoration: InputDecoration(
        hintText: '输入垃圾名称搜索',
        // 搜索图标
        prefixIcon: const Icon(Icons.search),
        // 清除按钮(有内容时显示)
        suffixIcon: Obx(() => controller.searchText.value.isNotEmpty
            ? IconButton(
                icon: const Icon(Icons.clear),
                onPressed: () {
                  controller.search('');
                  // 同时清空输入框
                  // 需要用TextEditingController来实现
                },
              )
            : const SizedBox()),
        // 输入框样式
        filled: true,
        fillColor: Colors.white,
        border: OutlineInputBorder(
          borderRadius: BorderRadius.circular(24.r),
          borderSide: BorderSide.none,
        ),
        contentPadding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h),
      ),
    ),
  );
}

清除按钮的显示逻辑:输入框有内容时显示清除按钮,没内容时显示空的SizedBox。用Obx包着,searchText变了按钮就会自动显示或隐藏。

关于输入框样式的设计:

  • prefixIcon:前置图标,放搜索图标
  • suffixIcon:后置图标,放清除按钮
  • filled: true:启用填充背景
  • borderSide: BorderSide.none:去掉边框线
  • borderRadius:圆角,让输入框看起来像胶囊

搜索结果的展示

搜索结果用列表展示,每个结果显示图标、名称和分类:

Widget _buildSearchResults(
  SearchPageController controller,
  HomeController homeController,
) {
  // 空结果的处理
  if (controller.searchResults.isEmpty) {
    return 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),
          ),
        ],
      ),
    );
  }

空结果的处理很重要,不能就显示个空白页面,得告诉用户"没找到,换个词试试"。这种设计叫做"空状态设计",是用户体验设计的重要组成部分。

好的空状态设计应该包含:

  1. 视觉元素:图标或插图,让页面不那么单调
  2. 说明文字:告诉用户发生了什么
  3. 引导文字:告诉用户下一步可以做什么

有结果时的列表:

  return ListView.builder(
    padding: EdgeInsets.symmetric(horizontal: 16.w),
    itemCount: controller.searchResults.length,
    itemBuilder: (context, index) {
      final item = controller.searchResults[index];
      return Card(
        margin: EdgeInsets.only(bottom: 8.h),
        child: ListTile(
          // 物品图标(emoji)
          leading: Text(item.icon, style: TextStyle(fontSize: 28.sp)),
          // 物品名称
          title: Text(item.name),
          // 分类名称
          subtitle: Text(item.typeName),

每个结果右边还有个分类标签,用对应的颜色显示:

          // 分类标签
          trailing: Container(
            padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h),
            decoration: BoxDecoration(
              // 分类颜色的20%透明度作为背景
              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: () {
            // 记录到最近搜索
            homeController.addRecentSearch(item);
            // 跳转到详情页
            Get.toNamed(Routes.itemDetail, arguments: item);
          },
        ),
      );
    },
  );
}

点击结果的处理:先把这个物品加到最近搜索记录里,然后跳转到详情页。这样用户在"搜索历史"里就能看到自己查过的东西。

搜索历史的展示

没有在搜索时,显示搜索历史和热门搜索:

Widget _buildSearchHistory(SearchPageController controller) {
  return SingleChildScrollView(
    padding: EdgeInsets.all(16.w),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        // 搜索历史
        if (controller.searchHistory.isNotEmpty) ...[
          _buildHistorySection(controller),
          SizedBox(height: 24.h),
        ],
        // 热门搜索
        _buildHotSearch(controller),
      ],
    ),
  );
}

Widget _buildHistorySection(SearchPageController controller) {
  return Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      // 标题栏
      Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          Text(
            '搜索历史',
            style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.bold),
          ),
          GestureDetector(
            onTap: () => _showClearHistoryDialog(controller),
            child: Text(
              '清空',
              style: TextStyle(fontSize: 14.sp, color: Colors.grey),
            ),
          ),
        ],
      ),
      SizedBox(height: 12.h),
      // 历史标签
      Wrap(
        spacing: 8.w,
        runSpacing: 8.h,
        children: controller.searchHistory.map((keyword) {
          return GestureDetector(
            onTap: () => controller.search(keyword),
            onLongPress: () => controller.removeFromHistory(keyword),
            child: Container(
              padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h),
              decoration: BoxDecoration(
                color: Colors.grey.shade100,
                borderRadius: BorderRadius.circular(16.r),
              ),
              child: Text(keyword, style: TextStyle(fontSize: 14.sp)),
            ),
          );
        }).toList(),
      ),
    ],
  );
}

历史标签支持两种交互:

  • 点击:直接搜索这个关键词
  • 长按:删除这条历史记录

热门搜索

没有搜索历史的时候,显示一些热门搜索词:

Widget _buildHotSearch(SearchPageController controller) {
  // 热门搜索词列表
  final hotItems = ['塑料瓶', '电池', '剩饭', '纸巾', '玻璃', '药品'];

  return Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Text(
        '热门搜索',
        style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.bold),
      ),
      SizedBox(height: 12.h),
      // Wrap组件会自动换行
      Wrap(
        spacing: 8.w,      // 水平间距
        runSpacing: 8.h,   // 行间距
        children: hotItems.map((item) {
          return GestureDetector(
            onTap: () {
              controller.search(item);
              controller.addToHistory(item);
            },

Wrap组件会自动换行,标签多了也不会挤在一起。点击热门词直接触发搜索,省得用户自己输入。

标签的样式:

            child: Container(
              padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
              decoration: BoxDecoration(
                // 主题色的浅色背景
                color: AppTheme.primaryColor.withOpacity(0.1),
                // 胶囊形状
                borderRadius: BorderRadius.circular(20.r),
              ),
              child: Text(
                item,
                style: TextStyle(
                  fontSize: 14.sp,
                  color: AppTheme.primaryColor,
                ),
              ),
            ),
          );
        }).toList(),
      ),
    ],
  );
}

胶囊形状的标签,背景是主题色的浅色版本,看着清爽又不会太抢眼。

颜色映射方法

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

/// 根据垃圾类型获取对应的颜色
Color _getTypeColor(GarbageType type) {
  switch (type) {
    case GarbageType.recyclable:
      return AppTheme.recyclableColor;  // 蓝色
    case GarbageType.hazardous:
      return AppTheme.hazardousColor;   // 红色
    case GarbageType.kitchen:
      return AppTheme.kitchenColor;     // 绿色
    case GarbageType.other:
      return AppTheme.otherColor;       // 灰色
  }
}

这个方法在好几个地方都会用到,保证整个App里分类颜色的一致性。可以考虑把它放到一个工具类里,或者作为GarbageType枚举的扩展方法:

extension GarbageTypeExtension on GarbageType {
  Color get color {
    switch (this) {
      case GarbageType.recyclable:
        return AppTheme.recyclableColor;
      case GarbageType.hazardous:
        return AppTheme.hazardousColor;
      case GarbageType.kitchen:
        return AppTheme.kitchenColor;
      case GarbageType.other:
        return AppTheme.otherColor;
    }
  }
}

// 使用方式
final color = item.type.color;

搜索算法的实现

GarbageData.searchItems方法的实现:

class GarbageData {
  /// 搜索垃圾物品
  /// [keyword] 搜索关键词
  /// 返回匹配的物品列表
  static List<GarbageItem> searchItems(String keyword) {
    if (keyword.isEmpty) return [];
    
    final lowerKeyword = keyword.toLowerCase();
    
    return allItems.where((item) {
      // 匹配名称
      if (item.name.toLowerCase().contains(lowerKeyword)) return true;
      // 匹配描述
      if (item.description.toLowerCase().contains(lowerKeyword)) return true;
      // 匹配别名
      if (item.aliases.any((alias) => 
          alias.toLowerCase().contains(lowerKeyword))) return true;
      return false;
    }).toList();
  }
}

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

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

性能优化

1. 防抖处理

实时搜索可能会触发太频繁,可以加个防抖:

Timer? _debounceTimer;

void search(String keyword) {
  _debounceTimer?.cancel();
  _debounceTimer = Timer(const Duration(milliseconds: 300), () {
    _doSearch(keyword);
  });
}

2. 搜索结果缓存

相同关键词的搜索结果可以缓存:

final _searchCache = <String, List<GarbageItem>>{};

List<GarbageItem> searchItems(String keyword) {
  if (_searchCache.containsKey(keyword)) {
    return _searchCache[keyword]!;
  }
  final results = _doSearch(keyword);
  _searchCache[keyword] = results;
  return results;
}

搜索功能的实现涵盖了Flutter开发中很多常见的场景:状态管理、列表渲染、条件显示等等。把这些东西串起来,就是一个完整的搜索模块了。


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

Logo

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

更多推荐