rn_for_openharmony狗狗之家app实战-我的收藏实现
本文介绍了如何在React Native应用中实现收藏功能,重点讲解了收藏页面的设计与实现。文章从组件导入、状态管理、Tab切换逻辑到数据加载进行了详细说明,展示了如何通过useState和useEffect管理收藏数据,以及如何实现Tab栏的条件样式渲染。此外还介绍了空状态和加载状态的显示处理,以及如何通过全局状态管理收藏数据。该实现采用模块化设计,包含自定义Hook和公共组件,确保代码可维护性
案例开源地址:https://atomgit.com/nutpi/rn_openharmony_dogimg
收藏这件小事
刷到一只超可爱的柯基,赶紧点个收藏。过几天想再看看,去哪找?
收藏页面就是干这个的。把用户散落在各处的"喜欢"集中起来,方便随时回顾。
做收藏功能看起来简单,实际上要考虑的东西不少:Tab 怎么切换、数据从哪来、列表怎么布局、空了怎么办。这篇文章一个个说。
先看导入了什么
import React, {useState, useEffect} from 'react';
useState 管理组件内部状态,useEffect 处理副作用(比如加载数据)。
这两个 Hook 是 React 的基础,写有状态的组件基本离不开它们。
import {View, Text, ScrollView, TouchableOpacity, Image, StyleSheet} from 'react-native';
从 react-native 导入基础组件。
ScrollView 让内容可以滚动,收藏多了一屏放不下就需要它。
TouchableOpacity 是可点击的容器,点击时会有透明度变化的反馈。
import {Header, Empty, Loading} from '../../components';
项目里封装的公共组件。
Empty 是空状态组件,没有收藏时显示。
Loading 是加载中组件,数据还没回来时显示。
import {useNavigation, useStore} from '../../hooks';
自定义 Hook。
useNavigation 提供页面跳转能力。
useStore 提供全局状态,收藏数据就存在这里。
import {api} from '../../api';
import type {Breed, DogImage} from '../../types';
api 是封装好的接口调用模块。
Breed 和 DogImage 是 TypeScript 类型定义,让代码有类型提示。
定义 Tab 的类型
type TabKey = 'breeds' | 'images';
这行代码定义了一个联合类型。
TabKey 只能是 ‘breeds’ 或 ‘images’ 两个值之一,写成别的 TypeScript 会报错。
比如你不小心写成 setTab('breed')(少了个 s),编辑器立刻就会提示你写错了。
这就是 TypeScript 的好处,很多低级错误在写代码时就能发现。
组件开头的状态定义
export function FavoritesPage() {
const {navigate} = useNavigation();
从 useNavigation 解构出 navigate 函数。
后面点击品种项时要跳转到详情页,点击空状态的按钮要跳转到列表页,都要用它。
const {favoriteBreeds, favoriteImages, toggleBreed, toggleImage} = useStore();
从全局状态里拿收藏相关的数据和方法。
favoriteBreeds 是收藏的品种 ID 数组,比如 [1, 5, 23]。
favoriteImages 是收藏的图片 ID 数组,比如 ['abc123', 'xyz789']。
toggleBreed 和 toggleImage 是切换收藏状态的方法,点一下收藏,再点一下取消。
组件内部的状态
const [tab, setTab] = useState<TabKey>('breeds');
当前选中的 Tab,默认是品种。
useState<TabKey> 指定了状态的类型,这样 setTab 只能传 ‘breeds’ 或 ‘images’。
const [loading, setLoading] = useState(true);
加载状态,一开始是 true。
数据加载完成后设为 false,Loading 组件就会消失。
const [breeds, setBreeds] = useState<Breed[]>([]);
品种详情数组,初始是空数组。
为什么需要这个?因为 favoriteBreeds 里只有 ID,要显示品种名称和图片,得根据 ID 查完整数据。
监听收藏变化
useEffect(() => {
loadBreeds();
}, [favoriteBreeds]);
useEffect 的第二个参数是依赖数组。
当 favoriteBreeds 变化时,重新执行 loadBreeds。
什么时候会变?用户在别的页面取消了某个品种的收藏,回到这个页面时列表就会自动更新。
如果依赖数组是空的 [],只在组件挂载时执行一次。
如果不传依赖数组,每次渲染都会执行,通常不是你想要的。
加载品种数据的函数
const loadBreeds = async () => {
if (favoriteBreeds.length === 0) {
setBreeds([]);
setLoading(false);
return;
}
先判断有没有收藏。
如果一个都没收藏,直接设置空数组,不用请求接口。
这是个提前返回的写法,避免不必要的网络请求,也让代码更清晰。
setLoading(true);
开始加载,显示 Loading。
try {
const all = await api.getBreeds();
调用接口获取所有品种。
await 会等接口返回,这期间用户看到的是 Loading。
setBreeds(all.filter(b => favoriteBreeds.includes(b.id)));
用 filter 筛选出收藏的品种。
favoriteBreeds.includes(b.id) 检查这个品种的 ID 是不是在收藏列表里。
这种方式简单直接,但如果品种有几百个,每次都全量获取再筛选有点浪费。更好的做法是后端提供按 ID 批量查询的接口。
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
try-catch-finally 处理异常。
catch 捕获错误,这里只是打印日志,实际项目可以显示错误提示。
finally 无论成功失败都会执行,把 loading 设为 false。
页面的整体结构
return (
<View style={s.container}>
<Header title="我的收藏" />
最外层是个 View,Header 显示页面标题。
<View style={s.tabs}>
...
</View>
Tab 栏,切换品种和图片。
{tab === 'breeds' ? (
// 品种列表
) : (
// 图片列表
)}
</View>
);
根据当前 Tab 显示不同的内容。
三元表达式在 JSX 里很常用,虽然嵌套多了可读性会变差。
Tab 栏的实现
<View style={s.tabs}>
<TouchableOpacity
style={[s.tab, tab === 'breeds' && s.tabActive]}
onPress={() => setTab('breeds')}
>
第一个 Tab 按钮。
style={[s.tab, tab === 'breeds' && s.tabActive]} 是条件样式的写法。
s.tab 是基础样式,所有 Tab 都有。
tab === 'breeds' && s.tabActive 当条件为真时,追加 tabActive 样式。
如果条件为假,&& 返回 false,React Native 会忽略它。
<Text style={[s.tabText, tab === 'breeds' && s.tabTextActive]}>
品种 ({favoriteBreeds.length})
</Text>
</TouchableOpacity>
Tab 文字后面显示数量。
用户一眼就能看到收藏了多少品种、多少图片。
<TouchableOpacity
style={[s.tab, tab === 'images' && s.tabActive]}
onPress={() => setTab('images')}
>
<Text style={[s.tabText, tab === 'images' && s.tabTextActive]}>
图片 ({favoriteImages.length})
</Text>
</TouchableOpacity>
</View>
第二个 Tab 按钮,逻辑一样。
点击时调用 setTab 切换当前 Tab。
Tab 栏的样式
tabs: {
flexDirection: 'row',
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: '#eee'
},
flexDirection: 'row' 让两个 Tab 横向排列。
底部有条浅灰色的线,和下面的内容区分开。
tab: {flex: 1, paddingVertical: 14, alignItems: 'center'},
flex: 1 让两个 Tab 平分宽度。
paddingVertical: 14 上下留白,让点击区域足够大。
alignItems: 'center' 让文字水平居中。
tabActive: {borderBottomWidth: 2, borderBottomColor: '#D2691E'},
选中的 Tab 底部有条 2 像素的主题色线。
这是常见的 Tab 指示器设计,用户一眼就知道当前在哪个 Tab。
tabText: {fontSize: 15, color: '#666'},
tabTextActive: {color: '#D2691E', fontWeight: '600'},
未选中的文字是灰色,选中的是主题色并加粗。
和底部线条呼应,视觉上更统一。
品种列表的条件渲染
{tab === 'breeds' ? (
loading ? <Loading full /> : breeds.length > 0 ? (
<ScrollView style={s.content}>
{breeds.map(breed => (
// 渲染每个品种
))}
</ScrollView>
) : (
<Empty ... />
)
) : (
// 图片列表
)}
这里有三层判断:
第一层:当前是不是品种 Tab。
第二层:是否正在加载。
第三层:有没有数据。
嵌套的三元表达式确实不太好读。可以抽成单独的函数:
const renderBreedList = () => {
if (loading) return <Loading full />;
if (breeds.length === 0) return <Empty ... />;
return <ScrollView>...</ScrollView>;
};
这样主渲染函数会清爽很多。
品种项的渲染
{breeds.map(breed => {
const img = breed.image?.url ||
`https://cdn2.thedogapi.com/images/${breed.reference_image_id}.jpg`;
遍历品种数组,为每个品种生成一个列表项。
图片 URL 有两种来源:
breed.image?.url 是品种对象自带的图片,有些品种有,有些没有。
?. 是可选链,如果 breed.image 是 undefined,不会报错,直接返回 undefined。
如果没有自带图片,用 reference_image_id 拼接 CDN 地址。
return (
<TouchableOpacity
key={breed.id}
style={s.breedItem}
onPress={() => navigate('BreedDetail', {breed})}
>
整个项是可点击的,点击跳转到品种详情页。
key={breed.id} 是 React 要求的,用于列表渲染的优化。
{breed} 把整个品种对象传给详情页,详情页就不用再请求接口了。
<Image source={{uri: img}} style={s.breedImg} />
品种头像,圆形的。
source 要传一个对象,uri 是图片地址。
<View style={s.breedInfo}>
<Text style={s.breedName}>{breed.name}</Text>
<Text style={s.breedGroup}>{breed.breed_group || '未分类'}</Text>
</View>
品种信息区,包含名称和分组。
breed.breed_group || '未分类' 如果没有分组信息,显示"未分类"。
<TouchableOpacity style={s.removeBtn} onPress={() => toggleBreed(breed.id)}>
<Text style={s.removeIcon}>❤️</Text>
</TouchableOpacity>
</TouchableOpacity>
);
})}
取消收藏按钮,用红心 emoji。
点击调用 toggleBreed,因为已经收藏了,再点就是取消。
注意这里用了事件冒泡阻止的技巧:内层的 TouchableOpacity 点击不会触发外层的 onPress。
品种项的样式
breedItem: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#fff',
padding: 12,
marginBottom: 1
},
横向布局,垂直居中。
marginBottom: 1 让每个项之间有条细缝,形成分隔效果,比用 borderBottom 更简洁。
breedImg: {width: 56, height: 56, borderRadius: 28, backgroundColor: '#eee'},
56x56 的圆形头像。
borderRadius: 28 是宽高的一半,让方形变成圆形。
backgroundColor: '#eee' 是图片加载前的占位色。
breedInfo: {flex: 1, marginLeft: 12},
flex: 1 让信息区占据头像和按钮之间的所有空间。
marginLeft: 12 和头像隔开一点。
breedName: {fontSize: 16, fontWeight: '500', color: '#333'},
breedGroup: {fontSize: 13, color: '#999', marginTop: 2},
名称是主要信息,字号大、颜色深。
分组是次要信息,字号小、颜色浅。
removeBtn: {padding: 8},
removeIcon: {fontSize: 20},
按钮加 padding 增大点击区域,手指比较粗也能点到。
图片列表的渲染
{favoriteImages.length > 0 ? (
<ScrollView style={s.content} contentContainerStyle={s.imageGrid}>
图片列表不需要 loading 状态,因为不用请求接口。
图片 ID 可以直接拼成 URL,不像品种需要查详情。
contentContainerStyle 是 ScrollView 内容容器的样式,用于设置网格布局。
{favoriteImages.map(id => (
<View key={id} style={s.imageItem}>
<Image
source={{uri: `https://cdn2.thedogapi.com/images/${id}.jpg`}}
style={s.imageThumb}
/>
遍历图片 ID 数组。
The Dog API 的图片 URL 格式是固定的:https://cdn2.thedogapi.com/images/{id}.jpg
知道 ID 就能拼出 URL,不用额外请求。
<TouchableOpacity style={s.imageRemove} onPress={() => toggleImage(id)}>
<Text style={s.removeIcon}>❤️</Text>
</TouchableOpacity>
</View>
))}
</ScrollView>
) : (
<Empty icon="📷" title="暂无收藏图片" desc="去图库收藏你喜欢的图片吧" btnText="去看看" onPress={() => navigate('Gallery')} />
)}
每张图片右上角有取消收藏按钮。
没有收藏时显示空状态,引导用户去图库。
图片网格的样式
imageGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
padding: 8
},
flexDirection: 'row' 横向排列。
flexWrap: 'wrap' 允许换行,一行放不下就换到下一行。
这两个属性配合实现网格布局。
imageItem: {width: '33.33%', padding: 4},
每个项占三分之一宽度,三列布局。
padding: 4 让图片之间有间距。
imageThumb: {
width: '100%',
aspectRatio: 1,
borderRadius: 8,
backgroundColor: '#eee'
},
width: '100%' 占满父容器宽度。
aspectRatio: 1 保持正方形,高度等于宽度。
这个属性很实用,不用计算具体的高度值。
imageRemove: {
position: 'absolute',
top: 8,
right: 8,
backgroundColor: 'rgba(0,0,0,0.3)',
borderRadius: 12,
padding: 4
},
position: 'absolute' 让按钮脱离文档流,浮在图片上面。
top: 8, right: 8 定位在右上角。
半透明黑色背景让按钮在任何颜色的图片上都清晰可见。
空状态的设计
<Empty
icon="🐕"
title="暂无收藏品种"
desc="去品种大全收藏你喜欢的狗狗吧"
btnText="去看看"
onPress={() => navigate('Breeds')}
/>
空状态不只是告诉用户"没有数据"。
好的空状态应该引导用户去产生数据。
这里告诉用户可以去品种大全收藏,还提供了跳转按钮。
用户点击"去看看",跳转到品种列表,收藏几个再回来,列表就有内容了。
容器样式
container: {flex: 1, backgroundColor: '#f5f5f5'},
content: {flex: 1},
container 占满整个屏幕,浅灰色背景。
content 是 ScrollView 的样式,flex: 1 让它占据 Header 和 Tab 之外的所有空间。
扩展:收藏数据的持久化
当前的收藏数据存在内存里,App 关闭就没了。
要持久化,可以用 AsyncStorage:
import AsyncStorage from '@react-native-async-storage/async-storage';
// 收藏变化时保存
useEffect(() => {
AsyncStorage.setItem('favoriteBreeds', JSON.stringify(favoriteBreeds));
}, [favoriteBreeds]);
每次 favoriteBreeds 变化,就保存到本地存储。
JSON.stringify 把数组转成字符串,因为 AsyncStorage 只能存字符串。
// App 启动时读取
useEffect(() => {
AsyncStorage.getItem('favoriteBreeds').then(data => {
if (data) {
appStore.setState({favoriteBreeds: JSON.parse(data)});
}
});
}, []);
App 启动时读取保存的数据,恢复用户的收藏。
JSON.parse 把字符串转回数组。
扩展:下拉刷新
可以加个下拉刷新功能:
import {RefreshControl} from 'react-native';
const [refreshing, setRefreshing] = useState(false);
const onRefresh = async () => {
setRefreshing(true);
await loadBreeds();
setRefreshing(false);
};
<ScrollView
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
>
RefreshControl 是 React Native 内置的下拉刷新组件。
用户下拉时触发 onRefresh,重新加载数据。
扩展:点击图片查看大图
现在点击图片没有反应,可以加个查看大图的功能:
<TouchableOpacity
style={s.imageItem}
onPress={() => navigate('ImageView', {imageId: id})}
>
<Image ... />
...
</TouchableOpacity>
点击图片跳转到图片查看页,可以看大图、下载、分享。
小结
收藏页面的实现要点:
- Tab 切换用条件样式实现选中效果
- 品种数据需要根据 ID 查询详情,图片可以直接拼 URL
- 列表布局品种用横向列表,图片用网格
- 空状态要引导用户去产生数据
- 持久化用 AsyncStorage 保存到本地
收藏功能让用户能保存喜欢的内容,是提升用户粘性的重要手段。
下一篇讲设置页面,用户可以调整 App 的各种配置。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐




所有评论(0)