Files
rn-app1/app/(tabs)/activity.tsx

264 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useRef } from 'react';
import {
Text,
SafeAreaView,
StyleSheet,
View,
FlatList,
TouchableOpacity,
Dimensions,
Image,
NativeSyntheticEvent,
NativeScrollEvent,
LayoutChangeEvent // 导入 LayoutChangeEvent 类型
} from "react-native";
const screenWidth = Dimensions.get('window').width;
// 分类列表
const categoryList = [
{ id: '0', title: '捕鱼', icon: '🎣' },
{ id: '1', title: '真人', icon: '👤' },
{ id: '2', title: '区块链', icon: '⛓️' },
{ id: '3', title: '棋牌', icon: '🎴' },
{ id: '4', title: '电子', icon: '🎰' },
{ id: '5', title: '体育', icon: '⚽' },
{ id: '6', title: '彩票', icon: '🎫' },
{ id: '7', title: '电竞', icon: '🎮' },
{ id: '8', title: '赛车', icon: '🏎️' },
{ id: '9', title: '街机', icon: '🕹️' },
{ id: '10', title: '桌游', icon: '🎲' },
{ id: '11', title: '其他', icon: '⭐' },
];
// 随机生成游戏数据
const generateRandomGames = (categoryId: string, baseName: string) => {
// 调整随机数使其更容易出现空列表或少量item以测试优化效果
// (Math.random() * 6) + 0 意味着可能产生 0 到 5 个
const count = Math.floor(Math.random() * 6);
const gameTypes = ['经典版', '豪华版', '至尊版', '竞技版', '休闲版', '大师版', '传奇版', '极速版', '黄金版', '钻石版', '王者版'];
const colors = ['4A90E2', 'E94B3C', '50C878', 'F39C12', '9B59B6', 'E74C3C'];
return Array.from({ length: count }).map((_, i) => {
const color = colors[Math.floor(Math.random() * colors.length)];
return {
id: `${categoryId}-${i}`,
name: `${baseName}${gameTypes[i % gameTypes.length]}`,
hot: Math.random() > 0.6,
image: `https://via.placeholder.com/120x80/${color}/ffffff?text=Game+${i + 1}`,
};
});
};
// 模拟数据
const mockData: Record<string, Array<{ id: string, name: string, hot: boolean, image: string }>> = {};
categoryList.forEach(cat => {
mockData[cat.id] = generateRandomGames(cat.id, cat.title);
});
export default function ActivityScreen() {
const [selectedId, setSelectedId] = useState('0');
const [isSwitching, setIsSwitching] = useState(false);
const rightListRef = useRef<FlatList>(null);
// 【新增】State用于存储右侧列表容器的实际高度
const [rightListHeight, setRightListHeight] = useState(0);
// 支持循环切换分类
const handleCategorySwitch = (direction: 'prev' | 'next') => {
const currentIndex = categoryList.findIndex(item => item.id === selectedId);
const total = categoryList.length;
let newIndex = direction === 'next' ? (currentIndex + 1) % total : (currentIndex - 1 + total) % total;
// 切换分类
setSelectedId(categoryList[newIndex].id);
// 切换时重置滚动,避免新列表加载时停在奇怪的位置
rightListRef.current?.scrollToOffset({ offset: 0, animated: false });
};
// 实时检测滚动边界 + 循环切换
const handleScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent;
const y = contentOffset.y;
const layoutHeight = layoutMeasurement.height;
const contentHeight = contentSize.height;
// 如果正在切换中,则锁定,防止短时间内连续触发
if (isSwitching) return;
// 滑到顶部并继续下拉(-40 是一个触发阈值)
if (y < -40) {
setIsSwitching(true);
handleCategorySwitch('prev');
// 延迟重置isSwitching状态给动画和数据加载留出时间
setTimeout(() => setIsSwitching(false), 600);
}
// 滑到底部并继续上推
// contentHeight <= layoutHeight 检查内容是否填满
// (如果没填满) y > 40 检查是否在空白处上拉
// (如果已填满) y + layoutHeight - contentHeight > 40 检查是否滚动到底部并继续上拉
if (contentHeight <= layoutHeight ? y > 40 : y + layoutHeight - contentHeight > 40) {
setIsSwitching(true);
handleCategorySwitch('next');
setTimeout(() => setIsSwitching(false), 600);
}
};
// 【新增】onLayout 事件处理函数
// 当右侧容器布局时,获取其高度
const onRightListLayout = (event: LayoutChangeEvent) => {
const { height } = event.nativeEvent.layout;
setRightListHeight(height);
};
const LeftItem = ({ id, title, icon, isSelected, onPress }: any) => (
<TouchableOpacity
style={[styles.leftItem, isSelected && styles.leftItemActive]}
onPress={onPress}
>
<Text style={styles.leftIcon}>{icon}</Text>
<Text style={[styles.leftTitle, isSelected && styles.leftTitleActive]}>{title}</Text>
{isSelected && <View style={styles.activeIndicator} />}
</TouchableOpacity>
);
const RightItem = ({ name, hot, image }: any) => (
<View style={styles.rightItem}>
<Image source={{ uri: image }} style={styles.gameImage} resizeMode="cover" />
<View style={styles.rightItemInfo}>
<View style={styles.rightItemContent}>
<Text style={styles.rightItemTitle} numberOfLines={1}>{name}</Text>
{hot && <View style={styles.hotBadge}><Text style={styles.hotText}>HOT</Text></View>}
</View>
<Text style={styles.rightItemDesc}></Text>
</View>
</View>
);
return (
<SafeAreaView style={styles.safeAreaContainer}>
<View style={styles.container}>
<View style={styles.content}>
{/* 左侧分类 */}
<View style={styles.leftWrapper}>
<FlatList
data={categoryList}
renderItem={({ item }) => (
<LeftItem
id={item.id}
title={item.title}
icon={item.icon}
isSelected={selectedId === item.id}
onPress={() => setSelectedId(item.id)}
/>
)}
keyExtractor={item => item.id}
showsVerticalScrollIndicator={false}
/>
</View>
{/* 右侧内容 */}
{/* 【优化点 1】给容器添加 onLayout 来获取高度 */}
<View style={styles.contentRight} onLayout={onRightListLayout}>
{/* 只有在获取到高度后才渲染 FlatList确保 minHeight 初始值正确 */}
{rightListHeight > 0 && (
<FlatList
ref={rightListRef}
data={mockData[selectedId] || []}
renderItem={({ item }) => (
<RightItem name={item.name} hot={item.hot} image={item.image} />
)}
keyExtractor={item => item.id}
showsVerticalScrollIndicator={false}
// bounces 必须为 true (默认值)
bounces={true}
scrollEventThrottle={16}
onScroll={handleScroll}
// 【优化点 2】为 iOS 添加,使其在内容不足时也能反弹
alwaysBounceVertical={true}
// 【优化点 3】设置 contentContainerStyle
contentContainerStyle={[
styles.rightListContent,
{
// 确保内容容器的最小高度
// 始终比 FlatList 视图本身高 1 像素
minHeight: rightListHeight + 1
}
]}
/>
)}
</View>
</View>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeAreaContainer: { flex: 1, backgroundColor: '#f5f5f5' },
container: { flex: 1 },
content: { flex: 1, flexDirection: 'row' },
leftWrapper: {
width: 90,
backgroundColor: '#fff',
borderRightWidth: 1,
borderRightColor: '#e0e0e0',
},
leftItem: {
paddingVertical: 18,
paddingHorizontal: 8,
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
},
leftItemActive: { backgroundColor: '#f0f0f0' },
leftIcon: { fontSize: 24, marginBottom: 4 },
leftTitle: { fontSize: 12, color: '#666', textAlign: 'center' },
leftTitleActive: { color: '#1890ff', fontWeight: '600' },
activeIndicator: {
position: 'absolute',
left: 0,
top: '50%',
marginTop: -12,
width: 3,
height: 24,
backgroundColor: '#1890ff',
borderTopRightRadius: 2,
borderBottomRightRadius: 2,
},
contentRight: { flex: 1, backgroundColor: '#f5f5f5' },
rightListContent: {
padding: 12,
paddingBottom: 20
},
rightItem: {
backgroundColor: '#fff',
borderRadius: 8,
padding: 12,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
flexDirection: 'row',
alignItems: 'center',
},
gameImage: {
width: 120,
height: 80,
borderRadius: 6,
marginRight: 12,
backgroundColor: '#f0f0f0',
},
rightItemInfo: { flex: 1, justifyContent: 'center' },
rightItemContent: { flexDirection: 'row', alignItems: 'center', marginBottom: 6 },
rightItemTitle: { fontSize: 15, fontWeight: '600', color: '#333', marginRight: 8, flex: 1 },
hotBadge: { backgroundColor: '#ff4d4f', paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4 },
hotText: { color: '#fff', fontSize: 10, fontWeight: '600' },
rightItemDesc: { fontSize: 13, color: '#999' },
});