02-rn_for_openharmony狗狗之家app实战-搜索实现
本文详细介绍了狗狗之家应用搜索功能的实现过程。首先从用户体验角度分析了搜索流程的设计考量,包括输入引导、结果展示等细节。然后重点讲解了搜索页面的状态管理(keyword、loading、results、searched四种状态)、搜索接口调用逻辑以及类型定义。文章还深入剖析了SearchBar组件的实现,包括受控组件设计、UI布局和样式处理。最后提到了Empty组件的封装,用于处理不同空状态展示。
案例开源地址:https://atomgit.com/nutpi/rn_openharmony_dogimg
写在前面
上一篇聊了首页的实现,这篇来说说搜索功能。搜索看起来简单,不就是一个输入框加个列表嘛,但真正做起来会发现细节还挺多的。
比如说,用户输入的时候要不要实时搜索?搜索结果为空怎么展示?输入框怎么设计才好用?这些问题都得考虑清楚。
狗狗之家的搜索功能主要是搜品种,用的是 The Dog API 提供的品种搜索接口。这个接口只支持英文搜索,所以界面上得给用户一些提示。
从用户角度思考
在写代码之前,我先想了想用户会怎么用这个搜索功能:
用户从首页点击搜索栏进来,看到一个输入框。这时候页面应该显示什么?空白肯定不行,得有点引导性的内容。我的做法是显示一个提示,告诉用户"输入英文品种名称开始搜索"。
用户开始输入,输入完按搜索键,页面显示加载状态,然后展示结果。如果没搜到,得告诉用户"未找到相关品种",而不是显示空白让人摸不着头脑。
搜到结果后,用户点击某个品种卡片,跳转到品种详情页。这个流程要顺畅,不能有卡顿。
想清楚这些,代码写起来就有方向了。
搜索页面的状态设计
搜索页面需要管理几个状态,先看看怎么定义的:
const [keyword, setKeyword] = useState('');
const [loading, setLoading] = useState(false);
const [results, setResults] = useState<Breed[]>([]);
const [searched, setSearched] = useState(false);
这四个状态各有用处:
keyword存储用户输入的搜索关键词,类型是字符串loading标记是否正在请求接口,用于显示加载动画results存储搜索结果,是一个品种数组searched标记用户是否已经执行过搜索
为什么要单独搞一个 searched 状态?因为要区分两种情况:用户还没搜索 和 用户搜了但没结果。这两种情况显示的内容不一样,前者显示引导文案,后者显示"未找到"的提示。
如果只用 results.length === 0 来判断,没法区分这两种情况。
搜索接口调用
搜索的核心逻辑在 search 函数里:
const search = async () => {
if (!keyword.trim()) return;
setLoading(true);
setSearched(true);
try {
const res = await api.searchBreeds(keyword.trim());
setResults(res);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
逐行解释一下这段代码:
第一行做了空值校验。keyword.trim() 去掉首尾空格,如果结果是空字符串就直接 return,不发请求。这样可以避免用户只输入空格就点搜索的情况。
第二三行设置状态。setLoading(true) 开始显示加载动画,setSearched(true) 标记已经执行过搜索。注意这两个要在请求之前设置,不然用户会看到一瞬间的旧状态。
try 块里调用搜索接口。api.searchBreeds 是封装好的接口方法,传入关键词,返回品种数组。拿到结果后用 setResults 更新状态。
catch 块捕获异常,这里只是打印了错误日志。实际项目中可能要给用户一个提示,比如弹个 Toast 说"网络错误"。
finally 块不管成功失败都会执行,把 loading 设为 false,结束加载状态。
搜索接口的封装
搜索用到的接口在 api.ts 里:
searchBreeds: (q: string) => http.get<Breed[]>('/breeds/search', {q}),
这行代码做了几件事:
- 定义了一个
searchBreeds方法,接收搜索关键词q - 调用
http.get发起 GET 请求 - 请求路径是
/breeds/search - 参数是
{q},这是 ES6 的简写,等价于{q: q} - 泛型
<Breed[]>指定返回类型是品种数组
The Dog API 的搜索接口是模糊匹配的,比如搜 “lab” 会返回 Labrador Retriever(拉布拉多)。这个特性挺好用,用户不用输入完整的品种名。
品种类型定义
搜索结果是品种数组,看看 Breed 类型是怎么定义的:
export interface Breed {
id: number;
name: string;
bred_for?: string;
breed_group?: string;
life_span: string;
temperament?: string;
origin?: string;
weight: { metric: string };
height: { metric: string };
reference_image_id?: string;
image?: { url: string };
}
字段挺多的,挑几个重要的说:
id和name是必有的,分别是品种 ID 和名称breed_group是品种分组,比如 “Working”、“Sporting”,但不是所有品种都有temperament是性格特点,比如 “Friendly, Active, Outgoing”weight和height是体重和身高范围,注意是对象格式image是图片信息,但有些品种没有,所以是可选的reference_image_id是图片 ID,可以用来拼接图片 URL
类型定义里带 ? 的字段表示可选,使用时要注意判空。
SearchBar 组件设计
搜索框是个通用组件,单独封装出来方便复用。先看 Props 定义:
interface Props {
value: string;
onChange: (v: string) => void;
placeholder?: string;
onSubmit?: () => void;
}
四个属性的作用:
value:输入框的值,由父组件控制(受控组件模式)onChange:值变化时的回调,参数是新的值placeholder:占位文本,可选,默认是"搜索…"onSubmit:提交搜索时的回调,可选
为什么用受控组件?因为父组件需要知道用户输入了什么,才能在点击搜索时拿到关键词。如果用非受控组件,还得通过 ref 去取值,麻烦。
SearchBar 的 UI 实现
export function SearchBar({value, onChange, placeholder = '搜索...', onSubmit}: Props) {
return (
<View style={s.box}>
<Text style={s.icon}>🔍</Text>
<TextInput
style={s.input}
value={value}
onChangeText={onChange}
placeholder={placeholder}
placeholderTextColor="#999"
returnKeyType="search"
onSubmitEditing={onSubmit}
/>
{value.length > 0 && (
<TouchableOpacity onPress={() => onChange('')}>
<Text style={s.clear}>✕</Text>
</TouchableOpacity>
)}
</View>
);
}
这个组件的结构是:搜索图标 + 输入框 + 清除按钮。
TextInput 的几个属性值得说一下:
onChangeText比onChange更方便,直接拿到字符串,不用从 event 里取placeholderTextColor设置占位文本颜色,不设的话在某些平台上可能看不清returnKeyType="search"让键盘的回车键显示为"搜索"onSubmitEditing在用户按回车键时触发,正好用来执行搜索
清除按钮用了条件渲染:value.length > 0 &&,只有输入框有内容时才显示。点击清除按钮调用 onChange(''),把值设为空字符串。
SearchBar 的样式处理
const s = StyleSheet.create({
box: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#f5f5f5',
borderRadius: 10,
margin: 16,
paddingHorizontal: 12,
height: 42
},
icon: {fontSize: 16, marginRight: 8},
input: {flex: 1, fontSize: 15, color: '#333', padding: 0},
clear: {fontSize: 14, color: '#999', padding: 4},
});
样式设计的几个考虑:
容器用灰色背景而不是白色边框,这样看起来更柔和,也更符合现代 App 的设计风格。
固定高度 42,这个高度在手机上点击起来比较舒服,太矮了不好点,太高了占空间。
输入框 padding: 0 是为了去掉默认的内边距,不然在某些平台上输入框会有额外的空白。
清除按钮加了 padding: 4,扩大点击区域。那个 ✕ 本身很小,不加 padding 的话很难点中。
Empty 组件的设计
搜索页面有两种空状态需要展示,我封装了一个通用的 Empty 组件:
interface Props {
icon?: string;
title?: string;
desc?: string;
btnText?: string;
onPress?: () => void;
}
这个组件很灵活,可以配置:
icon:显示的图标,用 emojititle:主标题desc:描述文字btnText和onPress:可选的操作按钮
所有属性都是可选的,有默认值。这样调用的时候可以只传需要的:
<Empty icon="🔍" title="搜索狗狗品种" desc="输入英文品种名称开始搜索" />
或者:
<Empty icon="🐕" title="未找到相关品种" desc="试试其他关键词" />
同一个组件,不同的配置,展示不同的内容。
Empty 组件的实现
export function Empty({icon = '📭', title = '暂无数据', desc, btnText, onPress}: Props) {
return (
<View style={s.box}>
<Text style={s.icon}>{icon}</Text>
<Text style={s.title}>{title}</Text>
{desc && <Text style={s.desc}>{desc}</Text>}
{btnText && onPress && (
<TouchableOpacity style={s.btn} onPress={onPress}>
<Text style={s.btnText}>{btnText}</Text>
</TouchableOpacity>
)}
</View>
);
}
参数解构时给了默认值:icon = '📭'、title = '暂无数据'。这样即使什么都不传,也能显示一个基本的空状态。
desc 和按钮用条件渲染,有值才显示。按钮需要同时有 btnText 和 onPress 才显示,缺一不可。
样式上让内容垂直水平居中:
box: {flex: 1, alignItems: 'center', justifyContent: 'center', padding: 40},
flex: 1 让容器撑满父元素,alignItems 和 justifyContent 都设为 center,内容就居中了。
BreedCard 组件
搜索结果用卡片形式展示,每个品种一张卡片:
export function BreedCard({breed, onPress}: Props) {
const img = breed.image?.url || `https://cdn2.thedogapi.com/images/${breed.reference_image_id}.jpg`;
return (
<TouchableOpacity style={s.card} onPress={onPress} activeOpacity={0.85}>
<Image source={{uri: img}} style={s.img} />
<View style={s.info}>
<Text style={s.name} numberOfLines={1}>{breed.name}</Text>
<Text style={s.group}>{breed.breed_group || '未分类'}</Text>
{breed.temperament && <Text style={s.temp} numberOfLines={2}>{breed.temperament}</Text>}
</View>
</TouchableOpacity>
);
}
图片 URL 的处理是个重点。API 返回的数据有两种情况:有的品种 image.url 直接就是图片地址,有的只有 reference_image_id。所以用了一个兜底逻辑:
const img = breed.image?.url || `https://cdn2.thedogapi.com/images/${breed.reference_image_id}.jpg`;
先尝试取 image.url,取不到就用 id 拼接 CDN 地址。
品种分组也做了兜底:breed.breed_group || '未分类'。有些品种没有分组信息,显示"未分类"比显示空白好。
性格特点用条件渲染:breed.temperament &&。不是所有品种都有这个字段,没有就不显示这一行。
numberOfLines 限制文本行数,超出部分显示省略号,避免内容太长把卡片撑得很高。
BreedCard 的样式
const s = StyleSheet.create({
card: {
backgroundColor: '#fff',
borderRadius: 12,
marginHorizontal: 16,
marginVertical: 8,
overflow: 'hidden',
elevation: 2,
shadowColor: '#000',
shadowOpacity: 0.08,
shadowRadius: 4
},
img: {width: '100%', height: 180, backgroundColor: '#f0f0f0'},
info: {padding: 12},
name: {fontSize: 18, fontWeight: '600', color: '#333'},
group: {fontSize: 13, color: '#D2691E', marginTop: 2},
temp: {fontSize: 13, color: '#666', marginTop: 6, lineHeight: 18},
});
卡片样式的几个细节:
阴影效果用了两套属性。elevation 是 Android 的阴影,shadowXxx 是 iOS 的阴影。两套都写上才能跨平台生效。
图片宽度 100%,高度固定 180。这样不管屏幕多宽,图片都能撑满卡片,比例也比较协调。
品种分组用主题色 #D2691E,和 App 整体风格统一,也起到强调作用。
性格特点设了 lineHeight: 18,行高稍微大一点,多行文本读起来更舒服。
搜索页面的条件渲染
搜索页面的主体部分用了多重条件渲染:
{loading ? <Loading full /> : !searched ? (
<Empty icon="🔍" title="搜索狗狗品种" desc="输入英文品种名称开始搜索" />
) : results.length > 0 ? (
<ScrollView style={s.list}>
{results.map(b => <BreedCard key={b.id} breed={b} onPress={() => navigate('BreedDetail', {breed: b})} />)}
</ScrollView>
) : (
<Empty icon="🐕" title="未找到相关品种" desc="试试其他关键词" />
)}
这段三元表达式嵌套看起来有点绕,拆解一下逻辑:
第一层判断:loading 为 true 吗?是的话显示加载动画。
第二层判断:searched 为 false 吗?是的话说明用户还没搜索过,显示引导文案。
第三层判断:results.length > 0 吗?是的话显示搜索结果列表。
兜底情况:以上都不满足,说明搜了但没结果,显示"未找到"提示。
这种写法虽然紧凑,但嵌套多了可读性会下降。如果逻辑更复杂,建议拆成独立的渲染函数。
结果列表的渲染
<ScrollView style={s.list}>
{results.map(b => (
<BreedCard
key={b.id}
breed={b}
onPress={() => navigate('BreedDetail', {breed: b})}
/>
))}
</ScrollView>
用 map 遍历结果数组,每个品种渲染一个 BreedCard。
key={b.id} 是 React 要求的,用于优化列表渲染性能。用品种 ID 作为 key 是合适的,因为 ID 是唯一的。
点击卡片时调用 navigate,跳转到品种详情页,同时把品种数据传过去。这样详情页就不用再请求一次接口了,直接用传过来的数据渲染。
一些可以优化的点
当前的实现能用,但还有优化空间:
搜索防抖:现在是用户按搜索键才触发搜索。如果想做实时搜索(边输入边搜索),需要加防抖,不然每输入一个字符都发请求,太浪费了。
搜索历史:可以把用户搜过的关键词存到本地,下次进来显示历史记录,点击直接搜索。
热门搜索:在引导页面显示一些热门的品种名称,用户点击直接搜索,降低使用门槛。
加载更多:如果搜索结果很多,可以做分页加载,而不是一次性全部返回。
这些功能后续有时间可以加上,目前先保证核心流程跑通。
关于英文搜索的问题
The Dog API 只支持英文搜索,这对国内用户不太友好。有几个解决思路:
一是在本地维护一份中英文对照表,用户输入中文时先转成英文再搜索。但这样维护成本比较高,品种那么多,很难覆盖全。
二是接入翻译 API,把用户输入的中文翻译成英文。但这样多了一次网络请求,而且翻译结果不一定准确。
三是干脆不做中文搜索,在界面上明确提示用户输入英文。这是目前采用的方案,简单直接,用户也能理解。
总结
搜索功能的实现就是这些了。回顾一下关键点:
用四个状态管理搜索流程,特别是 searched 状态用于区分"未搜索"和"无结果"两种情况。
封装了三个可复用的组件:SearchBar 处理输入,Empty 处理空状态,BreedCard 展示结果。
条件渲染处理多种页面状态,让用户在任何情况下都能看到合适的内容。
下一篇会讲趣味知识页面的实现,那个页面的数据是本地 mock 的,和这篇的网络请求有所不同,可以对比着看。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐


所有评论(0)