RN for OpenHarmony AnimeHub项目实战:实现动漫搜索过程
案例开源地址:https://atomgit.com/nutpi/Rn_openharmony_AnimeHub
上一篇讲了首页的实现,这篇来聊聊搜索页。搜索是用户找内容的核心功能,做好了能大大提升使用体验。
搜索页的设计思路
搜索页要解决的问题很简单:让用户快速找到想看的动漫。但实现起来有几个细节要考虑:
- 搜索框要好用,支持键盘回车搜索
- 没搜索之前显示热门搜索推荐,降低用户思考成本
- 搜索结果要展示足够的信息,让用户能判断是不是想要的
- 支持分页加载,结果多的时候不能一次全加载
搜索框组件
先封装一个通用的搜索框组件,后面其他地方可能也会用到:
interface SearchBarProps {
value: string;
onChangeText: (text: string) => void;
onSubmit?: () => void;
placeholder?: string;
autoFocus?: boolean;
}
Props 设计:
value和onChangeText- 受控组件的标准写法,输入值由外部状态控制onSubmit- 用户按下键盘搜索键时的回调placeholder- 占位文字,默认是"搜索动漫…"autoFocus- 是否自动聚焦,有些场景进入页面就要弹出键盘
export const SearchBar: React.FC<SearchBarProps> = ({
value,
onChangeText,
onSubmit,
placeholder = '搜索动漫...',
autoFocus = false,
}) => {
return (
<View style={styles.container}>
<Icon name="search" size={20} color={Colors.textMuted} />
<TextInput
style={styles.input}
value={value}
onChangeText={onChangeText}
onSubmitEditing={onSubmit}
placeholder={placeholder}
placeholderTextColor={Colors.textMuted}
autoFocus={autoFocus}
returnKeyType="search"
/>
{value.length > 0 && (
<TouchableOpacity onPress={() => onChangeText('')}>
<Icon name="close" size={18} color={Colors.textMuted} />
</TouchableOpacity>
)}
</View>
);
};
实现细节:
- 搜索图标 - 放在输入框左边,让用户一眼就知道这是搜索框
- onSubmitEditing - 这是 TextInput 的属性,用户按下键盘的确认键时触发
- returnKeyType=“search” - 把键盘的确认键显示为"搜索",更符合语义
- 清除按钮 - 只有输入了内容才显示,点击后清空输入框
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: Colors.backgroundLight,
borderRadius: BorderRadius.lg,
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
marginHorizontal: Spacing.lg,
marginVertical: Spacing.md,
},
input: {
flex: 1,
fontSize: FontSize.md,
color: Colors.text,
marginLeft: Spacing.sm,
paddingVertical: Spacing.xs,
},
});
样式说明:
搜索框用
flexDirection: 'row'横向排列图标和输入框。背景色用backgroundLight,比页面背景稍亮,形成视觉区分。圆角用BorderRadius.lg让整体看起来更柔和。flex: 1让输入框占满剩余空间。
搜索结果列表项
搜索结果用列表展示,每一项要显示动漫的关键信息:
interface AnimeListItemProps {
anime: Anime;
onPress: () => void;
rank?: number;
}
Props 说明:
anime- 动漫数据对象onPress- 点击时的回调,一般是跳转详情页rank- 可选的排名,在排行榜页面会用到
export const AnimeListItem: React.FC<AnimeListItemProps> = ({ anime, onPress, rank }) => {
return (
<TouchableOpacity style={styles.container} onPress={onPress} activeOpacity={0.7}>
{rank && (
<View style={styles.rankContainer}>
<Text style={[styles.rank, rank <= 3 && styles.topRank]}>#{rank}</Text>
</View>
)}
<Image
source={{ uri: anime.images?.jpg?.image_url }}
style={styles.image}
resizeMode="cover"
/>
<View style={styles.info}>
<Text style={styles.title} numberOfLines={2}>{anime.title}</Text>
{/* 更多信息... */}
</View>
<Icon name="forward" size={16} color={Colors.textMuted} />
</TouchableOpacity>
);
};
布局结构:
- 排名 - 如果传了 rank,显示在最左边,前三名用强调色
- 封面图 - 固定 70x100 的尺寸,用
resizeMode="cover"保持比例裁剪 - 信息区 - 用
flex: 1占满中间空间,显示标题、类型、集数等 - 箭头 - 最右边的小箭头,暗示可以点击进入详情
<View style={styles.meta}>
{anime.type && <Text style={styles.type}>{anime.type}</Text>}
{anime.episodes && <Text style={styles.episodes}>{anime.episodes} 集</Text>}
{anime.year && <Text style={styles.year}>{anime.year}</Text>}
</View>
元信息展示:
类型(TV/Movie/OVA)、集数、年份这些信息用小字显示在标题下方。每个字段都做了判空处理,API 返回的数据不一定每个字段都有值。类型用主题色显示,和其他信息区分开。
{anime.genres && anime.genres.length > 0 && (
<Text style={styles.genres} numberOfLines={1}>
{anime.genres.slice(0, 3).map(g => g.name).join(' · ')}
</Text>
)}
类型标签:
显示动漫的类型标签,比如"动作 · 冒险 · 奇幻"。用
slice(0, 3)最多显示三个,太多了一行放不下。join(' · ')用中点连接,比逗号好看。
<View style={styles.stats}>
{anime.score && <ScoreDisplay score={anime.score} size="sm" />}
{anime.members && (
<View style={styles.members}>
<Icon name="people" size={12} color={Colors.textMuted} />
<Text style={styles.membersText}>
{(anime.members / 1000).toFixed(0)}K
</Text>
</View>
)}
</View>
统计信息:
- 评分 - 用 ScoreDisplay 组件显示,会根据分数高低显示不同颜色
- 人气 - 显示关注人数,除以 1000 后加 K 后缀,比如 “150K”
搜索页状态管理
搜索页的状态比首页复杂一些:
const [query, setQuery] = useState('');
const [results, setResults] = useState<Anime[]>([]);
const [loading, setLoading] = useState(false);
const [searched, setSearched] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
状态说明:
query- 搜索关键词results- 搜索结果数组loading- 是否正在加载searched- 是否已经搜索过,用来区分初始状态和无结果状态page- 当前页码,用于分页加载hasMore- 是否还有更多数据
searched这个状态很重要。刚进入页面时searched是 false,显示热门搜索推荐。用户搜索后searched变成 true,如果没有结果就显示"未找到结果"。如果没有这个状态,就没法区分"还没搜索"和"搜索了但没结果"这两种情况。
搜索逻辑实现
const handleSearch = useCallback(async (searchQuery: string, pageNum = 1) => {
if (!searchQuery.trim()) return;
setLoading(true);
setSearched(true);
try {
const data = await searchAnime(searchQuery, pageNum);
if (pageNum === 1) {
setResults(data.data || []);
} else {
setResults(prev => [...prev, ...(data.data || [])]);
}
setHasMore(data.pagination?.has_next_page || false);
setPage(pageNum);
} catch (error) {
console.error('Search error:', error);
} finally {
setLoading(false);
}
}, []);
核心逻辑:
- 空值检查 -
searchQuery.trim()去掉首尾空格后判断,避免搜索空字符串 - 分页处理 -
pageNum === 1时替换结果,否则追加到现有结果后面 - useCallback - 包裹函数避免不必要的重新创建,这是性能优化的常见做法
const handleSubmit = () => {
handleSearch(query, 1);
};
提交搜索:
用户按下搜索键时调用,从第一页开始搜索。这里把页码写死为 1,因为新的搜索应该从头开始。
const handleLoadMore = () => {
if (!loading && hasMore) {
handleSearch(query, page + 1);
}
};
加载更多:
滚动到底部时触发。先检查是否正在加载和是否还有更多数据,避免重复请求。然后用当前页码加 1 去请求下一页。
const handleQuickSearch = (term: string) => {
setQuery(term);
handleSearch(term, 1);
};
快捷搜索:
点击热门搜索标签时调用。先把关键词填到搜索框里,然后立即执行搜索。这样用户能看到搜索框里有内容,知道搜的是什么。
热门搜索推荐
const POPULAR_SEARCHES = [
'进击的巨人', '鬼灭之刃', '咒术回战', '间谍过家家',
'海贼王', '火影忍者', '龙珠', '死神',
'我的英雄学院', '东京复仇者', '电锯人', '蓝色监狱',
];
推荐词选择:
这些是比较热门的动漫名称,用户大概率会搜这些。放在这里可以降低用户的思考成本,不知道搜什么的时候点一个就行。实际项目中这个列表可以从后端获取,根据真实的搜索热度动态更新。
<View style={styles.suggestions}>
<Text style={styles.suggestionsTitle}>热门搜索</Text>
<View style={styles.tags}>
{POPULAR_SEARCHES.map((term) => (
<TouchableOpacity
key={term}
style={styles.tag}
onPress={() => handleQuickSearch(term)}
>
<Text style={styles.tagText}>{term}</Text>
</TouchableOpacity>
))}
</View>
</View>
标签布局:
- flexWrap: ‘wrap’ - 让标签自动换行,一行放不下就换到下一行
- gap: Spacing.sm - 标签之间的间距,用 gap 比 margin 更简洁
tag: {
backgroundColor: Colors.backgroundLight,
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
borderRadius: BorderRadius.full,
},
标签样式:
用胶囊形状的标签,
borderRadius: BorderRadius.full让两端是圆的。背景色用backgroundLight,和搜索框保持一致。内边距让标签有足够的点击区域,不会太难点。
条件渲染逻辑
搜索页有三种状态,需要显示不同的内容:
{!searched ? (
// 初始状态:显示热门搜索
<View style={styles.suggestions}>
{/* ... */}
</View>
) : results.length === 0 && !loading ? (
// 搜索无结果
<EmptyState
icon="search"
title="未找到结果"
message={`没有找到与"${query}"相关的动漫`}
/>
) : (
// 有结果:显示列表
<FlatList
data={results}
renderItem={renderItem}
{/* ... */}
/>
)}
三种状态:
- 初始状态 -
searched为 false,显示热门搜索推荐 - 无结果 -
searched为 true 且results为空,显示空状态提示 - 有结果 - 显示搜索结果列表
这里用了三元表达式嵌套,虽然看起来有点复杂,但逻辑是清晰的。也可以拆成三个独立的组件,用 if-else 判断显示哪个,看个人喜好。
FlatList 配置
<FlatList
data={results}
renderItem={renderItem}
keyExtractor={(item) => item.mal_id.toString()}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.5}
ListFooterComponent={renderFooter}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.list}
/>
属性说明:
keyExtractor- 指定每项的唯一 key,用动漫 ID 转成字符串onEndReached- 滚动到底部时触发,用于加载更多onEndReachedThreshold- 触发阈值,0.5 表示距离底部还有 50% 高度时就触发ListFooterComponent- 列表底部组件,用来显示加载中的提示
const renderFooter = () => {
if (!loading) return null;
return <Loading text="加载更多..." />;
};
底部加载提示:
只有在加载中才显示,加载完成后返回 null 就不显示了。这样用户滚动到底部时能看到正在加载的反馈。
页面整体结构
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>搜索</Text>
</View>
<SearchBar
value={query}
onChangeText={setQuery}
onSubmit={handleSubmit}
placeholder="搜索动漫名称..."
/>
{/* 条件渲染的内容 */}
</View>
);
布局说明:
- 标题 - 顶部大标题"搜索",和首页的风格保持一致
- 搜索框 - 固定在标题下方,不会随列表滚动
- 内容区 - 根据状态显示热门搜索、空状态或结果列表
header: {
paddingHorizontal: Spacing.lg,
paddingTop: 50,
paddingBottom: Spacing.sm,
},
顶部间距:
paddingTop: 50是为了避开状态栏。在实际项目中可以用 SafeAreaView 或者获取状态栏高度来动态计算,这里简单处理写死了一个值。
小结
搜索页的核心是处理好几种状态的切换:初始状态、加载中、有结果、无结果。用 searched 这个状态变量来区分"还没搜索"和"搜索了但没结果"是个关键技巧。
另外,分页加载的实现也值得注意。FlatList 的 onEndReached 配合 onEndReachedThreshold 可以很方便地实现无限滚动,但要注意防止重复请求,用 loading 和 hasMore 两个状态来控制。
下一篇会讲新番页的实现,涉及到年份和季度的选择器。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐




所有评论(0)