案例开源地址: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']

toggleBreedtoggleImage 是切换收藏状态的方法,点一下收藏,再点一下取消。

组件内部的状态

  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

Logo

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

更多推荐