请添加图片描述

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

从云养狗到真养狗

前面讲的功能都是"看"狗狗,浏览图片、了解品种、投票互动。但狗狗之家还有一个更有温度的功能:领养。

领养模块连接的是真实的需求。有些狗狗因为各种原因需要新家庭,有些人想养狗但不知道去哪找。领养中心就是这个桥梁。

这篇文章讲领养列表页面的实现,涉及到数据结构设计、卡片组件封装、列表渲染等内容。

领养数据的结构

先看 AdoptDog 类型的定义:

export interface AdoptDog {
  id: string;
  name: string;
  breed: string;
  age: string;
  gender: '公' | '母';
  size: string;
  desc: string;
  image: string;
  location: string;
}

每个字段都有明确的用途:

id 是唯一标识,用于列表渲染的 key 和跳转详情时的参数。

name 是狗狗的名字,领养和买卖不同,每只狗都有自己的名字,这让它们更像"家人"而不是"商品"。

breed 是品种,字符串类型而不是关联 Breed 对象,因为领养的狗可能是混血或者品种不明确。

age 也是字符串,因为年龄的表达方式多样:“2岁”、“8个月”、“约3岁”。

gender 用联合类型 '公' | '母',只有这两个可能的值。TypeScript 会在编译时检查,传其他值会报错。

size 是体型,“小型”、“中型”、“大型”,帮助用户判断是否适合自己的居住环境。

desc 是描述,介绍狗狗的性格、健康状况等。

image 是照片 URL。

location 是所在地,领养通常需要线下见面,地理位置很重要。

模拟数据的设计

AdoptPage 里有一组模拟数据:

const DOGS: AdoptDog[] = [
  {
    id: '1', 
    name: '小黄', 
    breed: '中华田园犬', 
    age: '2岁', 
    gender: '公', 
    size: '中型', 
    desc: '性格温顺,亲人,已完成疫苗接种。', 
    image: 'https://cdn2.thedogapi.com/images/rkZRggqVX.jpg', 
    location: '北京市朝阳区'
  },

第一只是中华田园犬,也就是俗称的"土狗"。在领养场景里,田园犬占很大比例,它们同样值得被爱。

描述里提到"已完成疫苗接种",这是领养者很关心的信息。

  {
    id: '2', 
    name: '豆豆', 
    breed: '泰迪', 
    age: '1岁', 
    gender: '母', 
    size: '小型', 
    desc: '活泼可爱,喜欢玩耍。', 
    image: 'https://cdn2.thedogapi.com/images/A09F4c1qP.jpg', 
    location: '上海市浦东新区'
  },

泰迪是小型犬,适合公寓养。"活泼可爱"是性格描述,帮助用户判断是否和自己的生活方式匹配。

  {
    id: '3', 
    name: '大毛', 
    breed: '金毛', 
    age: '3岁', 
    gender: '公', 
    size: '大型', 
    desc: '温柔的大暖男,喜欢和小朋友玩。', 
    image: 'https://cdn2.thedogapi.com/images/SyRe4eAhm.jpg', 
    location: '广州市天河区'
  },

金毛是大型犬,需要更大的活动空间。"喜欢和小朋友玩"对有孩子的家庭很有吸引力。

  {
    id: '4', 
    name: '妞妞', 
    breed: '柯基', 
    age: '8个月', 
    gender: '母', 
    size: '小型', 
    desc: '小短腿,超级萌。', 
    image: 'https://cdn2.thedogapi.com/images/Bymw-fYVX.jpg', 
    location: '深圳市南山区'
  },

柯基虽然腿短,但其实是中型犬的体重。这里标成小型是为了数据多样性。

"8个月"说明还是幼犬,很多人偏好领养幼犬,觉得更容易培养感情。

  {
    id: '5', 
    name: '黑子', 
    breed: '拉布拉多', 
    age: '2岁', 
    gender: '公', 
    size: '大型', 
    desc: '聪明听话,已学会基本指令。', 
    image: 'https://cdn2.thedogapi.com/images/B1uW7l5VX.jpg', 
    location: '成都市武侯区'
  },
];

拉布拉多是导盲犬的常用品种,智商高。"已学会基本指令"说明经过训练,对新手友好。

五只狗覆盖了不同的品种、体型、年龄、性别、城市,数据多样性有助于测试和展示。

页面组件的结构

import React from 'react';
import {View, ScrollView, StyleSheet} from 'react-native';
import {Header, AdoptCard} from '../../components';
import {useNavigation} from '../../hooks';
import type {AdoptDog} from '../../types';

导入分成四组:

React:基础依赖。

RN 组件:View 是容器,ScrollView 是滚动列表。

业务组件:Header 是顶部导航栏,AdoptCard 是领养卡片。

Hooks 和类型:useNavigation 处理页面跳转,AdoptDog 是类型定义。

export function AdoptPage() {
  const {navigate} = useNavigation();

  return (
    <View style={s.container}>
      <Header title="❤️ 领养中心" showBack={false} />
      <ScrollView style={s.content}>
        {DOGS.map(dog => (
          <AdoptCard 
            key={dog.id} 
            dog={dog} 
            onPress={() => navigate('AdoptDetail', {dog})} 
          />
        ))}
      </ScrollView>
    </View>
  );
}

页面结构很简单:Header + 滚动列表。

showBack={false} 因为这是底部 Tab 页面,不需要返回按钮。

标题用了 ❤️ emoji,传达"爱心领养"的理念。

列表渲染的方式

{DOGS.map(dog => (
  <AdoptCard 
    key={dog.id} 
    dog={dog} 
    onPress={() => navigate('AdoptDetail', {dog})} 
  />
))}

map 遍历数组,为每只狗生成一个 AdoptCard。

key={dog.id} 是 React 列表渲染的必要属性,帮助 React 识别哪些元素变化了。

dog={dog} 把整个狗狗对象传给卡片组件。

onPress 点击时跳转到详情页,把 dog 对象作为参数传递。

为什么传整个对象而不是只传 id?因为详情页需要显示狗狗的所有信息,传对象可以直接用,不需要再查询。

页面样式

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

container 是页面容器,灰色背景。

content 是滚动区域,paddingTop: 8 让第一张卡片和 Header 之间有点间距。

为什么不用 padding: 16?因为卡片自己有 marginHorizontal: 16,如果 content 也有水平 padding,间距就太大了。

AdoptCard 组件的设计

import React from 'react';
import {View, Text, Image, TouchableOpacity, StyleSheet} from 'react-native';
import type {AdoptDog} from '../types';

卡片组件用到了更多的 RN 组件:View、Text、Image、TouchableOpacity。

interface Props {
  dog: AdoptDog;
  onPress?: () => void;
}

Props 很简单,一个必传的 dog 对象,一个可选的点击回调。

onPress 设为可选是为了灵活性,有些场景可能只需要展示,不需要点击。

export function AdoptCard({dog, onPress}: Props) {
  return (
    <TouchableOpacity style={s.card} onPress={onPress} activeOpacity={0.85}>
      <Image source={{uri: dog.image}} style={s.img} />
      <View style={s.info}>
        ...
      </View>
    </TouchableOpacity>
  );
}

卡片整体是个 TouchableOpacity,点击任何位置都能触发 onPress。

activeOpacity={0.85} 让点击时的透明度变化柔和一些。

布局是左图右文,Image 在左边,信息在右边。

卡片的图片部分

<Image source={{uri: dog.image}} style={s.img} />

图片样式:

img: {width: 110, height: 130, backgroundColor: '#f0f0f0'},

固定宽高,110 x 130,略微竖向的比例。

backgroundColor 在图片加载前显示,避免空白。

卡片的信息部分

<View style={s.info}>
  <View style={s.row}>
    <Text style={s.name}>{dog.name}</Text>
    <Text style={s.gender}>{dog.gender === '公' ? '♂' : '♀'}</Text>
  </View>
  <Text style={s.breed}>{dog.breed}</Text>
  <View style={s.tags}>
    <View style={s.tag}><Text style={s.tagText}>{dog.age}</Text></View>
    <View style={s.tag}><Text style={s.tagText}>{dog.size}</Text></View>
  </View>
  <Text style={s.loc}>📍 {dog.location}</Text>
</View>

信息区域分四行:

第一行:名字 + 性别符号。性别用 ♂ 和 ♀ 符号,比文字更直观。

第二行:品种。

第三行:标签,年龄和体型。用标签形式展示,视觉上更清晰。

第四行:位置,前面加个 📍 图标。

名字和性别的样式

row: {flexDirection: 'row', alignItems: 'center'},
name: {fontSize: 17, fontWeight: '600', color: '#333'},
gender: {fontSize: 16, marginLeft: 6, color: '#D2691E'},

名字和性别横向排列,用 flexDirection: 'row'

名字是 17 号半粗体,是卡片里最醒目的文字。

性别符号用主题色 #D2691E,和名字区分开。

品种的样式

breed: {fontSize: 13, color: '#666', marginTop: 2},

品种用小一号的灰色字体,作为名字的补充信息。

marginTop: 2 和名字行拉开一点距离。

标签的实现

tags: {flexDirection: 'row', marginTop: 8},
tag: {
  backgroundColor: '#f5f5f5', 
  paddingHorizontal: 8, 
  paddingVertical: 4, 
  borderRadius: 4, 
  marginRight: 6
},
tagText: {fontSize: 12, color: '#666'},

标签容器用 flexDirection: 'row' 让标签横向排列。

每个标签是灰色背景的小方块,圆角 4,内边距让文字不贴边。

marginRight: 6 让标签之间有间距。

位置的样式

loc: {fontSize: 12, color: '#999', marginTop: 8},

位置用最小的字号和最浅的颜色,是次要信息。

marginTop: 8 和标签行拉开距离。

卡片整体样式

card: {
  flexDirection: 'row', 
  backgroundColor: '#fff', 
  borderRadius: 12, 
  marginHorizontal: 16, 
  marginVertical: 8, 
  overflow: 'hidden', 
  elevation: 2
},

flexDirection: ‘row’ 让图片和信息横向排列。

backgroundColor: ‘#fff’ 白色背景,和页面的灰色背景形成对比。

borderRadius: 12 圆角,看起来更柔和。

marginHorizontal: 16 左右边距,卡片不贴边。

marginVertical: 8 上下边距,卡片之间有间隔。

overflow: ‘hidden’ 配合圆角,裁剪超出的内容。

elevation: 2 是 Android 的阴影效果。iOS 需要用 shadowXxx 属性。

信息区域样式

info: {flex: 1, padding: 12},

flex: 1 让信息区域占据图片之外的所有空间。

padding: 12 内边距,文字不贴边。

跨平台阴影的处理

当前代码只用了 elevation,在 iOS 上没有阴影效果。完整的跨平台阴影:

card: {
  ...
  // Android
  elevation: 2,
  // iOS
  shadowColor: '#000',
  shadowOffset: {width: 0, height: 1},
  shadowOpacity: 0.1,
  shadowRadius: 2,
},

iOS 的阴影需要四个属性配合:颜色、偏移、透明度、模糊半径。

这是 React Native 跨平台开发的常见问题,同一个效果在不同平台需要不同的实现。

数据来源的思考

当前用的是硬编码的模拟数据。真实场景下,数据应该从服务器获取:

const [dogs, setDogs] = useState<AdoptDog[]>([]);
const [loading, setLoading] = useState(true);

useEffect(() => {
  api.getAdoptDogs().then(res => {
    setDogs(res);
    setLoading(false);
  });
}, []);

加上 loading 状态,在数据加载完成前显示 Loading 组件。

下拉刷新的添加

const [refreshing, setRefreshing] = useState(false);

const onRefresh = async () => {
  setRefreshing(true);
  const res = await api.getAdoptDogs();
  setDogs(res);
  setRefreshing(false);
};

<ScrollView
  refreshControl={
    <RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
  }
>

领养信息会更新,有的狗被领养了,有的新狗加入。下拉刷新让用户能获取最新数据。

空状态处理

{dogs.length > 0 ? (
  dogs.map(dog => <AdoptCard key={dog.id} dog={dog} ... />)
) : (
  <Empty 
    icon="🐕" 
    title="暂无待领养狗狗" 
    desc="所有狗狗都找到家啦!"
  />
)}

如果列表为空,显示一个温馨的提示。"所有狗狗都找到家啦"比"暂无数据"更有人情味。

筛选功能的扩展

用户可能想按条件筛选:

const [filters, setFilters] = useState({
  size: null,      // 体型:小型/中型/大型
  gender: null,    // 性别:公/母
  city: null,      // 城市
});

const filteredDogs = dogs.filter(dog => {
  if (filters.size && dog.size !== filters.size) return false;
  if (filters.gender && dog.gender !== filters.gender) return false;
  if (filters.city && !dog.location.includes(filters.city)) return false;
  return true;
});

用 filter 方法筛选符合条件的狗狗。

每个条件都是可选的,为 null 时不筛选。

搜索功能

const [keyword, setKeyword] = useState('');

const searchedDogs = filteredDogs.filter(dog => {
  if (!keyword) return true;
  const k = keyword.toLowerCase();
  return (
    dog.name.toLowerCase().includes(k) ||
    dog.breed.toLowerCase().includes(k) ||
    dog.location.toLowerCase().includes(k)
  );
});

支持按名字、品种、位置搜索。

toLowerCase() 让搜索不区分大小写。

排序功能

const [sortBy, setSortBy] = useState<'default' | 'age'>('default');

const sortedDogs = [...searchedDogs].sort((a, b) => {
  if (sortBy === 'age') {
    // 简单的年龄排序,实际需要更复杂的解析
    return a.age.localeCompare(b.age);
  }
  return 0;
});

按年龄排序,让用户更容易找到幼犬或成年犬。

[...searchedDogs] 创建副本再排序,避免修改原数组。

收藏功能

用户可能想收藏感兴趣的狗狗,以后再看:

const {favoriteAdopts, toggleAdopt} = useStore();

<AdoptCard 
  dog={dog} 
  liked={favoriteAdopts.includes(dog.id)}
  onLike={() => toggleAdopt(dog.id)}
  onPress={() => navigate('AdoptDetail', {dog})} 
/>

需要在 AdoptCard 里加上收藏按钮,类似 ImageCard 的实现。

小结

领养列表页面的实现要点:

  • 数据结构设计:AdoptDog 类型包含领养场景需要的所有字段
  • 卡片组件封装:AdoptCard 展示狗狗的关键信息
  • 左图右文布局:flexDirection: ‘row’ 实现横向排列
  • 标签展示:用小方块展示年龄、体型等信息
  • 跨平台阴影:Android 用 elevation,iOS 用 shadow 系列属性

领养功能有社会价值,希望能帮助更多狗狗找到温暖的家。

下一篇讲领养详情页面,展示狗狗的完整信息和领养流程。


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

Logo

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

更多推荐