案例开源地址:https://atomgit.com/nutpi/rn_openharmony_dogimg
请添加图片描述

数据驱动的内容推荐

上一篇讲了投票功能,用户的每一次投票都是宝贵的数据。热门图片页面就是这些数据的呈现,展示得票最高的狗狗图片。

这种"用户投票 → 数据统计 → 热门推荐"的闭环,是很多内容平台的核心逻辑。用户既是内容的消费者,也是内容的筛选者。

当前页面的代码

TopImagesPage 目前是个占位:

import React from 'react';
import {View, StyleSheet} from 'react-native';
import {Header, Empty} from '../../components';

导入了 React、RN 基础组件,以及我们封装的 Header 和 Empty。

这三行导入在项目里出现频率很高,几乎每个页面都有。可以考虑在某个地方统一导出,减少重复代码。但为了保持每个文件的独立性,目前还是分开写。

export function TopImagesPage() {
  return (
    <View style={s.container}>
      <Header title="热门图片" />
      <Empty icon="🔥" title="最受欢迎的狗狗图片" desc="功能开发中..." />
    </View>
  );
}

🔥 这个图标选得很贴切,"热门"嘛,火热的意思。

标题"最受欢迎的狗狗图片"比单纯的"暂无数据"更有信息量,告诉用户这个页面将来会展示什么。

const s = StyleSheet.create({
  container: {flex: 1, backgroundColor: '#f5f5f5'}
});

灰色背景,和其他页面保持一致。

复用 GalleryPage 的模式

热门图片和普通图库的展示方式很像,都是图片网格。看看 GalleryPage 的实现:

import React, {useState, useEffect} from 'react';
import {View, ScrollView, StyleSheet, RefreshControl} from 'react-native';
import {Header, Loading, Empty, ImageCard} from '../../components';
import {useNavigation, useStore} from '../../hooks';
import {api} from '../../api';
import type {DogImage} from '../../types';

导入分成几组:

React 相关:useState、useEffect 是最常用的两个 Hook。

RN 组件:View 是容器,ScrollView 是滚动容器,RefreshControl 是下拉刷新。

业务组件:Header、Loading、Empty、ImageCard 都是我们封装的。

自定义 Hook:useNavigation 处理导航,useStore 处理全局状态。

API 和类型:api 是接口封装,DogImage 是类型定义。

这种分组方式让导入部分更清晰,一眼就能看出依赖了哪些模块。

状态设计的对比

GalleryPage 的状态:

const {navigate} = useNavigation();
const {favoriteImages, toggleImage} = useStore();
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [images, setImages] = useState<DogImage[]>([]);

热门图片页面可以复用这套状态,再加上排序相关的:

const [sortBy, setSortBy] = useState<'votes' | 'recent'>('votes');
const [timeRange, setTimeRange] = useState<'day' | 'week' | 'month'>('week');

sortBy 控制排序方式:按投票数还是按时间。

timeRange 控制时间范围:今日热门、本周热门、本月热门。

这两个筛选条件能让用户看到不同维度的热门内容。

数据加载的差异

GalleryPage 加载随机图片:

useEffect(() => { loadData(); }, []);

const loadData = async () => {
  try {
    const res = await api.getImages(20);
    setImages(res);
  } catch (e) {
    console.error(e);
  } finally {
    setLoading(false);
    setRefreshing(false);
  }
};

api.getImages(20) 获取 20 张随机图片。

热门图片需要不同的接口,按投票数排序:

const loadTopImages = async () => {
  try {
    const res = await api.getTopImages({
      limit: 20,
      order: sortBy,
      range: timeRange,
    });
    setImages(res);
  } catch (e) {
    console.error(e);
  } finally {
    setLoading(false);
    setRefreshing(false);
  }
};

参数里带上排序方式和时间范围,服务端返回对应的数据。

API 接口的扩展

当前的 api.ts 只有三个接口:

export const api = {
  getBreeds: (limit?: number) => http.get<Breed[]>('/breeds', {limit}),
  searchBreeds: (q: string) => http.get<Breed[]>('/breeds/search', {q}),
  getImages: (limit = 10, breedId?: number) =>
    http.get<DogImage[]>('/images/search', {
      limit,
      breed_ids: breedId,
      order: 'RANDOM',
    }),
};

需要加一个获取热门图片的接口:

getTopImages: (params: {limit?: number; order?: string; range?: string}) =>
  http.get<DogImage[]>('/images', {
    ...params,
    order: 'DESC',
    sort: 'votes',
  }),

The Dog API 的 /images 接口支持按投票数排序,需要 API Key 才能访问。

ImageCard 组件的复用

GalleryPage 用 ImageCard 展示每张图片:

{images.map(img => (
  <ImageCard 
    key={img.id} 
    image={img} 
    liked={favoriteImages.includes(img.id)} 
    onLike={() => toggleImage(img.id)} 
    onPress={() => navigate('ImageView', {image: img})} 
  />
))}

四个属性:

key:React 列表渲染必须的唯一标识。

image:图片数据,包含 id 和 url。

liked:是否已收藏,从全局状态判断。

onLike:点击收藏按钮的回调。

onPress:点击卡片的回调,跳转到详情页。

热门图片页面可以直接复用,不需要改动。

ImageCard 的实现细节

const W = (Dimensions.get('window').width - 48) / 2;

这行代码计算卡片宽度。屏幕宽度减去 48(左右各 16 的 padding,中间 16 的间距),再除以 2,得到两列布局下每张卡片的宽度。

为什么在组件外部计算?因为 Dimensions.get('window') 的结果在 App 运行期间不会变(除非屏幕旋转),放在外部只计算一次,避免每次渲染都重复计算。

interface Props {
  image: DogImage;
  liked?: boolean;
  onPress?: () => void;
  onLike?: () => void;
}

Props 接口定义了组件接受的属性。

image 是必传的,其他都是可选的。这种设计让组件在不同场景下都能用:

  • 只展示图片:<ImageCard image={img} />
  • 带收藏功能:<ImageCard image={img} liked={true} onLike={handleLike} />
  • 带点击跳转:<ImageCard image={img} onPress={handlePress} />
export function ImageCard({image, liked, onPress, onLike}: Props) {
  return (
    <TouchableOpacity style={s.card} onPress={onPress} activeOpacity={0.9}>
      <Image source={{uri: image.url}} style={s.img} />
      {onLike && (
        <TouchableOpacity style={s.like} onPress={onLike}>
          <Text style={s.heart}>{liked ? '❤️' : '🤍'}</Text>
        </TouchableOpacity>
      )}
    </TouchableOpacity>
  );
}

外层 TouchableOpacity 让整个卡片可点击。activeOpacity={0.9} 让点击时的透明度变化不那么明显,视觉上更舒服。

收藏按钮用条件渲染 {onLike && ...},只有传了 onLike 才显示。这样在不需要收藏功能的场景,按钮就不会出现。

ImageCard 的样式

card: {
  width: W, 
  height: W * 1.2, 
  borderRadius: 12, 
  overflow: 'hidden', 
  marginBottom: 12
},

宽度是计算出来的 W,高度是宽度的 1.2 倍,形成竖向的卡片比例

overflow: 'hidden' 配合 borderRadius 实现圆角裁剪。如果不加这个,图片会超出圆角区域。

img: {
  width: '100%', 
  height: '100%', 
  backgroundColor: '#f0f0f0'
},

图片填满整个卡片。backgroundColor 在图片加载前显示,避免空白。

like: {
  position: 'absolute', 
  bottom: 8, 
  right: 8, 
  backgroundColor: 'rgba(0,0,0,0.3)', 
  borderRadius: 16, 
  padding: 6
},
heart: {fontSize: 18},

收藏按钮用绝对定位浮在图片右下角。

半透明黑色背景 rgba(0,0,0,0.3) 让按钮在任何颜色的图片上都清晰可见。

网格布局的实现

GalleryPage 的网格样式:

grid: {
  flexDirection: 'row', 
  flexWrap: 'wrap', 
  padding: 16, 
  justifyContent: 'space-between'
},

flexDirection: ‘row’ 让子元素横向排列。

flexWrap: ‘wrap’ 允许换行,一行放不下就换到下一行。

justifyContent: ‘space-between’ 让两列之间自动分配间距。

这三个属性组合起来,就实现了两列的网格布局,不需要额外的布局库。

热门图片的排名展示

热门图片应该显示排名,让用户知道这是第几名:

{images.map((img, index) => (
  <View key={img.id} style={s.cardWrapper}>
    <ImageCard 
      image={img} 
      liked={favoriteImages.includes(img.id)} 
      onLike={() => toggleImage(img.id)} 
      onPress={() => navigate('ImageView', {image: img})} 
    />
    <View style={s.rank}>
      <Text style={s.rankText}>{index + 1}</Text>
    </View>
  </View>
))}

用 View 包裹 ImageCard,在左上角加一个排名标签。

index + 1 因为数组索引从 0 开始,排名从 1 开始。

cardWrapper: {
  position: 'relative',
},
rank: {
  position: 'absolute',
  top: 8,
  left: 8,
  backgroundColor: '#D2691E',
  borderRadius: 12,
  paddingHorizontal: 8,
  paddingVertical: 4,
},
rankText: {
  color: '#fff',
  fontSize: 12,
  fontWeight: '600',
},

排名标签用主题色背景,白色文字,圆角胶囊形状。

放在左上角,和右下角的收藏按钮形成对角呼应。

投票数的展示

除了排名,还可以显示具体的投票数:

interface TopImage extends DogImage {
  votes: number;
}

扩展 DogImage 类型,加上 votes 字段。

<View style={s.voteInfo}>
  <Text style={s.voteIcon}>👍</Text>
  <Text style={s.voteCount}>{img.votes}</Text>
</View>

在卡片底部显示投票数。

voteInfo: {
  position: 'absolute',
  bottom: 8,
  left: 8,
  flexDirection: 'row',
  alignItems: 'center',
  backgroundColor: 'rgba(0,0,0,0.5)',
  borderRadius: 10,
  paddingHorizontal: 8,
  paddingVertical: 4,
},
voteIcon: {fontSize: 12, marginRight: 4},
voteCount: {color: '#fff', fontSize: 12},

和收藏按钮类似的样式,放在左下角。

筛选栏的设计

页面顶部加一个筛选栏,让用户切换时间范围:

<View style={s.filters}>
  <TouchableOpacity 
    style={[s.filterBtn, timeRange === 'day' && s.filterBtnActive]}
    onPress={() => setTimeRange('day')}
  >
    <Text style={[s.filterText, timeRange === 'day' && s.filterTextActive]}>
      今日
    </Text>
  </TouchableOpacity>
  <TouchableOpacity 
    style={[s.filterBtn, timeRange === 'week' && s.filterBtnActive]}
    onPress={() => setTimeRange('week')}
  >
    <Text style={[s.filterText, timeRange === 'week' && s.filterTextActive]}>
      本周
    </Text>
  </TouchableOpacity>
  <TouchableOpacity 
    style={[s.filterBtn, timeRange === 'month' && s.filterBtnActive]}
    onPress={() => setTimeRange('month')}
  >
    <Text style={[s.filterText, timeRange === 'month' && s.filterTextActive]}>
      本月
    </Text>
  </TouchableOpacity>
</View>

三个按钮,选中的那个高亮显示。

style={[s.filterBtn, timeRange === 'day' && s.filterBtnActive]} 用数组合并样式,条件为真时才加上 active 样式。

筛选栏样式

filters: {
  flexDirection: 'row',
  backgroundColor: '#fff',
  paddingVertical: 12,
  paddingHorizontal: 16,
  borderBottomWidth: 1,
  borderBottomColor: '#eee',
},
filterBtn: {
  paddingHorizontal: 16,
  paddingVertical: 6,
  borderRadius: 16,
  marginRight: 12,
},
filterBtnActive: {
  backgroundColor: '#FFF3E0',
},
filterText: {
  fontSize: 14,
  color: '#666',
},
filterTextActive: {
  color: '#D2691E',
  fontWeight: '600',
},

筛选栏白色背景,底部有条细线分隔。

按钮默认是透明的,选中后变成浅橙色背景。

文字默认灰色,选中后变成主题色并加粗。

切换筛选时重新加载

useEffect(() => {
  setLoading(true);
  loadTopImages();
}, [timeRange, sortBy]);

当 timeRange 或 sortBy 变化时,重新加载数据。

setLoading(true) 先显示 Loading,避免用户看到旧数据。

下拉刷新

<ScrollView 
  style={s.content} 
  contentContainerStyle={s.grid} 
  refreshControl={
    <RefreshControl refreshing={refreshing} onRefresh={loadData} />
  }
>

RefreshControl 是 RN 内置的下拉刷新组件。

refreshing 控制刷新指示器的显示。

onRefresh 是下拉触发的回调。

用户下拉时会看到一个转圈的指示器,松手后触发 onRefresh,加载完成后指示器消失。

空状态处理

{images.length > 0 ? images.map(...) : <Empty icon="📷" title="暂无图片" />}

如果图片列表为空,显示 Empty 组件。

热门图片页面可以定制一下空状态:

<Empty 
  icon="🔥" 
  title="暂无热门图片" 
  desc="快去投票,让你喜欢的狗狗上榜吧!"
  btnText="去投票"
  onPress={() => navigate('Vote')}
/>

加上引导按钮,把用户带到投票页面。这样空状态也能产生价值,而不是死胡同。

DogImage 类型的结构

export interface DogImage {
  id: string;
  url: string;
  breeds?: Breed[];
}

id 是字符串类型,The Dog API 返回的图片 ID 是字符串。

url 是图片地址,直接用于 Image 组件的 source。

breeds 是可选的品种数组,有些图片会关联品种信息。

为什么 breeds 是数组?因为一张图片可能包含多只不同品种的狗。虽然大多数情况只有一个品种,但接口设计要考虑边界情况。

加载更多的实现

当前用 ScrollView,一次加载所有图片。如果图片很多,可以改成分页加载:

const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);

const loadMore = async () => {
  if (!hasMore || loading) return;
  
  setLoading(true);
  try {
    const res = await api.getTopImages({
      limit: 20,
      page: page + 1,
      range: timeRange,
    });
    
    if (res.length < 20) {
      setHasMore(false);
    }
    
    setImages([...images, ...res]);
    setPage(page + 1);
  } catch (e) {
    console.error(e);
  } finally {
    setLoading(false);
  }
};

page 记录当前页码。

hasMore 标记是否还有更多数据。

返回的数据少于请求的数量,说明没有更多了。

setImages([...images, ...res]) 把新数据追加到现有数据后面。

滚动到底部触发加载

<ScrollView
  onScroll={({nativeEvent}) => {
    const {layoutMeasurement, contentOffset, contentSize} = nativeEvent;
    const isBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - 20;
    if (isBottom) {
      loadMore();
    }
  }}
  scrollEventThrottle={400}
>

layoutMeasurement.height 是可视区域高度。

contentOffset.y 是滚动距离。

contentSize.height 是内容总高度。

当可视区域高度 + 滚动距离 >= 内容总高度 - 20 时,说明快到底了,触发加载。

scrollEventThrottle={400} 限制 onScroll 的触发频率,避免性能问题。

小结

热门图片页面的实现要点:

  • 复用现有组件:ImageCard、Empty 等组件可以直接用
  • 扩展数据展示:加上排名、投票数等信息
  • 筛选功能:让用户切换不同的时间范围
  • 空状态引导:把用户带到投票页面,形成闭环
  • 分页加载:数据量大时按需加载,优化性能

热门图片是投票数据的呈现,两个功能相互配合,形成完整的用户参与体系。

下一篇进入领养模块,讲领养列表页面的实现。


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

Logo

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

更多推荐