rn_for_openharmony_steam资讯app实战-搜索结果实现
React Native for OpenHarmony 实战:Steam 资讯 App 搜索结果页面 本项目实现了 Steam 游戏资讯 App 的搜索结果页面功能,核心包括: 搜索结果展示 - 显示匹配搜索词的游戏列表,包含游戏名称、Logo 和价格信息 排序功能 - 支持按相关性、价格升序/降序、评分等多种排序方式 过滤功能 - 可按免费、付费、打折等条件筛选游戏 状态管理 - 采用分离式状
React Native for OpenHarmony 实战:Steam 资讯 App 搜索结果页面
案例开源地址:https://atomgit.com/nutpi/rn_openharmony_steam
上一篇我们实现了搜索功能,用户可以输入关键词搜索游戏。这一篇来聊搜索结果页面的实现。搜索结果页面需要展示搜索到的游戏列表,支持排序、过滤等功能,让用户能更方便地找到想要的游戏。
搜索结果页面的需求
搜索结果页面的核心功能包括:
- 展示搜索结果列表 - 显示匹配搜索词的游戏
- 排序功能 - 支持按相关性、价格、评分等排序
- 过滤功能 - 支持按游戏类型、价格范围等过滤
- 分页加载 - 当结果很多时,支持加载更多
- 结果统计 - 显示搜索结果的总数
这些功能看起来复杂,但实现起来其实不难。关键是要理清数据流和状态管理。
为什么需要搜索结果页面
在实际开发中,我发现搜索功能不能只停留在"能搜"的阶段。用户搜索出来的结果可能有几十甚至几百个,如果不提供排序和过滤的能力,用户就很难从这么多结果中找到真正想要的游戏。
比如用户搜索"RPG",可能会得到几百个结果。如果没有排序功能,用户只能一个一个往下翻。如果有按价格排序的功能,用户可以快速找到便宜的 RPG 游戏。如果有按类型过滤的功能,用户可以只看免费的 RPG 游戏。
所以搜索结果页面的设计,直接影响到用户的搜索体验。
搜索结果的数据结构
从 Steam API 获取的搜索结果数据结构大概是这样的:
{
"total": 150,
"apps": [
{
"id": 730,
"name": "Counter-Strike 2",
"logo": "https://cdn.cloudflare.steamstatic.com/steam/apps/730/logo.png",
"price": "免费游玩"
}
]
}
关键字段说明:
total- 搜索结果总数,告诉用户一共有多少个匹配的游戏apps- 游戏列表数组,包含所有搜索到的游戏id- 游戏的 AppId,这是 Steam 中游戏的唯一标识,用于跳转详情页name- 游戏名称,直接显示给用户logo- 游戏 logo 图片 URL,用于展示游戏的缩略图price- 游戏价格,可能是具体价格、"免费游玩"或其他价格信息
我们需要在这个基础上,添加排序和过滤的逻辑。API 返回的数据是原始的,我们需要根据用户的选择进行处理。
排序和过滤的实现
首先定义排序和过滤的选项。这里用 TypeScript 的类型系统来确保类型安全:
type SortType = 'relevance' | 'price_asc' | 'price_desc' | 'rating';
type FilterType = 'all' | 'free' | 'paid' | 'discount';
interface SearchFilters {
sort: SortType;
filter: FilterType;
priceMin?: number;
priceMax?: number;
}
这里的设计思路:
SortType- 定义了四种排序方式:相关性(API 返回的默认顺序)、价格升序、价格降序、评分。相关性是默认的,因为 Steam API 已经按相关性排序了FilterType- 定义了四种过滤方式:全部游戏、只显示免费游戏、只显示付费游戏、只显示打折游戏SearchFilters- 组合排序和过滤条件,还支持价格范围过滤(虽然这篇文章暂时没用到)
接下来实现排序和过滤的核心逻辑。这个函数会接收原始的游戏列表和过滤条件,返回处理后的列表:
const applyFiltersAndSort = (games: any[], filters: SearchFilters) => {
let result = [...games];
// 应用过滤
if (filters.filter === 'free') {
result = result.filter(g => g.price === '免费游玩' || g.price === '免费');
} else if (filters.filter === 'paid') {
result = result.filter(g => g.price && g.price !== '免费游玩' && g.price !== '免费');
} else if (filters.filter === 'discount') {
result = result.filter(g => g.discount_percent && g.discount_percent > 0);
}
// 应用排序
if (filters.sort === 'price_asc') {
result.sort((a, b) => {
const priceA = parseFloat(a.price) || 0;
const priceB = parseFloat(b.price) || 0;
return priceA - priceB;
});
} else if (filters.sort === 'price_desc') {
result.sort((a, b) => {
const priceA = parseFloat(a.price) || 0;
const priceB = parseFloat(b.price) || 0;
return priceB - priceA;
});
}
return result;
};
这里的实现细节:
- 创建副本 - 用
[...games]创建一个新数组,避免修改原始数据。这样做的好处是,如果用户改变过滤条件,我们可以基于原始数据重新过滤,而不是基于已经过滤过的数据 - 过滤逻辑 - 根据
filter类型过滤游戏列表。注意这里用的是filter()方法,它会返回一个新数组 - 排序逻辑 - 根据
sort类型对列表进行排序。用sort()方法,它会修改原数组(但这里没关系,因为我们已经创建了副本) - 价格解析 - 用
parseFloat将价格字符串转换成数字,便于比较。如果转换失败,用|| 0作为默认值
这个函数的设计很关键。它是纯函数,不会修改输入的数据,只返回新的结果。这样的设计让代码更容易测试和维护。
搜索结果页面的状态管理
搜索结果页面需要管理的状态比较多。让我们逐个分析每个状态的作用:
export const SearchResultsScreen = () => {
const {navigate, setSelectedAppId, addToHistory} = useApp();
const [searchQuery, setSearchQuery] = useState('');
const [allResults, setAllResults] = useState<any[]>([]);
const [displayResults, setDisplayResults] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [filters, setFilters] = useState<SearchFilters>({
sort: 'relevance',
filter: 'all',
});
const [showFilters, setShowFilters] = useState(false);
const [pageIndex, setPageIndex] = useState(0);
状态的作用:
searchQuery- 当前的搜索词,用于显示在页面顶部,告诉用户搜索的是什么allResults- 所有搜索结果(未过滤)。这很重要,因为当用户改变过滤条件时,我们需要基于这个原始数据重新过滤displayResults- 显示的结果(已过滤和排序)。这是最终要显示给用户的数据loading- 加载状态,用于显示 Loading 组件或隐藏它filters- 当前的排序和过滤条件。初始值是相关性排序、显示全部游戏showFilters- 是否显示过滤面板。用户点击"排序"按钮时设为 true,点击"应用"或"关闭"时设为 falsepageIndex- 当前页码(用于分页加载)。初始值是 0,表示第一页
这样的状态设计有个好处:allResults 和 displayResults 的分离,让我们可以高效地处理过滤。用户改变过滤条件时,不需要重新请求 API,只需要基于 allResults 重新计算 displayResults。
搜索结果的获取和处理
当用户从搜索页面跳转过来时,需要获取搜索结果。这里用 useEffect 来处理副作用:
useEffect(() => {
const loadSearchResults = async () => {
if (!searchQuery) return;
setLoading(true);
try {
const data = await searchGames(searchQuery);
const apps = data?.apps || [];
setAllResults(apps);
// 应用初始的排序和过滤
const filtered = applyFiltersAndSort(apps, filters);
setDisplayResults(filtered);
} catch (error) {
console.error('Search error:', error);
} finally {
setLoading(false);
}
};
loadSearchResults();
}, [searchQuery]);
这里的流程:
- 检查搜索词 - 如果搜索词为空,直接返回,不执行后续逻辑
- 设置加载状态 - 设为 true,这样页面会显示 Loading 组件
- 调用搜索 API - 使用
searchGames函数获取搜索结果 - 提取游戏列表 - 从 API 响应中提取
apps字段,如果不存在则用空数组 - 存储原始结果 - 将结果存储到
allResults,这是后续过滤的基础 - 应用排序和过滤 - 调用
applyFiltersAndSort函数,得到displayResults - 错误处理 - 用 try-catch 捕获错误,即使出错也会把
loading设为 false - 依赖数组 -
[searchQuery]表示只有当搜索词变化时才重新执行
这个 useEffect 的设计很重要。它确保了每当用户搜索新的关键词时,都会重新获取结果。
过滤条件变化时的处理
当用户改变排序或过滤条件时,需要重新处理结果。这里用另一个 useEffect 来处理:
useEffect(() => {
const filtered = applyFiltersAndSort(allResults, filters);
setDisplayResults(filtered);
setPageIndex(0); // 重置页码
}, [filters]);
const handleFilterChange = (newFilters: Partial<SearchFilters>) => {
setFilters(prev => ({...prev, ...newFilters}));
};
这里的设计:
- 依赖数组 -
[filters]表示只有当过滤条件变化时才重新计算 - 重新计算 - 基于
allResults和新的filters重新计算displayResults - 重置页码 - 因为过滤后的结果数量可能变化,所以要重置页码为 0
- handleFilterChange - 这是一个辅助函数,用于更新过滤条件。用
...prev保留之前的条件,只更新新传入的条件
这样的设计让过滤逻辑很清晰:用户改变条件 → filters 变化 → useEffect 触发 → 重新计算 displayResults → 页面自动更新。
搜索结果列表的渲染
搜索结果列表用 FlatList 渲染,支持分页加载。这里只展示关键部分:
<FlatList
data={displayResults.slice(0, (pageIndex + 1) * 10)}
keyExtractor={(item) => item.id.toString()}
renderItem={({item}) => (
<TouchableOpacity
style={styles.resultItem}
onPress={() => {
setSelectedAppId(item.id);
addToHistory(item.id);
navigate('gameDetail');
}}
>
<Image
source={{uri: item.logo}}
style={styles.resultLogo}
cache="force-cache"
/>
<View style={styles.resultInfo}>
<Text style={styles.resultName} numberOfLines={1}>{item.name}</Text>
<View style={styles.resultMeta}>
<Text style={styles.resultPrice}>{item.price}</Text>
{item.discount_percent > 0 && (
<View style={styles.discountBadge}>
<Text style={styles.discountText}>-{item.discount_percent}%</Text>
</View>
)}
</View>
</View>
</TouchableOpacity>
)}
onEndReached={() => {
if (pageIndex < Math.ceil(displayResults.length / 10) - 1) {
setPageIndex(prev => prev + 1);
}
}}
onEndReachedThreshold={0.5}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>未找到相关游戏</Text>
</View>
}
/>
这里的关键点:
- 分页加载 -
displayResults.slice(0, (pageIndex + 1) * 10)表示每次显示 10 条结果。当pageIndex为 0 时显示前 10 条,为 1 时显示前 20 条,以此类推 - keyExtractor - 用游戏 ID 作为 key,确保列表项的唯一性
- renderItem - 每个列表项显示游戏 logo、名称、价格和折扣信息
- 点击处理 - 点击游戏卡片时,设置选中的游戏 ID、添加到浏览历史、跳转到详情页
- onEndReached - 当用户滚动到列表底部时触发。这里检查是否还有更多页面,如果有就增加
pageIndex - onEndReachedThreshold - 设为 0.5,表示当用户滚动到列表底部 50% 时触发
onEndReached。这样用户不需要完全滚到底部就能触发加载 - ListEmptyComponent - 当搜索结果为空时显示这个组件,提示用户"未找到相关游戏"
分页加载的好处是显而易见的:不需要一次性加载所有结果,可以减少内存占用,提高列表滚动的流畅度。
过滤面板的实现
过滤面板用 Modal 实现,用户点击"排序"按钮时显示。这里展示排序选项部分:
<Modal
visible={showFilters}
transparent
animationType="slide"
onRequestClose={() => setShowFilters(false)}
>
<View style={styles.filterModal}>
<View style={styles.filterHeader}>
<Text style={styles.filterTitle}>排序和过滤</Text>
<TouchableOpacity onPress={() => setShowFilters(false)}>
<Text style={styles.closeBtn}>✕</Text>
</TouchableOpacity>
</View>
<ScrollView style={styles.filterContent}>
<View style={styles.filterSection}>
<Text style={styles.filterSectionTitle}>排序方式</Text>
{[
{value: 'relevance', label: '相关性'},
{value: 'price_asc', label: '价格:低到高'},
{value: 'price_desc', label: '价格:高到低'},
].map(option => (
<TouchableOpacity
key={option.value}
style={styles.filterOption}
onPress={() => handleFilterChange({sort: option.value as SortType})}
>
<View style={[styles.radio, filters.sort === option.value && styles.radioSelected]} />
<Text style={styles.filterOptionText}>{option.label}</Text>
</TouchableOpacity>
))}
</View>
这里的设计:
- Modal 组件 - 用
animationType="slide"让面板从下往上滑出来,这是常见的 UI 模式 - filterHeader - 显示标题和关闭按钮
- filterContent - 用
ScrollView包裹,这样当选项很多时可以滚动 - filterSection - 每个过滤类别(排序、游戏类型等)都是一个 section
- 单选按钮 - 用
map遍历选项数组,为每个选项创建一个单选按钮。当用户点击时,调用handleFilterChange更新过滤条件 - radio 样式 - 根据
filters.sort === option.value判断是否选中,选中时显示radioSelected样式
这样的设计让用户可以很直观地看到当前选中的选项。
过滤选项的完整实现
除了排序,还需要实现游戏类型的过滤。这里展示过滤选项部分:
<View style={styles.filterSection}>
<Text style={styles.filterSectionTitle}>游戏类型</Text>
{[
{value: 'all', label: '全部'},
{value: 'free', label: '免费游戏'},
{value: 'paid', label: '付费游戏'},
{value: 'discount', label: '打折游戏'},
].map(option => (
<TouchableOpacity
key={option.value}
style={styles.filterOption}
onPress={() => handleFilterChange({filter: option.value as FilterType})}
>
<View style={[styles.radio, filters.filter === option.value && styles.radioSelected]} />
<Text style={styles.filterOptionText}>{option.label}</Text>
</TouchableOpacity>
))}
</View>
<TouchableOpacity
style={styles.applyBtn}
onPress={() => setShowFilters(false)}
>
<Text style={styles.applyBtnText}>应用</Text>
</TouchableOpacity>
这里的实现:
- 游戏类型选项 - 提供了四种选择:全部、免费、付费、打折
- 单选逻辑 - 同样用单选按钮实现,用户只能选一个
- 应用按钮 - 点击后关闭面板。注意这里不需要额外的逻辑,因为过滤条件已经在
handleFilterChange中实时更新了
这样的设计让用户可以快速改变过滤条件,而且改变是实时的。
工具栏的实现
在列表上方显示一个工具栏,显示搜索结果的总数和排序按钮:
<View style={styles.toolbar}>
<Text style={styles.resultCount}>找到 {totalCount} 个结果</Text>
<TouchableOpacity
style={styles.filterBtn}
onPress={() => setShowFilters(true)}
>
<Text style={styles.filterBtnText}>⚙️ 排序</Text>
</TouchableOpacity>
</View>
这里的设计:
- 结果统计 - 显示搜索结果的总数,让用户了解有多少个匹配的游戏
- 排序按钮 - 点击打开过滤面板。用齿轮图标表示设置,这是常见的 UI 约定
这个工具栏很简洁,但提供了关键的信息和操作。
完整页面的核心逻辑
现在把所有部分组合在一起。这里展示页面的主要结构:
export const SearchResultsScreen = () => {
// ... 状态定义 ...
if (loading) {
return (
<View style={styles.container}>
<Header title="搜索结果" showBack />
<Loading />
<TabBar />
</View>
);
}
const displayCount = (pageIndex + 1) * 10;
const totalCount = displayResults.length;
return (
<View style={styles.container}>
<Header title="搜索结果" showBack />
<View style={styles.toolbar}>
<Text style={styles.resultCount}>找到 {totalCount} 个结果</Text>
<TouchableOpacity
style={styles.filterBtn}
onPress={() => setShowFilters(true)}
>
<Text style={styles.filterBtnText}>⚙️ 排序</Text>
</TouchableOpacity>
</View>
<FlatList
data={displayResults.slice(0, displayCount)}
keyExtractor={(item) => item.id.toString()}
renderItem={({item}) => (
// ... 列表项渲染 ...
)}
onEndReached={() => {
if (pageIndex < Math.ceil(displayResults.length / 10) - 1) {
setPageIndex(prev => prev + 1);
}
}}
onEndReachedThreshold={0.5}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>未找到相关游戏</Text>
</View>
}
/>
{/* 过滤面板 Modal */}
<Modal visible={showFilters} transparent animationType="slide">
{/* ... 过滤面板内容 ... */}
</Modal>
<TabBar />
</View>
);
};
页面的整体结构:
- Header - 显示"搜索结果"标题和返回按钮
- Toolbar - 显示结果统计和排序按钮
- FlatList - 显示搜索结果列表,支持分页加载
- Modal - 过滤面板,用户点击排序按钮时显示
- TabBar - 底部导航栏
这样的结构很清晰,每个部分都有明确的职责。
样式设计的考虑
样式设计需要考虑几个方面:
const styles = StyleSheet.create({
container: {flex: 1, backgroundColor: '#171a21'},
toolbar: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 12,
backgroundColor: '#1b2838',
borderBottomWidth: 1,
borderBottomColor: '#2a475e',
},
resultCount: {fontSize: 14, color: '#8f98a0'},
filterBtn: {paddingHorizontal: 12, paddingVertical: 6, backgroundColor: '#2a475e', borderRadius: 4},
filterBtnText: {fontSize: 12, color: '#66c0f4'},
});
样式的设计思路:
- 背景色 - 使用 Steam 的深色主题,
#171a21是最深的背景色 - 工具栏 - 用
flexDirection: 'row'让内容横排显示,justifyContent: 'space-between'让内容两端对齐 - 按钮样式 - 用
#2a475e作为背景色,这是比主背景色稍浅的颜色,能清晰地区分按钮 - 文字颜色 - 主要文字用
#acdbf5(浅蓝色),次要文字用#8f98a0(灰色)
这样的配色方案保持了整个应用的视觉统一性。
关键实现细节总结
分页加载的优化: 每次加载 10 条结果,而不是一次性加载所有结果。这样可以减少内存占用,提高列表滚动的流畅度。特别是当搜索结果有几百条时,这个优化就很明显了。
排序和过滤的分离: 将原始结果存储在 allResults 中,排序和过滤后的结果存储在 displayResults 中。这样做的好处是,改变过滤条件时,不需要重新请求 API,只需要基于原始数据重新计算。这大大提高了应用的响应速度。
实时过滤反馈: 用户改变过滤条件时,结果会立即更新。这样用户能看到过滤的效果,不需要点击"应用"按钮。这提升了用户体验。
结果统计: 显示搜索结果的总数,让用户了解有多少个匹配的游戏。这个信息很重要,因为它告诉用户搜索是否成功。
实际开发中的经验
在实际开发中,我还遇到过一些有趣的问题。比如用户搜索"免费"时,结果可能包含很多免费游戏,但也可能包含一些名字里有"免费"的付费游戏。这时候过滤功能就很有用了,用户可以快速过滤出真正的免费游戏。
还有一个问题是价格的显示。Steam 的价格格式不统一,有的是"¥99",有的是"免费游玩",有的甚至没有价格信息。所以在排序时需要特别处理这些情况。我们用 parseFloat 将价格字符串转换成数字,如果转换失败就用 0 作为默认值。这样即使价格格式不统一,排序也能正常工作。
还有一个性能优化的技巧。当搜索结果很多时(比如几千条),一次性渲染所有结果会导致页面卡顿。所以我们用分页加载的方式,每次只渲染 10 条结果。当用户滚动到底部时,再加载下一页。这样可以大大提高页面的响应速度。
小结
搜索结果页面虽然功能多,但核心逻辑其实很清晰:获取搜索结果、应用排序和过滤、分页显示。关键是要合理管理状态,分离原始数据和处理后的数据。
这样的设计不仅提高了用户体验,也让代码更容易维护和扩展。如果后续要加新的排序或过滤方式,只需要在 applyFiltersAndSort 函数中添加新的逻辑就行。比如要加按评分排序,只需要在 SortType 中加 'rating',然后在 applyFiltersAndSort 中加相应的排序逻辑。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐
所有评论(0)