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

详情页的重要性

列表页负责展示,详情页负责深入。用户在列表里看到感兴趣的品种,点进来想看更多信息。详情页要做的就是把品种的各种属性清晰地呈现出来。

这个页面还有一个重要功能——收藏。用户可以把喜欢的品种收藏起来,方便以后查看。这就涉及到全局状态管理,是这篇文章的重点之一。

页面需要哪些数据

先看看从列表页传过来的品种数据长什么样:

interface Breed {
  id: number;
  name: string;
  bred_for?: string;
  breed_group?: string;
  life_span: string;
  temperament?: string;
  origin?: string;
  weight: { metric: string };
  height: { metric: string };
  reference_image_id?: string;
  image?: { url: string };
}

信息挺丰富的:名称、分组、用途、产地、寿命、性格、体型、图片。详情页要把这些都展示出来。

组件引入

import React from 'react';
import {View, Text, ScrollView, Image, TouchableOpacity, StyleSheet} from 'react-native';
import {Header, Card} from '../../components';
import {useNavigation, useRoute, useStore} from '../../hooks';
import type {Breed} from '../../types';

引入了三个自定义 Hook:

  • useNavigation:页面跳转
  • useRoute:获取路由参数
  • useStore:获取全局状态和操作方法

数据准备

export function BreedDetailPage() {
  const {navigate} = useNavigation();
  const {params} = useRoute<{breed: Breed}>();
  const {favoriteBreeds, toggleBreed} = useStore();

从三个 Hook 里分别取出需要的东西:

  • navigate 用于跳转到图片页
  • params.breed 是列表页传过来的品种数据
  • favoriteBreeds 是收藏的品种 ID 数组
  • toggleBreed 是切换收藏状态的方法

继续处理数据:

  const breed = params.breed;
  const isFav = favoriteBreeds.includes(breed.id);
  const img = breed.image?.url || `https://cdn2.thedogapi.com/images/${breed.reference_image_id}.jpg`;
  • breed 简化后续使用
  • isFav 判断当前品种是否已收藏
  • img 处理图片 URL,有 image.url 就用,没有就用 ID 拼接

Header 的收藏按钮

<Header title={breed.name} right={
  <TouchableOpacity onPress={() => toggleBreed(breed.id)}>
    <Text style={s.fav}>{isFav ? '❤️' : '🤍'}</Text>
  </TouchableOpacity>
} />

Header 组件支持 right 属性,可以传入右侧的自定义内容。

这里放了一个收藏按钮:

  • 已收藏显示红心 ❤️
  • 未收藏显示白心 🤍
  • 点击调用 toggleBreed 切换状态

用 emoji 做图标,简单直接。

顶部大图

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

品种图片放在最顶部,宽度撑满,高度固定 260:

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

backgroundColor 是图片加载前的占位色,避免白屏闪烁。

操作按钮区

<View style={s.actions}>
  <TouchableOpacity style={s.action} onPress={() => navigate('BreedImages', {breed})}>
    <Text style={s.actionIcon}>📷</Text>
    <Text style={s.actionText}>更多图片</Text>
  </TouchableOpacity>
</View>

图片下方是操作区,目前只有一个"更多图片"按钮。点击跳转到品种图集页面。

样式上做成横向排列,方便以后加更多按钮:

actions: {flexDirection: 'row', backgroundColor: '#fff', paddingVertical: 12, marginBottom: 8},
action: {flex: 1, alignItems: 'center'},

flex: 1 让按钮平分宽度,即使以后加多个按钮也能自动均分。

基本信息卡片

<Card>
  <Text style={s.sectionTitle}>基本信息</Text>
  {[
    ['品种名称', breed.name],
    ['品种分组', breed.breed_group || '未分类'],
    ['培育用途', breed.bred_for || '未知'],
    ['原产地', breed.origin || '未知'],
    ['寿命', breed.life_span],
  ].map(([label, value]) => (
    <View key={label} style={s.row}>
      <Text style={s.label}>{label}</Text>
      <Text style={s.value}>{value}</Text>
    </View>
  ))}
</Card>

用二维数组配置信息项,然后 map 渲染。这种写法比写五个重复的 View 简洁多了。

每项都做了兜底处理,比如 breed.breed_group || '未分类',没有数据时显示默认文案。

行样式:

row: {flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: '#f0f0f0'},
label: {fontSize: 14, color: '#666'},
value: {fontSize: 14, color: '#333', flex: 1, textAlign: 'right'},
  • 左右两端对齐
  • 底部有分隔线
  • 值文字右对齐,flex: 1 让它占满剩余空间

体型信息卡片

<Card>
  <Text style={s.sectionTitle}>体型信息</Text>
  <View style={s.sizeRow}>
    <View style={s.sizeItem}>
      <Text style={s.sizeLabel}>身高</Text>
      <Text style={s.sizeValue}>{breed.height.metric} cm</Text>
    </View>
    <View style={s.sizeItem}>
      <Text style={s.sizeLabel}>体重</Text>
      <Text style={s.sizeValue}>{breed.weight.metric} kg</Text>
    </View>
  </View>
</Card>

身高体重并排显示,用大字号突出数值:

sizeRow: {flexDirection: 'row'},
sizeItem: {flex: 1, alignItems: 'center', paddingVertical: 10},
sizeLabel: {fontSize: 13, color: '#999'},
sizeValue: {fontSize: 20, fontWeight: '600', color: '#D2691E', marginTop: 4},

数值用主题色 #D2691E,字号 20,加粗,是视觉焦点。

性格特点卡片

{breed.temperament && (
  <Card>
    <Text style={s.sectionTitle}>性格特点</Text>
    <View style={s.tags}>
      {breed.temperament.split(', ').map((t, i) => (
        <View key={i} style={s.tag}>
          <Text style={s.tagText}>{t}</Text>
        </View>
      ))}
    </View>
  </Card>
)}

temperament 是逗号分隔的字符串,比如 “Friendly, Active, Outgoing”。

split(', ') 拆成数组,每个特点渲染成一个标签。

条件渲染 breed.temperament &&,没有性格数据的品种不显示这个卡片。

标签样式:

tags: {flexDirection: 'row', flexWrap: 'wrap'},
tag: {backgroundColor: '#FFF3E0', paddingHorizontal: 12, paddingVertical: 6, borderRadius: 16, marginRight: 8, marginBottom: 8},
tagText: {fontSize: 13, color: '#E65100'},
  • flexWrap: 'wrap' 让标签自动换行
  • 浅橙色背景 + 深橙色文字,和主题色呼应
  • borderRadius: 16 做成胶囊形状

useStore 的实现

收藏功能依赖 useStore Hook,看看它是怎么实现的:

export function useStore(): AppState & {
  toggleBreed: (id: number) => void;
  toggleImage: (id: string) => void;
} {
  const [state, setState] = useState(appStore.getState());

返回类型是 AppState 加上两个方法。用 useState 存储状态,初始值从 appStore 获取。

订阅状态变化:

  useEffect(() => {
    const unsubscribe = appStore.subscribe(setState);
    return unsubscribe;
  }, []);

组件挂载时订阅 store,状态变化时自动更新组件。

toggleBreed 方法

  const toggleBreed = (id: number) => {
    const list = state.favoriteBreeds;
    appStore.setState({
      favoriteBreeds: list.includes(id) ? list.filter(x => x !== id) : [...list, id],
    });
  };

这个方法实现收藏的切换:

  • 如果 id 已经在列表里,用 filter 移除
  • 如果不在,用展开运算符添加

list.includes(id) 检查是否已收藏,返回布尔值。

三元表达式根据结果决定是移除还是添加。

toggleImage 方法

  const toggleImage = (id: string) => {
    const list = state.favoriteImages;
    appStore.setState({
      favoriteImages: list.includes(id) ? list.filter(x => x !== id) : [...list, id],
    });
  };

  return {...state, toggleBreed, toggleImage};
}

toggleBreed 逻辑一样,只是操作的是图片收藏列表。

最后返回状态和两个方法,组件里解构使用。

全局状态的定义

export interface AppState {
  favoriteBreeds: number[];
  favoriteImages: string[];
  darkMode: boolean;
}

export const appStore = createStore<AppState>({
  favoriteBreeds: [],
  favoriteImages: [],
  darkMode: false,
});

AppState 定义了三个字段:

  • favoriteBreeds:收藏的品种 ID 数组,number 类型
  • favoriteImages:收藏的图片 ID 数组,string 类型
  • darkMode:深色模式开关

初始状态都是空数组和 false。

为什么用 ID 而不是整个对象

收藏列表存的是 ID,不是完整的品种对象。为什么?

节省空间。一个品种对象有十几个字段,存 ID 只需要一个数字。

避免数据不一致。如果存对象,品种信息更新后,收藏里的还是旧数据。存 ID 的话,每次展示都从最新数据里取。

序列化方便。如果要把收藏存到本地(AsyncStorage),ID 数组序列化后很小。

收藏状态的持久化

当前的收藏只存在内存里,App 关闭就没了。要持久化可以这样做:

// 伪代码
import AsyncStorage from '@react-native-async-storage/async-storage';

// 读取
const saved = await AsyncStorage.getItem('favorites');
if (saved) {
  appStore.setState(JSON.parse(saved));
}

// 保存
appStore.subscribe(state => {
  AsyncStorage.setItem('favorites', JSON.stringify(state));
});

订阅状态变化,每次变化都存到本地。App 启动时读取恢复。

这个功能后续可以加,先保证核心流程。

样式的组织

详情页样式比较多,看看怎么组织的:

const s = StyleSheet.create({
  container: {flex: 1, backgroundColor: '#f5f5f5'},
  content: {flex: 1},
  fav: {fontSize: 22},

按功能分组:容器、收藏按钮…

图片和操作区:

  img: {width: '100%', height: 260, backgroundColor: '#eee'},
  actions: {flexDirection: 'row', backgroundColor: '#fff', paddingVertical: 12, marginBottom: 8},
  action: {flex: 1, alignItems: 'center'},
  actionIcon: {fontSize: 24, marginBottom: 2},
  actionText: {fontSize: 12, color: '#666'},

基本信息:

  sectionTitle: {fontSize: 16, fontWeight: '600', color: '#333', marginBottom: 10},
  row: {flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: '#f0f0f0'},
  label: {fontSize: 14, color: '#666'},
  value: {fontSize: 14, color: '#333', flex: 1, textAlign: 'right'},

体型信息:

  sizeRow: {flexDirection: 'row'},
  sizeItem: {flex: 1, alignItems: 'center', paddingVertical: 10},
  sizeLabel: {fontSize: 13, color: '#999'},
  sizeValue: {fontSize: 20, fontWeight: '600', color: '#D2691E', marginTop: 4},

性格标签:

  tags: {flexDirection: 'row', flexWrap: 'wrap'},
  tag: {backgroundColor: '#FFF3E0', paddingHorizontal: 12, paddingVertical: 6, borderRadius: 16, marginRight: 8, marginBottom: 8},
  tagText: {fontSize: 13, color: '#E65100'},
});

样式命名清晰,一眼就知道是干什么的。

数据展示的兜底处理

详情页多处用到了兜底:

breed.breed_group || '未分类'
breed.bred_for || '未知'
breed.origin || '未知'

API 返回的数据不一定完整,有些品种缺少某些字段。不做兜底的话,页面会显示空白或 undefined,用户体验不好。

图片 URL 也有兜底:

breed.image?.url || `https://cdn2.thedogapi.com/images/${breed.reference_image_id}.jpg`

优先用 image.url,没有就用 ID 拼接 CDN 地址。

小结

品种详情页涉及的知识点:

  • 路由参数接收:useRoute 获取列表页传来的数据
  • 收藏功能:useStore 管理全局状态,toggleBreed 切换收藏
  • 数据展示:二维数组配置 + map 渲染,减少重复代码
  • 条件渲染:没有数据的卡片不显示
  • 兜底处理:缺失字段显示默认文案

下一篇讲品种图集页面,会涉及到按品种筛选图片、图片列表的网格布局。


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

Logo

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

更多推荐