React Native for OpenHarmony 实战:Steam 资讯 App 游戏截图页面

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

游戏截图是展示游戏画面的重要方式。截图页面需要展示游戏的所有截图,并支持图片预览、缩放等功能。这个页面涉及图片网格布局、图片加载优化和交互设计。
请添加图片描述

截图数据的来源

游戏的截图数据来自 appdetails API 的返回结果。这个 API 返回的数据中包含 screenshots 数组:

export const getAppDetails = async (appId: number) => {
  const res = await fetch(`${STORE_API}/appdetails?appids=${appId}`);
  return res.json();
};

这里做了什么: 调用 appdetails 接口获取游戏详情。这个接口返回的数据包含游戏的所有信息,包括截图、视频、成就等。

为什么用 appdetails: 虽然 Steam 没有专门的截图 API,但 appdetails 接口已经包含了所有需要的信息。这样可以减少 API 调用次数。

数据结构: appdetails 返回的 screenshots 数组大概是这样的:

{
  "screenshots": [
    {
      "id": 0,
      "path_thumbnail": "https://cdn.cloudflare.steamstatic.com/steam/apps/730/ss_xxx_thumb.jpg",
      "path_full": "https://cdn.cloudflare.steamstatic.com/steam/apps/730/ss_xxx.jpg"
    }
  ]
}

关键字段说明:

  • id - 截图的唯一标识
  • path_thumbnail - 缩略图 URL,用于列表显示
  • path_full - 完整图片 URL,用于预览

页面结构设计

截图页面的布局需要展示大量图片:

顶部 是 Header,显示游戏名称和返回按钮。

中间 是图片网格,通常是 2 列或 3 列布局。

每个网格项 显示截图的缩略图,点击可以预览。

底部 是 TabBar,保持应用的导航一致性。

核心代码实现

组件初始化和数据加载

export const GameScreenshotsScreen = () => {
  const {selectedAppId} = useApp();
  const [screenshots, setScreenshots] = useState<any[]>([]);
  const [loading, setLoading] = useState(true);
  const [selectedImage, setSelectedImage] = useState<string | null>(null);

这里做了什么: 定义了截图列表、加载状态和当前选中的图片。selectedImage 用来存储用户点击的图片 URL,用于预览。

状态的作用: screenshots 存储所有截图数据,loading 控制加载动画,selectedImage 控制预览 Modal 的显示。

为什么分离 selectedImage: 这样可以独立控制预览 Modal 的显示和隐藏,不需要修改 screenshots 数组。

数据加载

  useEffect(() => {
    if (!selectedAppId) return;
    
    getAppDetails(selectedAppId).then(data => {
      const gameData = data?.[selectedAppId]?.data;
      setScreenshots(gameData?.screenshots || []);
      setLoading(false);
    }).catch(() => setLoading(false));
  }, [selectedAppId]);

这里做了什么: 当组件挂载或 selectedAppId 变化时,调用 getAppDetails() 获取游戏详情,然后提取 screenshots 数组。

数据提取的安全性: 使用可选链操作符 ?. 安全地访问嵌套属性。如果某个属性不存在,不会抛出错误。

默认值的处理: 如果 screenshots 不存在,使用空数组作为默认值。这样即使 API 返回的数据不完整,也不会导致应用崩溃。

错误处理: 即使请求失败,也会把 loading 设为 false,这样用户不会一直看到加载动画。

图片网格的渲染

<FlatList
  data={screenshots}
  numColumns={2}
  keyExtractor={(item) => item.id.toString()}
  renderItem={({item}) => (
    <TouchableOpacity
      style={styles.gridItem}
      onPress={() => setSelectedImage(item.path_full)}
    >
      <Image
        source={{uri: item.path_thumbnail}}
        style={styles.thumbnail}
        cache="force-cache"
      />
    </TouchableOpacity>
  )}
/>

这里做了什么:

  • numColumns={2} - 设置为 2 列网格布局
  • 缩略图显示 - 使用 path_thumbnail 显示缩略图
  • 点击预览 - 点击时设置 selectedImage,打开预览 Modal

为什么用 FlatList: FlatList 的 numColumns 属性可以轻松实现网格布局。相比 ScrollView + map(),FlatList 有虚拟化优化,性能更好。

缩略图的优势: 使用缩略图而不是完整图片可以显著减少加载时间和流量消耗。缩略图通常只有几十 KB,而完整图片可能有几 MB。

图片缓存: cache="force-cache" 告诉 React Native 优先使用缓存的图片。这样用户滚动列表时,已经加载过的缩略图会从缓存读取。

图片预览 Modal

{selectedImage && (
  <Modal visible={true} transparent={true}>
    <View style={styles.previewContainer}>
      <TouchableOpacity
        style={styles.closeBtn}
        onPress={() => setSelectedImage(null)}
      >
        <Text style={styles.closeBtnText}>✕</Text>
      </TouchableOpacity>
      <Image
        source={{uri: selectedImage}}
        style={styles.previewImage}
        resizeMode="contain"
      />
    </View>
  </Modal>
)}

这里做了什么:

  • Modal 显示 - 当 selectedImage 不为 null 时显示 Modal
  • 关闭按钮 - 点击关闭按钮或背景可以关闭预览
  • 图片显示 - 使用 resizeMode=“contain” 保持图片比例

Modal 的设计: 使用 transparent={true} 使背景透明,这样可以看到后面的内容。用户点击关闭按钮时,设置 selectedImage 为 null,Modal 就会隐藏。

resizeMode 的作用: contain 表示保持图片的宽高比,不会拉伸或裁剪。这样可以完整地显示整个图片。

关闭按钮的位置: 关闭按钮放在左上角,这是一个常见的 UI 模式。用户能快速找到关闭按钮。

加载和空状态

if (loading) {
  return (
    <View style={styles.container}>
      <Header title="游戏截图" showBack />
      <Loading />
      <TabBar />
    </View>
  );
}

if (screenshots.length === 0) {
  return (
    <View style={styles.container}>
      <Header title="游戏截图" showBack />
      <View style={styles.emptyContainer}>
        <Text style={styles.emptyText}>暂无截图</Text>
      </View>
      <TabBar />
    </View>
  );
}

这里做了什么:

  • 加载状态 - 显示 Loading 组件
  • 空状态 - 如果没有截图,显示"暂无截图"提示

用户体验: 通过显示明确的状态提示,用户能理解当前的情况。不会因为看到空白页面而困惑。

提前返回模式: 这种模式让代码更清晰,避免嵌套多层 if-else。

样式设计

网格项的样式

gridItem: {
  flex: 1,
  margin: 8,
  aspectRatio: 16 / 9,
  borderRadius: 8,
  overflow: 'hidden',
},
thumbnail: {
  width: '100%',
  height: '100%',
},

这里的设计考量:

  • flex: 1 - 每个项占据相等的空间
  • margin: 8 - 项之间有 8 的间距
  • aspectRatio: 16 / 9 - 保持 16:9 的宽高比
  • borderRadius: 8 - 圆角使图片看起来更现代
  • overflow: ‘hidden’ - 超出边界的内容被隐藏

aspectRatio 的作用: 这个属性保持固定的宽高比。即使图片的实际比例不同,容器也会保持 16:9。这样网格看起来很整齐。

overflow: ‘hidden’ 的作用: 这个属性确保圆角效果正确显示。如果不设置,图片可能会超出圆角边界。

margin 的作用: 项之间的间距使网格看起来不拥挤。8 是一个常见的间距单位。

预览图片的样式

previewContainer: {
  flex: 1,
  backgroundColor: 'rgba(0, 0, 0, 0.9)',
  justifyContent: 'center',
  alignItems: 'center',
},
previewImage: {
  width: '90%',
  height: '90%',
},
closeBtn: {
  position: 'absolute',
  top: 20,
  left: 20,
  width: 40,
  height: 40,
  borderRadius: 20,
  backgroundColor: 'rgba(255, 255, 255, 0.3)',
  justifyContent: 'center',
  alignItems: 'center',
  zIndex: 10,
},
closeBtnText: {
  fontSize: 24,
  color: '#fff',
  fontWeight: 'bold',
},

这里的设计考量:

  • backgroundColor: ‘rgba(0, 0, 0, 0.9)’ - 深色半透明背景
  • width: ‘90%’, height: ‘90%’ - 图片占据屏幕的 90%
  • position: ‘absolute’ - 关闭按钮绝对定位
  • zIndex: 10 - 确保关闭按钮在最上层

背景颜色的选择: 深色背景可以让图片更突出。0.9 的透明度表示 90% 不透明,10% 透明。

图片大小的设置: 90% 的大小可以在图片周围留出一些空间,看起来不会太拥挤。

关闭按钮的设计: 圆形按钮(borderRadius: 20)看起来更现代。半透明的白色背景可以在深色背景上清晰显示。

图片加载优化

渐进式加载

const [imageLoading, setImageLoading] = useState<Record<number, boolean>>({});

<Image
  source={{uri: item.path_thumbnail}}
  style={styles.thumbnail}
  onLoadStart={() => setImageLoading({...imageLoading, [item.id]: true})}
  onLoadEnd={() => setImageLoading({...imageLoading, [item.id]: false})}
/>

这里做了什么:

  • onLoadStart - 图片开始加载时调用
  • onLoadEnd - 图片加载完成时调用
  • imageLoading 状态 - 记录每个图片的加载状态

为什么要追踪加载状态: 这样可以在图片加载时显示占位符或加载动画。用户能看到图片正在加载,而不是一个空白区域。

Record 类型的使用: Record<number, boolean> 表示一个对象,键是图片 ID,值是加载状态。这样可以独立追踪每个图片的加载状态。

占位符的显示

{imageLoading[item.id] && (
  <View style={styles.placeholder}>
    <ActivityIndicator size="large" color="#66c0f4" />
  </View>
)}

这里做了什么: 当图片正在加载时,显示一个加载动画。

用户体验: 加载动画告诉用户图片正在加载,而不是应用卡住了。

ActivityIndicator 的作用: 这是 React Native 内置的加载动画组件。size="large" 表示大尺寸,color="#66c0f4" 设置颜色为 Steam 蓝。

图片预览的增强

支持缩放

import { PinchGestureHandler, State } from 'react-native-gesture-handler';

const [scale, setScale] = useState(1);

const onPinchEvent = Animated.event(
  [{ nativeEvent: { scale } }],
  { useNativeDriver: false }
);

<PinchGestureHandler onGestureEvent={onPinchEvent}>
  <Animated.Image
    source={{uri: selectedImage}}
    style={[
      styles.previewImage,
      { transform: [{ scale }] }
    ]}
  />
</PinchGestureHandler>

这里做了什么:

  • PinchGestureHandler - 捕获两指缩放手势
  • Animated.event - 将手势事件映射到动画值
  • transform: [{ scale }] - 应用缩放变换

为什么要支持缩放: 用户可能想看清图片的细节。通过两指缩放,用户可以放大图片。

Animated 的作用: 使用 Animated API 可以让缩放动画更流畅。直接修改状态会导致重新渲染,性能不好。

useNativeDriver: 设置为 false 是因为 transform 不支持 native driver。

支持滑动切换

const [currentIndex, setCurrentIndex] = useState(0);

const onScroll = (event: any) => {
  const index = Math.round(event.nativeEvent.contentOffset.x / screenWidth);
  setCurrentIndex(index);
};

<ScrollView
  horizontal
  pagingEnabled
  onScroll={onScroll}
  scrollEventThrottle={16}
>
  {screenshots.map((item) => (
    <Image
      key={item.id}
      source={{uri: item.path_full}}
      style={{width: screenWidth, height: screenHeight}}
    />
  ))}
</ScrollView>

这里做了什么:

  • horizontal - 水平滚动
  • pagingEnabled - 启用分页,每次滚动一个屏幕
  • onScroll - 监听滚动事件,更新当前索引

为什么要支持滑动切换: 用户可以通过滑动在不同的截图之间切换,这是一个常见的交互模式。

pagingEnabled 的作用: 这个属性使 ScrollView 以屏幕为单位滚动,而不是连续滚动。这样用户每次滑动都会看到一个完整的图片。

scrollEventThrottle: 这个属性控制滚动事件的触发频率。16 表示每 16ms 触发一次,这样可以保证动画流畅。

完整页面示例

import React, {useEffect, useState} from 'react';
import {View, Text, FlatList, TouchableOpacity, Image, StyleSheet, Modal, ActivityIndicator} from 'react-native';
import {Header} from '../components/Header';
import {TabBar} from '../components/TabBar';
import {Loading} from '../components/Loading';
import {useApp} from '../store/AppContext';
import {getAppDetails} from '../api/steam';

export const GameScreenshotsScreen = () => {
  const {selectedAppId} = useApp();
  const [screenshots, setScreenshots] = useState<any[]>([]);
  const [loading, setLoading] = useState(true);
  const [selectedImage, setSelectedImage] = useState<string | null>(null);
  const [imageLoading, setImageLoading] = useState<Record<number, boolean>>({});

  useEffect(() => {
    if (!selectedAppId) return;
    
    getAppDetails(selectedAppId).then(data => {
      const gameData = data?.[selectedAppId]?.data;
      setScreenshots(gameData?.screenshots || []);
      setLoading(false);
    }).catch(() => setLoading(false));
  }, [selectedAppId]);

  if (loading) {
    return (
      <View style={styles.container}>
        <Header title="游戏截图" showBack />
        <Loading />
        <TabBar />
      </View>
    );
  }

  return (
    <View style={styles.container}>
      <Header title="游戏截图" showBack />
      
      {screenshots.length === 0 ? (
        <View style={styles.emptyContainer}>
          <Text style={styles.emptyText}>暂无截图</Text>
        </View>
      ) : (
        <FlatList
          data={screenshots}
          numColumns={2}
          keyExtractor={(item) => item.id.toString()}
          renderItem={({item}) => (
            <TouchableOpacity
              style={styles.gridItem}
              onPress={() => setSelectedImage(item.path_full)}
            >
              {imageLoading[item.id] && (
                <View style={styles.placeholder}>
                  <ActivityIndicator size="large" color="#66c0f4" />
                </View>
              )}
              <Image
                source={{uri: item.path_thumbnail}}
                style={styles.thumbnail}
                cache="force-cache"
                onLoadStart={() => setImageLoading({...imageLoading, [item.id]: true})}
                onLoadEnd={() => setImageLoading({...imageLoading, [item.id]: false})}
              />
            </TouchableOpacity>
          )}
        />
      )}

      {selectedImage && (
        <Modal visible={true} transparent={true}>
          <View style={styles.previewContainer}>
            <TouchableOpacity
              style={styles.closeBtn}
              onPress={() => setSelectedImage(null)}
            >
              <Text style={styles.closeBtnText}>✕</Text>
            </TouchableOpacity>
            <Image
              source={{uri: selectedImage}}
              style={styles.previewImage}
              resizeMode="contain"
            />
          </View>
        </Modal>
      )}

      <TabBar />
    </View>
  );
};

const styles = StyleSheet.create({
  container: {flex: 1, backgroundColor: '#171a21'},
  emptyContainer: {flex: 1, justifyContent: 'center', alignItems: 'center'},
  emptyText: {fontSize: 16, color: '#8f98a0'},
  gridItem: {flex: 1, margin: 8, aspectRatio: 16 / 9, borderRadius: 8, overflow: 'hidden'},
  thumbnail: {width: '100%', height: '100%'},
  placeholder: {position: 'absolute', width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center', backgroundColor: '#1b2838', zIndex: 5},
  previewContainer: {flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.9)', justifyContent: 'center', alignItems: 'center'},
  previewImage: {width: '90%', height: '90%'},
  closeBtn: {position: 'absolute', top: 20, left: 20, width: 40, height: 40, borderRadius: 20, backgroundColor: 'rgba(255, 255, 255, 0.3)', justifyContent: 'center', alignItems: 'center', zIndex: 10},
  closeBtnText: {fontSize: 24, color: '#fff', fontWeight: 'bold'},
});

这里做了什么: 完整的游戏截图页面实现,包括:

  • 截图列表的加载和渲染
  • 2 列网格布局
  • 图片预览 Modal
  • 加载状态显示
  • 空状态处理

小结

游戏截图页面虽然功能相对简单,但涉及了很多实用的开发技巧:

  • 网格布局 - 使用 FlatList 的 numColumns 属性实现
  • 图片优化 - 使用缩略图和缓存提高性能
  • 加载状态 - 显示加载动画提升用户体验
  • 预览功能 - 使用 Modal 实现全屏预览
  • 交互设计 - 支持缩放和滑动切换

下一篇我们来实现游戏视频页面,这个页面会展示游戏的所有视频,涉及视频播放和列表管理。


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

Logo

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

更多推荐