在这里插入图片描述

案例开源地址:https://atomgit.com/nutpi/Rn_openharmony_AnimeHub

上一篇讲了首页的实现,这篇来聊聊搜索页。搜索是用户找内容的核心功能,做好了能大大提升使用体验。

搜索页的设计思路

搜索页要解决的问题很简单:让用户快速找到想看的动漫。但实现起来有几个细节要考虑:

  • 搜索框要好用,支持键盘回车搜索
  • 没搜索之前显示热门搜索推荐,降低用户思考成本
  • 搜索结果要展示足够的信息,让用户能判断是不是想要的
  • 支持分页加载,结果多的时候不能一次全加载

搜索框组件

先封装一个通用的搜索框组件,后面其他地方可能也会用到:

interface SearchBarProps {
  value: string;
  onChangeText: (text: string) => void;
  onSubmit?: () => void;
  placeholder?: string;
  autoFocus?: boolean;
}

Props 设计:

  • valueonChangeText - 受控组件的标准写法,输入值由外部状态控制
  • 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 可以很方便地实现无限滚动,但要注意防止重复请求,用 loadinghasMore 两个状态来控制。

下一篇会讲新番页的实现,涉及到年份和季度的选择器。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

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

更多推荐