图书搜索是教育百科App中使用频率很高的功能,用户可以通过关键词快速找到想要的书籍。这个页面比图书列表复杂一些,因为要处理用户输入、搜索请求、结果展示,还要支持分页加载。

做搜索功能的时候,我特别注意了几个细节:搜索前的引导提示、搜索中的加载状态、无结果时的友好提示、以及加载更多的交互。这些细节加起来,才能让搜索体验变得顺畅。


请添加图片描述

状态变量设计

搜索页面需要管理的状态比较多:

class BookSearchScreen extends StatefulWidget {
  final String? initialQuery;
  final String? title;

  const BookSearchScreen({super.key, this.initialQuery, this.title});

  
  State<BookSearchScreen> createState() => _BookSearchScreenState();
}

class _BookSearchScreenState extends State<BookSearchScreen> {
  final _searchController = TextEditingController();
  List<dynamic> _results = [];
  bool _isLoading = false;
  bool _hasSearched = false;
  int _currentPage = 1;
  int _totalResults = 0;
}

initialQuery支持从外部传入初始搜索词,比如从首页的搜索入口跳转过来时可以带上用户输入的关键词。_hasSearched用来区分"还没搜索"和"搜索结果为空"两种状态,这个很重要。

为什么需要_hasSearched? 想象一下,用户刚进入搜索页面,还没输入任何内容。这时候显示"没有找到相关图书"就很奇怪,应该显示"搜索你感兴趣的图书"这样的引导提示。_hasSearched就是用来区分这两种情况的。


初始化处理

如果有初始搜索词,自动执行搜索:


void initState() {
  super.initState();
  if (widget.initialQuery != null) {
    _searchController.text = widget.initialQuery!;
    _search();
  }
}

把初始搜索词填入输入框,然后调用搜索方法。这样用户从其他页面跳转过来时可以直接看到搜索结果,不需要再手动点击搜索。


搜索方法实现

支持首次搜索和加载更多:

Future<void> _search({bool loadMore = false}) async {
  if (_searchController.text.isEmpty) return;

  setState(() {
    _isLoading = true;
    if (!loadMore) {
      _results = [];
      _currentPage = 1;
    }
  });

loadMore参数区分是新搜索还是加载更多。新搜索时清空结果并重置页码,加载更多时保留现有结果。

  try {
    final data = await ApiService.searchBooks(_searchController.text, page: _currentPage);
    setState(() {
      if (loadMore) {
        _results.addAll(data['docs'] ?? []);
      } else {
        _results = data['docs'] ?? [];
      }
      _totalResults = data['numFound'] ?? 0;
      _hasSearched = true;
      _isLoading = false;
    });
  } catch (e) {
    setState(() => _isLoading = false);
  }
}

加载更多时用addAll把新数据追加到现有列表,新搜索时直接替换整个列表。numFound是API返回的总结果数,用于判断是否还有更多数据可以加载。


搜索输入框

页面顶部的搜索框:


Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: Text(widget.title ?? '搜索图书'),
    ),
    body: Column(
      children: [
        Padding(
          padding: const EdgeInsets.all(16),
          child: TextField(
            controller: _searchController,
            decoration: InputDecoration(
              hintText: '输入书名、作者...',
              prefixIcon: const Icon(Icons.search),
              suffixIcon: _searchController.text.isNotEmpty
                  ? IconButton(
                      icon: const Icon(Icons.clear),
                      onPressed: () {
                        _searchController.clear();
                        setState(() {
                          _results = [];
                          _hasSearched = false;
                        });
                      },
                    )
                  : null,
            ),
            onSubmitted: (_) => _search(),
            onChanged: (_) => setState(() {}),
          ),
        ),

prefixIcon显示搜索图标,suffixIcon在有输入内容时显示清除按钮。onSubmitted在用户按回车时触发搜索,onChanged触发setState更新清除按钮的显示状态。

为什么清除按钮要用条件判断? 输入框为空时显示清除按钮没有意义,而且会让界面显得杂乱。只有在有内容时才显示,用户体验更好。


搜索结果数量提示

显示找到多少本图书:

        if (_hasSearched && !_isLoading)
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16),
            child: Row(
              children: [
                Text('找到 $_totalResults 本图书', style: TextStyle(color: Colors.grey[600])),
              ],
            ),
          ),
        Expanded(child: _buildResults()),
      ],
    ),
  );
}

只有在搜索完成且不在加载中时才显示结果数量。这个提示帮助用户了解搜索结果的规模,如果结果很多,用户可能会考虑换个更精确的关键词。


结果区域构建

根据不同状态显示不同内容:

Widget _buildResults() {
  if (!_hasSearched) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(Icons.search, size: 80, color: Colors.grey[300]),
          const SizedBox(height: 16),
          Text('搜索你感兴趣的图书', style: TextStyle(color: Colors.grey[500])),
        ],
      ),
    );
  }

未搜索时显示引导提示,一个大的搜索图标加一句引导语。这比显示空白页面友好多了。

  if (_isLoading && _results.isEmpty) {
    return ListView.builder(
      padding: const EdgeInsets.all(16),
      itemCount: 5,
      itemBuilder: (context, index) => const Padding(
        padding: EdgeInsets.only(bottom: 12),
        child: LoadingShimmer(height: 100),
      ),
    );
  }

  if (_results.isEmpty) {
    return const EmptyWidget(message: '没有找到相关图书', icon: Icons.menu_book);
  }

首次加载时显示骨架屏,无结果时显示空状态提示。注意_isLoading && _results.isEmpty这个条件,加载更多时不应该显示骨架屏,因为已经有结果在显示了。


结果列表和加载更多

有结果时显示列表和加载更多按钮:

  return ListView.builder(
    padding: const EdgeInsets.all(16),
    itemCount: _results.length + (_results.length < _totalResults ? 1 : 0),
    itemBuilder: (context, index) {
      if (index == _results.length) {
        return Padding(
          padding: const EdgeInsets.all(16),
          child: ElevatedButton(
            onPressed: _isLoading ? null : () {
              _currentPage++;
              _search(loadMore: true);
            },
            child: _isLoading ? const SizedBox(
              width: 20,
              height: 20,
              child: CircularProgressIndicator(strokeWidth: 2),
            ) : const Text('加载更多'),
          ),
        );
      }
      return _buildBookItem(_results[index]);
    },
  );
}

itemCount多加1是为了在列表末尾显示加载更多按钮。只有当已加载的结果少于总数时才显示按钮,如果已经加载完了就不显示。

按钮的状态处理: 加载中时按钮禁用(onPressed: null)并显示小型加载动画,避免用户重复点击。加载动画用SizedBox限制大小,strokeWidth: 2让圆环更细,适合在按钮里显示。


图书项展示

每本图书用横向布局展示:

Widget _buildBookItem(Map<String, dynamic> book) {
  final coverId = book['cover_i'];
  final coverUrl = coverId != null ? 'https://covers.openlibrary.org/b/id/$coverId-M.jpg' : null;

  return Card(
    margin: const EdgeInsets.only(bottom: 12),
    child: InkWell(
      onTap: () {
        final key = book['key'];
        if (key != null) {
          Navigator.push(context, MaterialPageRoute(builder: (_) => BookDetailScreen(bookKey: key)));
        }
      },
      borderRadius: BorderRadius.circular(12),
      child: Padding(
        padding: const EdgeInsets.all(12),
        child: Row(
          children: [
            NetworkImageWidget(
              imageUrl: coverUrl,
              width: 70,
              height: 100,
            ),
            const SizedBox(width: 12),

左侧是封面图片,固定70x100的尺寸,接近书籍封面的比例。InkWellborderRadius要和Card的圆角一致,否则水波纹效果会超出边界。

            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    book['title'] ?? '未知标题',
                    maxLines: 2,
                    overflow: TextOverflow.ellipsis,
                    style: const TextStyle(fontWeight: FontWeight.w600),
                  ),
                  const SizedBox(height: 4),
                  Text(
                    (book['author_name'] as List?)?.join(', ') ?? '未知作者',
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                    style: TextStyle(color: Colors.grey[600], fontSize: 13),
                  ),
                  const SizedBox(height: 4),
                  if (book['first_publish_year'] != null)
                    Text(
                      '首次出版: ${book['first_publish_year']}年',
                      style: TextStyle(color: Colors.grey[500], fontSize: 12),
                    ),
                  if (book['publisher'] != null)
                    Text(
                      '出版社: ${(book['publisher'] as List).first}',
                      maxLines: 1,
                      overflow: TextOverflow.ellipsis,
                      style: TextStyle(color: Colors.grey[500], fontSize: 12),
                    ),
                ],
              ),
            ),
          ],
        ),
      ),
    ),
  );
}

搜索结果比列表页显示更多信息,包括出版年份和出版社。publisher是数组,取第一个元素显示。Expanded让右侧的文字区域占据剩余空间。


资源释放

页面销毁时释放TextEditingController:


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

TextEditingController需要手动释放,否则会造成内存泄漏。这是Flutter开发中的常见模式,每次用到Controller都要记得在dispose里释放。


搜索体验优化的思考

目前的实现是用户按回车或点击搜索按钮才触发搜索。还有一种做法是实时搜索——用户每输入一个字符就自动搜索。但这样会产生大量请求,对服务器压力大,而且用户还没输完就开始搜索,结果可能不是用户想要的。

如果要做实时搜索,可以加一个防抖(debounce)机制:用户停止输入一段时间(比如500毫秒)后才触发搜索。这样既能提供实时反馈,又不会产生太多无效请求。


小结

图书搜索页面展示了如何处理用户输入和分页加载。通过_hasSearched状态区分不同的显示场景,让用户在各种情况下都能得到合适的反馈。加载更多按钮的实现让用户可以按需加载数据,避免一次性加载过多内容。

下一篇我们来看图书详情页面的实现,了解如何展示一本书的完整信息。


本文是Flutter for OpenHarmony教育百科实战系列的第七篇。

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

Logo

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

更多推荐