通过网盘分享的文件:flutter1.zip
链接: https://pan.baidu.com/s/1jkLZ9mZXjNm0LgP6FTVRzw 提取码: 2t97

搜索功能是动漫应用中最核心的交互入口之一。用户打开App,往往第一件事就是搜索自己想看的番剧。一个好用的搜索页面,不仅要响应快、结果准,还要有搜索历史记录,方便用户快速回顾之前搜过的内容。

这篇文章会带你从零实现一个完整的搜索页面,包括搜索框交互、历史记录管理、搜索结果展示、加载状态处理等功能。代码都是项目中实际跑着的,拿来就能用。
请添加图片描述


搜索页面的整体结构

先来看搜索页面的基本骨架。我们需要一个有状态的Widget,因为搜索涉及到输入框内容变化、加载状态切换、搜索结果更新等多个状态:

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/api_service.dart';
import '../models/anime.dart';
import '../providers/search_provider.dart';
import '../widgets/anime_list_tile.dart';
import '../widgets/shimmer_loading.dart';

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

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

这里引入了几个关键依赖:provider用于管理搜索历史,ApiService负责发起搜索请求,AnimeListTileShimmerLoading是展示搜索结果和加载状态的组件。把这些功能拆分成独立模块,代码会清晰很多。


状态变量的设计

搜索页面需要管理几个核心状态:

class _SearchScreenState extends State<SearchScreen> {
  final TextEditingController _controller = TextEditingController();
  List<Anime> _results = [];
  bool _isLoading = false;
  bool _hasSearched = false;

_controller控制搜索框的文本内容,_results存放搜索结果列表,_isLoading标记是否正在加载,_hasSearched用来区分"还没搜索"和"搜索了但没结果"这两种状态。这个区分很重要,因为两种情况下显示的UI完全不同。


搜索逻辑的实现

当用户在搜索框按下回车,触发搜索:

  Future<void> _search(String query) async {
    if (query.trim().isEmpty) return;
    
    setState(() {
      _isLoading = true;
      _hasSearched = true;
    });

    try {
      final results = await ApiService.searchAnime(query);
      setState(() {
        _results = results;
        _isLoading = false;
      });
      context.read<SearchProvider>().addSearch(query);
    } catch (e) {
      setState(() => _isLoading = false);
    }
  }

搜索前先检查输入是否为空,避免发起无效请求。然后立即把_isLoading设为true,让界面显示加载动画。搜索成功后更新结果列表,同时把这次搜索的关键词存入历史记录。用try-catch包裹异步操作是个好习惯,即使请求失败也不会让App崩溃。


AppBar中的搜索框

搜索框直接放在AppBar的title位置,这样用户一进页面就能看到并开始输入:

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: TextField(
          controller: _controller,
          decoration: InputDecoration(
            hintText: '搜索动漫...',
            border: InputBorder.none,
            suffixIcon: _controller.text.isNotEmpty
                ? IconButton(
                    icon: const Icon(Icons.clear),
                    onPressed: () {
                      _controller.clear();
                      setState(() {
                        _results = [];
                        _hasSearched = false;
                      });
                    },
                  )
                : null,
          ),
          onChanged: (value) => setState(() {}),
          onSubmitted: _search,
        ),
      ),
      body: _buildBody(),
    );
  }

搜索框右侧有个清除按钮,只在有输入内容时才显示。点击清除按钮会同时清空输入框和搜索结果,并把_hasSearched重置为false,这样页面会回到显示搜索历史的状态。onChanged回调里调用setState是为了让清除按钮能及时显示或隐藏。


页面主体内容的条件渲染

根据不同状态显示不同内容,这是搜索页面的核心逻辑:

  Widget _buildBody() {
    if (!_hasSearched) {
      return _buildSearchHistory();
    }

    if (_isLoading) {
      return const ShimmerLoading(itemCount: 8, isGrid: false);
    }

    if (_results.isEmpty) {
      return Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.search, size: 64, color: Colors.grey[400]),
            const SizedBox(height: 16),
            Text(
              '未找到相关动漫',
              style: TextStyle(color: Colors.grey[600], fontSize: 16),
            ),
          ],
        ),
      );
    }

    return ListView.builder(
      padding: const EdgeInsets.all(8),
      itemCount: _results.length,
      itemBuilder: (_, i) => AnimeListTile(anime: _results[i]),
    );
  }

这里有四种状态:未搜索时显示历史记录,加载中显示骨架屏动画,搜索无结果显示空状态提示,有结果则用ListView展示。骨架屏用的是ShimmerLoading组件,isGrid: false表示用列表形式而不是网格形式。


搜索历史的展示

搜索历史用Wrap组件实现流式布局,每个历史记录是一个Chip:

  Widget _buildSearchHistory() {
    return Consumer<SearchProvider>(
      builder: (context, provider, _) {
        if (provider.searchHistory.isEmpty) {
          return Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Icon(Icons.history, size: 64, color: Colors.grey[400]),
                const SizedBox(height: 16),
                Text(
                  '搜索历史为空',
                  style: TextStyle(color: Colors.grey[600], fontSize: 16),
                ),
              ],
            ),
          );
        }

        return ListView(
          padding: const EdgeInsets.all(16),
          children: [
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                const Text(
                  '搜索历史',
                  style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
                ),
                TextButton(
                  onPressed: () => provider.clearSearchHistory(),
                  child: const Text('清空'),
                ),
              ],
            ),
            const SizedBox(height: 12),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: provider.searchHistory.map((query) {
                return GestureDetector(
                  onTap: () {
                    _controller.text = query;
                    _search(query);
                  },
                  onLongPress: () => provider.removeSearch(query),
                  child: Chip(
                    label: Text(query),
                    onDeleted: () => provider.removeSearch(query),
                  ),
                );
              }).toList(),
            ),
          ],
        );
      },
    );
  }

Consumer监听SearchProvider的变化,历史记录更新时UI会自动刷新。每个Chip支持三种操作:点击直接搜索、长按删除、点击删除图标删除。顶部有个"清空"按钮可以一键清除所有历史。Wrap组件会自动换行,不用担心历史记录太多挤不下。


资源释放

别忘了在页面销毁时释放TextEditingController:

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

这是Flutter开发的基本规范。TextEditingController内部持有资源,不手动释放会造成内存泄漏。养成在dispose里清理资源的习惯,App运行会更稳定。


搜索结果列表项组件

搜索结果用AnimeListTile组件展示,来看看它的实现:

import 'package:flutter/material.dart';
import '../models/anime.dart';
import '../screens/anime_detail_screen.dart';

class AnimeListTile extends StatelessWidget {
  final Anime anime;
  final VoidCallback? onDelete;

  const AnimeListTile({super.key, required this.anime, this.onDelete});

这是个无状态组件,接收一个Anime对象和可选的删除回调。onDelete在收藏页面会用到,搜索结果里不需要删除功能,传null就行。


列表项的滑动删除

虽然搜索结果不需要删除,但组件设计时考虑了复用性:

  
  Widget build(BuildContext context) {
    return Dismissible(
      key: Key(anime.malId.toString()),
      direction: onDelete != null ? DismissDirection.endToStart : DismissDirection.none,
      onDismissed: (_) => onDelete?.call(),
      background: Container(
        alignment: Alignment.centerRight,
        padding: const EdgeInsets.only(right: 20),
        color: Colors.red,
        child: const Icon(Icons.delete, color: Colors.white),
      ),

Dismissible组件实现滑动删除效果。通过判断onDelete是否为null来决定是否启用滑动,这样同一个组件在不同场景下表现不同。滑动时背景显示红色删除图标,给用户明确的视觉反馈。


列表项的内容布局

ListTile是Material Design的标准列表项组件:

      child: ListTile(
        onTap: () => Navigator.push(
          context,
          MaterialPageRoute(builder: (_) => AnimeDetailScreen(anime: anime)),
        ),
        leading: ClipRRect(
          borderRadius: BorderRadius.circular(8),
          child: SizedBox(
            width: 50,
            height: 70,
            child: _buildImage(),
          ),
        ),
        title: Text(
          anime.title,
          maxLines: 2,
          overflow: TextOverflow.ellipsis,
          style: const TextStyle(fontWeight: FontWeight.w600),
        ),
        subtitle: Row(
          children: [
            if (anime.score != null) ...[
              const Icon(Icons.star, color: Colors.amber, size: 14),
              const SizedBox(width: 2),
              Text(anime.score!.toStringAsFixed(1)),
              const SizedBox(width: 8),
            ],
            if (anime.type != null) Text(anime.type!),
          ],
        ),
        trailing: const Icon(Icons.chevron_right),
      ),
    );
  }

左侧是圆角封面图,中间是标题和副标题,右侧是箭头图标。标题最多显示两行,超出部分用省略号。副标题显示评分和类型,用if语句处理可能为null的情况。点击整个列表项跳转到详情页。


封面图片的加载处理

网络图片加载需要处理各种异常情况:

  Widget _buildImage() {
    final imageUrl = anime.imageUrl;
    if (imageUrl == null || imageUrl.isEmpty) {
      return Container(
        color: Colors.grey[300],
        child: const Icon(Icons.movie),
      );
    }

    return Image.network(
      imageUrl,
      fit: BoxFit.cover,
      loadingBuilder: (context, child, loadingProgress) {
        if (loadingProgress == null) return child;
        return Container(color: Colors.grey[300]);
      },
      errorBuilder: (context, error, stackTrace) {
        return Container(
          color: Colors.grey[300],
          child: const Icon(Icons.movie),
        );
      },
    );
  }
}

先检查URL是否有效,无效就显示占位图。loadingBuilder在图片加载过程中显示灰色背景,errorBuilder在加载失败时显示默认图标。这样不管网络状况如何,界面都不会出现空白或报错。


骨架屏加载动画

搜索过程中显示骨架屏,比转圈圈的体验好很多:

import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';

class ShimmerLoading extends StatelessWidget {
  final int itemCount;
  final bool isGrid;

  const ShimmerLoading({super.key, this.itemCount = 6, this.isGrid = true});

骨架屏组件支持两种模式:网格和列表。搜索结果用列表模式,首页推荐用网格模式。itemCount控制显示几个占位项。


骨架屏的深色模式适配

骨架屏的颜色要跟随主题变化:

  
  Widget build(BuildContext context) {
    final isDark = Theme.of(context).brightness == Brightness.dark;
    final baseColor = isDark ? Colors.grey[800]! : Colors.grey[300]!;
    final highlightColor = isDark ? Colors.grey[700]! : Colors.grey[100]!;

通过Theme.of(context).brightness判断当前是深色还是浅色模式,然后设置对应的底色和高亮色。深色模式下用深灰色,浅色模式下用浅灰色,这样骨架屏在任何主题下都能和谐融入界面。


列表模式的骨架屏

搜索结果用列表形式的骨架屏:

    return ListView.builder(
      padding: const EdgeInsets.all(16),
      itemCount: itemCount,
      itemBuilder: (_, __) => Shimmer.fromColors(
        baseColor: baseColor,
        highlightColor: highlightColor,
        child: Container(
          height: 80,
          margin: const EdgeInsets.only(bottom: 12),
          decoration: BoxDecoration(
            color: Colors.white,
            borderRadius: BorderRadius.circular(12),
          ),
        ),
      ),
    );
  }
}

每个占位项高度80,底部间距12,圆角12。Shimmer.fromColors会让这些灰色方块产生闪烁效果,模拟加载中的状态。这种效果比单纯的loading圈更能让用户感知到"内容即将出现"。


搜索历史的Provider实现

搜索历史需要持久化存储,用Provider管理状态:

import 'package:flutter/material.dart';
import '../services/storage_service.dart';

class SearchProvider extends ChangeNotifier {
  List<String> _searchHistory = [];

  List<String> get searchHistory => _searchHistory;

  SearchProvider() {
    _loadSearchHistory();
  }

继承ChangeNotifier让这个类具备通知监听者的能力。构造函数里调用_loadSearchHistory从本地存储加载历史记录,这样App重启后历史记录还在。


加载历史记录

从本地存储读取之前保存的搜索历史:

  Future<void> _loadSearchHistory() async {
    try {
      await StorageService.instance.init();
      _searchHistory = StorageService.instance.getStringList('searchHistory') ?? [];
      notifyListeners();
    } catch (e) {
      print('Error loading search history: $e');
    }
  }

先确保存储服务初始化完成,然后读取searchHistory这个key对应的字符串列表。如果没有历史记录就返回空列表。加载完成后调用notifyListeners通知UI更新。


添加搜索记录

每次搜索成功后把关键词加入历史:

  Future<void> addSearch(String query) async {
    try {
      if (query.trim().isEmpty) return;
      _searchHistory.remove(query);
      _searchHistory.insert(0, query);
      if (_searchHistory.length > 20) {
        _searchHistory = _searchHistory.sublist(0, 20);
      }
      await StorageService.instance.setStringList('searchHistory', _searchHistory);
      notifyListeners();
    } catch (e) {
      print('Error adding search: $e');
    }
  }

先移除已存在的相同记录,再插入到列表开头,这样最近搜索的总是排在最前面。限制最多保存20条,超出的自动删除。每次修改都同步到本地存储,保证数据不丢失。


删除和清空历史

支持删除单条记录和清空全部:

  Future<void> removeSearch(String query) async {
    try {
      _searchHistory.remove(query);
      await StorageService.instance.setStringList('searchHistory', _searchHistory);
      notifyListeners();
    } catch (e) {
      print('Error removing search: $e');
    }
  }

  Future<void> clearSearchHistory() async {
    try {
      _searchHistory.clear();
      await StorageService.instance.remove('searchHistory');
      notifyListeners();
    } catch (e) {
      print('Error clearing search history: $e');
    }
  }
}

删除单条用remove方法,清空全部用clear方法。清空时直接从存储中移除整个key,比存一个空列表更干净。所有操作都包在try-catch里,存储出问题也不会影响App正常运行。


小结

搜索功能看起来简单,实际涉及的细节不少:输入框的交互、多种状态的切换、历史记录的管理、结果列表的展示、加载动画的处理。把这些功能拆分成独立的组件和Provider,代码结构会清晰很多,后期维护也方便。

这套搜索方案在OpenHarmony设备上跑得很稳,响应速度也不错。如果你的App也需要搜索功能,可以直接参考这个实现。


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

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐