案例开源地址: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 };
}

字段挺多的,挑几个重要的说:

  • idname 是必有的,分别是品种 ID 和名称
  • breed_group 是品种分组,比如 “Working”、“Sporting”,但不是所有品种都有
  • temperament 是性格特点,比如 “Friendly, Active, Outgoing”
  • weightheight 是体重和身高范围,注意是对象格式
  • 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 的几个属性值得说一下:

  • onChangeTextonChange 更方便,直接拿到字符串,不用从 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:显示的图标,用 emoji
  • title:主标题
  • desc:描述文字
  • btnTextonPress:可选的操作按钮

所有属性都是可选的,有默认值。这样调用的时候可以只传需要的:

<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 和按钮用条件渲染,有值才显示。按钮需要同时有 btnTextonPress 才显示,缺一不可。

样式上让内容垂直水平居中:

box: {flex: 1, alignItems: 'center', justifyContent: 'center', padding: 40},

flex: 1 让容器撑满父元素,alignItemsjustifyContent 都设为 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

Logo

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

更多推荐