在这里插入图片描述

搜索是二手交易App的核心功能之一,用户想找特定的商品,第一反应就是搜索。今天我们来实现"闲置换"的搜索功能,包括搜索历史记录、热门搜索推荐、搜索结果展示这几个部分。做好搜索功能能大大提升用户找到心仪商品的效率,也是衡量一个电商类App体验好坏的重要指标。

搜索功能的设计思路

一个好用的搜索功能应该具备这些特点:输入框自动获取焦点方便用户直接输入、展示搜索历史方便用户快速重复搜索、提供热门搜索词引导用户发现热门商品、搜索结果用网格展示和首页保持一致的体验。我们还要考虑搜索历史的本地存储,让用户下次打开App还能看到之前的搜索记录。

搜索页面完整实现

先看页面的基础结构和状态定义,这部分代码定义了搜索页面需要管理的所有状态变量:

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../product/product_detail_page.dart';

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

  
  State<SearchPage> createState() => _SearchPageState();
}

导入必要的包是第一步,GetX用于页面跳转,它比Flutter原生的Navigator更简洁,代码量更少。商品详情页用于点击搜索结果后跳转,让用户能查看商品的完整信息。SearchPageStatefulWidget是因为要管理搜索框的输入状态、搜索历史、搜索结果等多个状态,这些状态会随着用户操作而变化,需要动态更新UI。

class _SearchPageState extends State<SearchPage> {
  final TextEditingController _searchController = TextEditingController();
  final List<String> _hotSearches = ['iPhone', '笔记本', '相机', '耳机', '手表', '包包'];
  final List<String> _historySearches = ['二手手机', '电脑', '书籍'];
  List<Map<String, dynamic>> _searchResults = [];
  bool _isSearching = false;

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

这里定义了几个关键的状态变量,每个都有特定的用途。_searchController控制搜索输入框,可以获取用户输入的内容,也可以程序化地设置输入框的值,是Flutter表单处理的标准做法。_hotSearches是热门搜索词列表,实际项目中这个数据应该从后端获取,可以根据搜索热度动态调整,运营人员也可以手动配置推广某些关键词。_historySearches存储用户的搜索历史,真实项目要持久化到本地存储,这样用户下次打开App还能看到。_isSearching标记当前是否在展示搜索结果,用来切换显示搜索建议还是搜索结果。dispose方法里记得释放TextEditingController,这是Flutter开发的好习惯,避免内存泄漏。

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        titleSpacing: 0,
        title: Container(
          height: 36,
          margin: const EdgeInsets.only(right: 16),
          child: TextField(
            controller: _searchController,
            autofocus: true,
            decoration: InputDecoration(
              hintText: '搜索你想要的宝贝',
              hintStyle: TextStyle(color: Colors.grey[400], fontSize: 14),
              prefixIcon: const Icon(Icons.search, color: Colors.grey, size: 20),
              filled: true,
              fillColor: const Color(0xFFF5F5F5),
              contentPadding: EdgeInsets.zero,
              border: OutlineInputBorder(
                borderRadius: BorderRadius.circular(18),
                borderSide: BorderSide.none,
              ),
            ),
            onSubmitted: _search,
          ),
        ),

搜索框放在AppBar的title位置,这样可以充分利用顶部空间,让搜索框尽可能宽,用户输入长关键词时也能完整显示。titleSpacing: 0去掉默认的左边距,让搜索框更宽。autofocus: true让页面打开时输入框自动获取焦点,键盘自动弹出,用户可以直接输入,不需要再点一下搜索框,这个小细节能提升用户体验。输入框样式用圆角矩形,浅灰色背景,左边放搜索图标,整体风格简洁现代。contentPadding: EdgeInsets.zero配合prefixIcon可以让图标和文字垂直居中。onSubmitted在用户按下键盘回车时触发搜索,这是移动端搜索的标准交互方式。

        actions: [
          TextButton(
            onPressed: () => _search(_searchController.text),
            child: const Text('搜索', style: TextStyle(color: Color(0xFF07C160))),
          ),
        ],
      ),
      body: _isSearching ? _buildSearchResults() : _buildSearchSuggestions(),
    );
  }

右边放一个搜索按钮,点击也能触发搜索,方便不想用键盘回车的用户,有些用户习惯点击按钮而不是按回车,提供多种操作方式能覆盖更多用户习惯。按钮用主题绿色0xFF07C160,和整体风格统一,也能引导用户注意到这个操作入口。body部分根据_isSearching状态决定显示什么内容:还没搜索时显示搜索建议(历史+热门),搜索后显示搜索结果。这种条件渲染的方式让代码结构清晰,两种状态的UI完全分开,互不干扰,后期维护也方便。

  Widget _buildSearchSuggestions() {
    return SingleChildScrollView(
      padding: const EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          if (_historySearches.isNotEmpty) ...[
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                const Text('搜索历史', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
                GestureDetector(
                  onTap: () => setState(() => _historySearches.clear()),
                  child: const Icon(Icons.delete_outline, color: Colors.grey, size: 20),
                ),
              ],
            ),
            const SizedBox(height: 12),
            Wrap(
              spacing: 10,
              runSpacing: 10,
              children: _historySearches.map((item) => _buildSearchTag(item)).toList(),
            ),
            const SizedBox(height: 24),
          ],

搜索建议页面用SingleChildScrollView包裹,内容多了可以滚动,避免内容超出屏幕。搜索历史部分只在有历史记录时才显示,用if配合展开运算符...[]实现条件渲染,这是Dart语言的一个很实用的特性,可以在列表中条件性地添加元素,比传统的if-else更简洁。标题栏右边放一个删除图标,点击清空所有历史记录,给用户一个快速清理的入口,保护用户隐私。Wrap组件让标签自动换行,spacing控制水平间距,runSpacing控制行间距,这样不管有多少个历史记录,都能整齐地排列,不会超出屏幕。

          const Text('热门搜索', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
          const SizedBox(height: 12),
          Wrap(
            spacing: 10,
            runSpacing: 10,
            children: _hotSearches.map((item) => _buildSearchTag(item, isHot: true)).toList(),
          ),
        ],
      ),
    );
  }

热门搜索部分始终显示,因为这是引导用户发现商品的重要入口,即使用户没有搜索历史也能看到热门内容,帮助新用户快速上手。用同样的Wrap布局保持视觉一致性,用户不需要适应不同的布局方式。传入isHot: true参数让热门标签有不同的样式,和历史记录区分开,用户一眼就能分辨哪些是自己搜过的,哪些是平台推荐的热门词汇。

  Widget _buildSearchTag(String text, {bool isHot = false}) {
    return GestureDetector(
      onTap: () {
        _searchController.text = text;
        _search(text);
      },
      child: Container(
        padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
        decoration: BoxDecoration(
          color: isHot ? const Color(0xFF07C160).withOpacity(0.1) : Colors.grey[100],
          borderRadius: BorderRadius.circular(16),
        ),
        child: Text(
          text,
          style: TextStyle(
            color: isHot ? const Color(0xFF07C160) : Colors.black87,
            fontSize: 14,
          ),
        ),
      ),
    );
  }

搜索标签是一个可点击的圆角容器,封装成独立方法方便复用,历史标签和热门标签都用这个方法生成。点击时先把文字填入搜索框,然后执行搜索,这样用户能看到搜索框里显示了什么关键词,体验更清晰透明。热门标签用浅绿色背景和绿色文字,历史标签用灰色背景和黑色文字,视觉上有明显区分。withOpacity(0.1)让背景色很淡,不会太抢眼,但又能和灰色标签区分开。圆角用16像素,看起来像胶囊形状,这是现在很流行的标签设计风格,比方角看起来更柔和友好。

  Widget _buildSearchResults() {
    if (_searchResults.isEmpty) {
      return Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.search_off, size: 80, color: Colors.grey[300]),
            const SizedBox(height: 16),
            Text('未找到相关商品', style: TextStyle(color: Colors.grey[500])),
          ],
        ),
      );
    }

    return GridView.builder(
      padding: const EdgeInsets.all(12),
      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 2,
        childAspectRatio: 0.65,
        crossAxisSpacing: 10,
        mainAxisSpacing: 10,
      ),
      itemCount: _searchResults.length,
      itemBuilder: (context, index) => _buildProductCard(_searchResults[index]),
    );
  }

搜索结果页面先判断是否有结果,没有的话显示空状态提示,用一个大图标和文字告诉用户没找到商品,这比什么都不显示要友好得多,用户知道搜索已经执行了只是没有匹配的结果。有结果就用GridView.builder展示,两列网格布局,和首页商品列表保持一致的样式,用户不需要适应新的布局。childAspectRatio: 0.65控制卡片的宽高比,让卡片有足够的高度展示图片和信息。builder模式只会创建可见区域的卡片,性能更好,特别是搜索结果很多的时候不会卡顿。

  Widget _buildProductCard(Map<String, dynamic> product) {
    return GestureDetector(
      onTap: () => Get.to(() => ProductDetailPage(productId: product['id'])),
      child: Container(
        decoration: BoxDecoration(
          color: Colors.white,
          borderRadius: BorderRadius.circular(12),
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Expanded(
              flex: 3,
              child: Container(
                decoration: BoxDecoration(
                  borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
                  color: Colors.grey[200],
                ),
                child: Center(child: Icon(Icons.image, size: 60, color: Colors.grey[400])),
              ),
            ),

商品卡片的结构和首页一样,保持一致性很重要,用户在不同页面看到相同的卡片样式会感觉更熟悉,降低认知负担。点击跳转到商品详情页,传入商品id让详情页知道要显示哪个商品的信息。图片区域占卡片的大部分空间,用flex: 3控制比例,图片是吸引用户点击的关键元素所以要足够大。这里用占位图标,实际项目要换成网络图片,可以用CachedNetworkImage实现图片缓存。

            Expanded(
              flex: 2,
              child: Padding(
                padding: const EdgeInsets.all(10),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(product['title'], style: const TextStyle(fontSize: 14), maxLines: 2, overflow: TextOverflow.ellipsis),
                    const Spacer(),
                    Text('¥${product['price']}', style: const TextStyle(color: Color(0xFFFF4D4F), fontSize: 16, fontWeight: FontWeight.bold)),
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }

信息区域展示商品标题和价格,这是用户最关心的两个信息,其他信息放到详情页展示。标题最多两行,超出显示省略号,避免标题太长把卡片撑变形,保持卡片高度一致。价格用红色0xFFFF4D4F突出显示,这是电商App的通用做法,红色能吸引用户注意力,让用户快速扫描到价格信息做出购买决策。Spacer把标题和价格撑开,让布局更稳定,不管标题是一行还是两行,价格始终在底部。

  void _search(String keyword) {
    if (keyword.isEmpty) return;
    setState(() {
      _isSearching = true;
      _searchResults = List.generate(10, (index) => {
        'id': index,
        'title': '$keyword 相关商品 ${index + 1}',
        'price': (index + 1) * 100,
      });
      if (!_historySearches.contains(keyword)) {
        _historySearches.insert(0, keyword);
        if (_historySearches.length > 10) _historySearches.removeLast();
      }
    });
  }
}

搜索方法先检查关键词是否为空,空的话直接返回,避免无意义的搜索请求。然后设置_isSearching为true切换到结果页面,生成模拟的搜索结果,实际项目中这里要调用后端API获取真实数据。搜索历史的处理逻辑很重要:如果关键词不在历史记录里,就插入到最前面,这样最近搜索的词总是在最前面方便用户找到;如果历史记录超过10条,就删除最后一条,避免历史记录无限增长占用太多空间。这种处理方式在各大App中都很常见,是经过验证的最佳实践。

小结

这篇实现了"闲置换"App的搜索功能,包括带自动聚焦的搜索框、搜索历史管理、热门搜索展示、搜索结果网格列表。搜索是用户找商品的主要入口,做好搜索体验能显著提升用户满意度。


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

Logo

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

更多推荐