Flutter for OpenHarmony 微动漫App实战:搜索功能实现
本文介绍如何用Flutter实现动漫App的搜索功能,包含搜索框交互、历史记录管理、搜索结果展示等功能。文章提供了完整代码实现,包括状态管理(搜索关键词、结果列表、加载状态)、搜索逻辑(API请求、错误处理)、UI组件(AppBar搜索框、骨架屏加载动画、空状态提示)等核心模块。重点讲解了四种状态的条件渲染逻辑:未搜索时显示历史记录、加载中显示骨架屏、无结果显示空状态、有结果则用列表展示。搜索历史
通过网盘分享的文件: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负责发起搜索请求,AnimeListTile和ShimmerLoading是展示搜索结果和加载状态的组件。把这些功能拆分成独立模块,代码会清晰很多。
状态变量的设计
搜索页面需要管理几个核心状态:
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
更多推荐
所有评论(0)