rn_for_openharmony_steam资讯app实战-游戏截图实现
本文介绍了使用React Native开发OpenHarmony平台Steam资讯App中的游戏截图页面实现。通过调用appdetails API获取游戏截图数据,包含缩略图和完整图片URL。页面采用2列网格布局展示缩略图,点击可预览完整图片。关键实现包括:使用FlatList优化网格性能、图片缓存策略、Modal实现图片预览、以及加载/空状态处理。样式设计注重用户体验,通过flex布局、固定宽高
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
更多推荐


所有评论(0)