在这里插入图片描述

衣橱里衣物多了之后,想找某件特定的衣服就变得困难了。这时候搜索功能就派上用场了。用户输入关键词,App帮他快速找到匹配的衣物。

今天这篇文章,我来详细讲讲衣橱管家App里搜索功能的实现。这个功能看起来简单,但要做好用户体验,还是有不少细节要考虑的。

搜索功能的设计思路

一个好的搜索功能应该具备以下特点:

第一,搜索范围要广,不仅能搜名称,还能搜品牌、标签等。

第二,要有搜索历史,方便用户快速重复搜索。

第三,要有热门搜索,给用户一些搜索建议。

第四,搜索结果要实时显示,用户边输入边看结果。

页面基础结构

先看SearchScreen的定义:

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../../providers/wardrobe_provider.dart';
import '../../models/clothing_item.dart';
import 'clothing_detail_screen.dart';

class SearchScreen extends StatefulWidget {
  const SearchScreen({super.key});

  
  State<SearchScreen> createState() => _SearchScreenState();
}

class _SearchScreenState extends State<SearchScreen> {
  final _searchController = TextEditingController();
  List<ClothingItem> _results = [];
  List<String> _recentSearches = ['白色', 'T恤', '休闲'];

  
  void dispose() {
    _searchController.dispose();
    super.dispose();
  }
}

用StatefulWidget是因为需要维护搜索框的内容、搜索结果、搜索历史等状态。
_searchController控制搜索输入框,可以获取和设置输入内容。
_results存储搜索结果,初始为空列表。
_recentSearches是最近搜索记录,这里先写死几个示例,实际项目中应该从本地存储读取。

搜索方法实现

搜索的核心逻辑:

void _search(String query) {
  if (query.isEmpty) {
    setState(() => _results = []);
    return;
  }
  final provider = Provider.of<WardrobeProvider>(context, listen: false);
  setState(() {
    _results = provider.searchClothing(query);
  });
}

如果搜索词为空,直接清空结果列表,不执行搜索。
从Provider获取WardrobeProvider实例,调用searchClothing方法执行搜索。
setState触发页面重建,显示新的搜索结果。
listen: false因为这里只是调用方法,不需要监听数据变化。

Provider里的searchClothing方法大概长这样:

// WardrobeProvider里的搜索方法
List<ClothingItem> searchClothing(String query) {
  final lowerQuery = query.toLowerCase();
  return clothes.where((item) {
    return item.name.toLowerCase().contains(lowerQuery) ||
           item.brand.toLowerCase().contains(lowerQuery) ||
           item.category.toLowerCase().contains(lowerQuery) ||
           item.tags.any((tag) => tag.toLowerCase().contains(lowerQuery));
  }).toList();
}

把搜索词转成小写,实现大小写不敏感的搜索。
搜索范围包括名称、品牌、分类、标签,只要有一个匹配就返回。
用contains而不是==,这样输入"白"也能匹配到"白色T恤"。

页面布局

build方法构建整个页面:


Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: TextField(
        controller: _searchController,
        autofocus: true,
        style: const TextStyle(color: Colors.white),
        decoration: const InputDecoration(
          hintText: '搜索衣物名称、品牌、标签...',
          hintStyle: TextStyle(color: Colors.white70),
          border: InputBorder.none,
        ),
        onChanged: _search,
      ),
      actions: [
        IconButton(
          icon: const Icon(Icons.clear),
          onPressed: () {
            _searchController.clear();
            setState(() => _results = []);
          },
        ),
      ],
    ),
    body: _searchController.text.isEmpty ? _buildRecentSearches() : _buildSearchResults(),
  );
}

搜索框放在AppBar的title位置,这是很常见的设计模式。
autofocus: true让页面打开时搜索框自动获得焦点,键盘自动弹出。
文字颜色用白色,因为AppBar背景是主题色,白色文字对比度好。
onChanged绑定_search方法,用户每输入一个字符就触发搜索,实现实时搜索。
右上角的清除按钮可以一键清空搜索框和结果。

最近搜索和热门搜索

当搜索框为空时,显示最近搜索和热门搜索:

Widget _buildRecentSearches() {
  return Padding(
    padding: EdgeInsets.all(16.w),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text('最近搜索', style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.bold)),
        SizedBox(height: 12.h),
        Wrap(
          spacing: 8.w,
          runSpacing: 8.h,
          children: _recentSearches.map((search) {
            return ActionChip(
              label: Text(search),
              onPressed: () {
                _searchController.text = search;
                _search(search);
              },
            );
          }).toList(),
        ),
        SizedBox(height: 24.h),
        Text('热门搜索', style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.bold)),
        SizedBox(height: 12.h),
        Wrap(
          spacing: 8.w,
          runSpacing: 8.h,
          children: ['百搭', '正装', '运动', '约会', '夏季'].map((tag) {
            return ActionChip(
              label: Text(tag),
              backgroundColor: const Color(0xFFE91E63).withOpacity(0.1),
              onPressed: () {
                _searchController.text = tag;
                _search(tag);
              },
            );
          }).toList(),
        ),
      ],
    ),
  );
}

最近搜索从_recentSearches列表读取,实际项目中应该持久化存储。
热门搜索这里写死了几个常用标签,实际项目中可以根据用户行为动态生成。
ActionChip点击后把内容填入搜索框并执行搜索,方便用户快速搜索。
热门搜索的Chip用浅粉色背景,和最近搜索区分开来。

搜索结果展示

当有搜索结果时,用列表展示:

Widget _buildSearchResults() {
  if (_results.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)),
        ],
      ),
    );
  }

  return 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: Container(
            width: 50.w,
            height: 50.w,
            decoration: BoxDecoration(
              color: ClothingItem.getColorFromName(item.color).withOpacity(0.3),
              borderRadius: BorderRadius.circular(8.r),
            ),
            child: Icon(Icons.checkroom, color: ClothingItem.getColorFromName(item.color)),
          ),
          title: Text(item.name),
          subtitle: Text('${item.category} · ${item.color}'),
          trailing: const Icon(Icons.chevron_right),
          onTap: () => Navigator.push(
            context,
            MaterialPageRoute(builder: (_) => ClothingDetailScreen(item: item)),
          ),
        ),
      );
    },
  );
}

先判断结果是否为空,为空时显示"未找到"的提示,配上一个搜索图标,比空白页面友好。
ListView.builder用来展示搜索结果,比直接用Column性能更好,因为它只渲染可见的项。
每个结果用Card包裹的ListTile展示,leading是颜色方块,title是名称,subtitle是分类和颜色。
点击结果跳转到衣物详情页面。

实时搜索的实现

实时搜索是指用户边输入边看结果,不需要点击搜索按钮:

TextField(
  controller: _searchController,
  onChanged: _search,  // 每次输入变化都触发搜索
)

onChanged在每次输入内容变化时触发,包括输入、删除、粘贴等操作。
这种方式用户体验很好,但如果搜索逻辑复杂或数据量大,可能会有性能问题。
可以考虑加防抖(debounce),比如用户停止输入300毫秒后再执行搜索。

防抖的实现方式:

Timer? _debounceTimer;

void _search(String query) {
  _debounceTimer?.cancel();
  _debounceTimer = Timer(const Duration(milliseconds: 300), () {
    if (query.isEmpty) {
      setState(() => _results = []);
      return;
    }
    final provider = Provider.of<WardrobeProvider>(context, listen: false);
    setState(() {
      _results = provider.searchClothing(query);
    });
  });
}


void dispose() {
  _debounceTimer?.cancel();
  _searchController.dispose();
  super.dispose();
}

每次输入变化时,先取消之前的定时器,再创建新的定时器。
只有用户停止输入300毫秒后,定时器才会触发,执行真正的搜索。
dispose时要记得取消定时器,避免内存泄漏。

搜索历史的持久化

实际项目中,搜索历史应该保存到本地存储:

// 使用shared_preferences保存搜索历史
import 'package:shared_preferences/shared_preferences.dart';

// 保存搜索历史
Future<void> _saveRecentSearch(String query) async {
  if (query.isEmpty) return;
  final prefs = await SharedPreferences.getInstance();
  List<String> searches = prefs.getStringList('recent_searches') ?? [];
  searches.remove(query);  // 先移除,避免重复
  searches.insert(0, query);  // 插入到最前面
  if (searches.length > 10) {
    searches = searches.sublist(0, 10);  // 最多保存10条
  }
  await prefs.setStringList('recent_searches', searches);
}

// 读取搜索历史
Future<void> _loadRecentSearches() async {
  final prefs = await SharedPreferences.getInstance();
  setState(() {
    _recentSearches = prefs.getStringList('recent_searches') ?? [];
  });
}

shared_preferences是Flutter常用的本地存储插件,可以保存简单的键值对数据。
搜索历史用StringList存储,新的搜索词插入到最前面。
限制最多保存10条,避免列表太长。
在initState里调用_loadRecentSearches加载历史记录。

清除搜索框的交互

清除按钮的实现:

IconButton(
  icon: const Icon(Icons.clear),
  onPressed: () {
    _searchController.clear();
    setState(() => _results = []);
  },
),

_searchController.clear()清空输入框内容。
同时清空搜索结果,页面会显示最近搜索和热门搜索。
这个按钮只在搜索框有内容时才有意义,可以考虑在搜索框为空时隐藏它。

条件显示清除按钮:

actions: _searchController.text.isNotEmpty
    ? [
        IconButton(
          icon: const Icon(Icons.clear),
          onPressed: () {
            _searchController.clear();
            setState(() => _results = []);
          },
        ),
      ]
    : [],

用三元表达式判断,搜索框有内容时显示清除按钮,没内容时不显示。
这样界面更简洁,用户不会困惑为什么点清除按钮没反应。

搜索结果高亮

更好的用户体验是把搜索词在结果中高亮显示:

Widget _buildHighlightedText(String text, String query) {
  if (query.isEmpty) return Text(text);
  
  final lowerText = text.toLowerCase();
  final lowerQuery = query.toLowerCase();
  final index = lowerText.indexOf(lowerQuery);
  
  if (index == -1) return Text(text);
  
  return RichText(
    text: TextSpan(
      style: const TextStyle(color: Colors.black),
      children: [
        TextSpan(text: text.substring(0, index)),
        TextSpan(
          text: text.substring(index, index + query.length),
          style: const TextStyle(color: Color(0xFFE91E63), fontWeight: FontWeight.bold),
        ),
        TextSpan(text: text.substring(index + query.length)),
      ],
    ),
  );
}

找到搜索词在文本中的位置,把文本分成三部分:匹配前、匹配中、匹配后。
匹配中的部分用主题色和粗体显示,让用户一眼看到为什么这个结果被搜出来。
RichText可以在一段文字中使用不同的样式。

空状态的处理

搜索无结果时的展示很重要:

if (_results.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)),
      ],
    ),
  );
}

用图标加文字的方式展示,比单纯的文字更友好。
居中显示,视觉上更舒服。
灰色调表示这是一个"空"的状态,不是错误。

键盘处理

搜索页面的键盘交互也需要考虑:

// 点击搜索结果时收起键盘
onTap: () {
  FocusScope.of(context).unfocus();  // 收起键盘
  Navigator.push(
    context,
    MaterialPageRoute(builder: (_) => ClothingDetailScreen(item: item)),
  );
},

点击搜索结果跳转前先收起键盘,避免键盘遮挡下一个页面。
FocusScope.of(context).unfocus()是收起键盘的标准方法。

总结

搜索功能的实现涉及到输入框控制、实时搜索、搜索历史、结果展示等多个方面。关键点在于:

实时搜索让用户体验更流畅,但要注意性能问题,必要时加防抖。

搜索历史和热门搜索能帮助用户快速找到想要的内容。

空状态的处理要友好,告诉用户为什么没有结果。

在OpenHarmony平台上,这套搜索功能的实现方式完全适用,Flutter的跨平台能力让我们可以用同一套代码在不同平台上提供一致的搜索体验。

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

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐