请添加图片描述

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

图片是最好的内容

做宠物类 App,图片绝对是核心。用户打开狗狗之家,第一眼看到的就是各种可爱的狗狗照片。图库页面作为图片模块的入口,承担着展示、浏览、收藏等多重职责。

这篇文章从实际代码出发,讲讲图库页面的实现思路。涉及到瀑布流布局、图片卡片组件、收藏状态管理等内容。

页面整体结构

先看 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';

这里用到了几个关键模块:

  • ScrollView + RefreshControl:实现下拉刷新的滚动容器
  • ImageCard:图片卡片组件,负责单张图片的展示
  • useStore:全局状态管理,处理收藏逻辑
  • api:封装好的接口调用

状态定义

页面需要管理的状态不多,但每个都有明确的用途:

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

navigate 用于跳转到图片详情页。

favoriteImages 是收藏的图片 ID 列表,从全局状态获取。

toggleImage 是切换收藏状态的方法。

loading 控制首次加载的 Loading 显示。

refreshing 控制下拉刷新的状态。

images 存储从 API 获取的图片数据。

数据加载函数

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

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

useEffect 在组件挂载时触发一次数据加载。

loadData 是个 async 函数,调用 api.getImages(20) 获取 20 张随机图片。

finally 块很重要,不管成功还是失败都要把 loading 状态关掉,否则用户会一直看到加载中。

API 接口设计

看看 api.getImages 的实现:

getImages: (limit = 10, breedId?: number) =>
  http.get<DogImage[]>('/images/search', {
    limit,
    breed_ids: breedId,
    order: 'RANDOM',
  }),

参数说明:

  • limit:返回图片数量,默认 10 张
  • breedId:可选,指定品种 ID 筛选
  • order: ‘RANDOM’:随机排序,每次刷新看到不同的图片

这个接口设计得很灵活,图库页面用它获取随机图片,品种详情页也能用它获取特定品种的图片。

页面渲染结构

return (
  <View style={s.container}>
    <Header title="📷 狗狗图库" showBack={false} />
    {loading ? <Loading full /> : (
      <ScrollView 
        style={s.content} 
        contentContainerStyle={s.grid} 
        refreshControl={
          <RefreshControl refreshing={refreshing} onRefresh={loadData} />
        }
      >
        {images.length > 0 ? images.map(img => (
          <ImageCard 
            key={img.id} 
            image={img} 
            liked={favoriteImages.includes(img.id)} 
            onLike={() => toggleImage(img.id)} 
            onPress={() => navigate('ImageView', {image: img})} 
          />
        )) : <Empty icon="📷" title="暂无图片" />}
      </ScrollView>
    )}
  </View>
);

结构分三层:

Header:顶部标题栏,showBack={false} 因为这是底部 Tab 页面,不需要返回按钮。

Loading:首次加载时显示全屏 Loading。

ScrollView:加载完成后显示图片列表。

下拉刷新的实现

<RefreshControl refreshing={refreshing} onRefresh={loadData} />

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

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

onRefresh 是下拉触发的回调,这里直接复用 loadData 函数。

但有个问题,loadData 里没有设置 setRefreshing(true)。正确的做法应该是:

const onRefresh = () => {
  setRefreshing(true);
  loadData();
};

然后把 onRefresh 传给 RefreshControl。不过当前代码也能工作,因为 loadData 的 finally 里会把 refreshing 设为 false。

图片卡片组件

ImageCard 是图库的核心组件,看看它的实现:

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

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

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

卡片的 Props 定义

interface Props {
  image: DogImage;
  liked?: boolean;
  onPress?: () => void;
  onLike?: () => void;
}
  • image:图片数据,必传
  • liked:是否已收藏,可选
  • onPress:点击卡片的回调
  • onLike:点击收藏按钮的回调

likedonLike 设为可选,是因为有些场景可能不需要收藏功能。

卡片渲染逻辑

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 才显示。

收藏图标用 emoji 实现,❤️ 表示已收藏,🤍 表示未收藏。简单直观。

卡片样式详解

const s = StyleSheet.create({
  card: {
    width: W, 
    height: W * 1.2, 
    borderRadius: 12, 
    overflow: 'hidden', 
    marginBottom: 12
  },
  img: {
    width: '100%', 
    height: '100%', 
    backgroundColor: '#f0f0f0'
  },
  like: {
    position: 'absolute', 
    bottom: 8, 
    right: 8, 
    backgroundColor: 'rgba(0,0,0,0.3)', 
    borderRadius: 16, 
    padding: 6
  },
  heart: {fontSize: 18},
});

card 样式:

  • 宽度是计算出来的 W
  • 高度是宽度的 1.2 倍,形成竖向的卡片比例
  • overflow: 'hidden' 配合 borderRadius 实现圆角裁剪

img 样式:

  • 宽高都是 100%,填满卡片
  • 背景色 #f0f0f0 在图片加载前显示,避免空白

like 样式:

  • position: 'absolute' 让按钮浮在图片上
  • 半透明黑色背景让按钮在任何图片上都清晰可见

收藏功能的状态管理

收藏状态存在全局 store 里,看看 useStore 的实现:

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

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

useStore 返回当前状态和操作方法。

通过 appStore.subscribe 订阅状态变化,状态更新时自动触发组件重渲染。

toggleImage 的实现

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

逻辑很简单:

  • 如果已经在收藏列表里,就过滤掉(取消收藏)
  • 如果不在列表里,就添加进去(添加收藏)

用三元表达式一行搞定,简洁明了。

网格布局的实现

const s = StyleSheet.create({
  container: {flex: 1, backgroundColor: '#f5f5f5'},
  content: {flex: 1},
  grid: {
    flexDirection: 'row', 
    flexWrap: 'wrap', 
    padding: 16, 
    justifyContent: 'space-between'
  },
});

grid 样式是关键:

  • flexDirection: 'row' 让子元素横向排列
  • flexWrap: 'wrap' 允许换行
  • justifyContent: 'space-between' 让两列之间自动分配间距

这样就实现了两列的网格布局,不需要额外的布局库。

点击跳转详情

onPress={() => navigate('ImageView', {image: img})}

点击卡片时,跳转到 ImageView 页面,并把整个 image 对象作为参数传递。

这样详情页就能直接使用图片数据,不需要再请求一次接口。

空状态处理

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

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

虽然正常情况下 API 总会返回图片,但做好空状态处理是个好习惯。网络异常、接口变更都可能导致空数据。

性能优化思考

当前实现用的是 ScrollView,对于大量图片可能有性能问题。优化方向:

FlatList 替代 ScrollView:FlatList 有虚拟列表功能,只渲染可见区域的元素。

<FlatList
  data={images}
  numColumns={2}
  renderItem={({item}) => <ImageCard image={item} ... />}
  keyExtractor={item => item.id}
/>

图片懒加载:只有进入可视区域才开始加载图片。

图片缓存:避免重复下载相同的图片。

不过对于 20 张图片的场景,ScrollView 完全够用。过早优化是万恶之源。

图片类型定义

interface DogImage {
  id: string;
  url: string;
  width?: number;
  height?: number;
  breeds?: Breed[];
}

The Dog API 返回的图片数据结构。

id 是唯一标识,用于收藏功能和列表渲染的 key。

url 是图片地址。

breeds 是可选的品种信息,有些图片会带上对应的品种数据。

小结

图库页面虽然代码不多,但涉及的知识点挺丰富:

  • Dimensions API:获取屏幕尺寸,计算响应式布局
  • Flexbox 网格:用 flexWrap 实现多列布局
  • 全局状态:收藏功能的跨组件状态共享
  • 条件渲染:根据状态显示不同的 UI
  • 组件复用:ImageCard 可以在多个页面使用

下一篇讲图片查看页面,会涉及到手势操作和图片缩放。


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

Logo

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

更多推荐