feat: implement back button logic — single tap to scroll to top, double tap to exit.

This commit is contained in:
James Chen
2025-09-05 12:26:44 +08:00
parent 79308607b8
commit 37d8580b9c
2 changed files with 128 additions and 38 deletions

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useCallback, useRef, useState } from "react";
import { View, StyleSheet, ActivityIndicator, FlatList, Pressable, Animated, StatusBar, Platform } from "react-native";
import { View, StyleSheet, ActivityIndicator, FlatList, Pressable, Animated, StatusBar, Platform, BackHandler, ToastAndroid } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ThemedView } from "@/components/ThemedView";
import { ThemedText } from "@/components/ThemedText";
@@ -53,6 +53,41 @@ export default function HomeScreen() {
}, [refreshPlayRecords])
);
// 双击返回退出逻辑(只限当前页面)
const backPressTimeRef = useRef<number | null>(null);
const exitToastShownRef = useRef(false); // 防止重复显示提示
useFocusEffect(
useCallback(() => {
const handleBackPress = () => {
const now = Date.now();
// 如果还没按过返回键或距离上次超过2秒
if (!backPressTimeRef.current || now - backPressTimeRef.current > 2000) {
backPressTimeRef.current = now;
ToastAndroid.show("再按一次返回键退出", ToastAndroid.SHORT);
return true; // 拦截返回事件,不退出
}
// 两次返回键间隔小于2秒退出应用
BackHandler.exitApp();
return true;
};
// 仅限 Android 平台启用此功能
if (Platform.OS === "android") {
const backHandler = BackHandler.addEventListener("hardwareBackPress", handleBackPress);
// 返回首页时重置状态
return () => {
backHandler.remove();
backPressTimeRef.current = null;
exitToastShownRef.current = false;
};
}
}, [])
);
// 统一的数据获取逻辑
useEffect(() => {
if (!selectedCategory) return;

View File

@@ -1,5 +1,5 @@
import React, { useCallback } from "react";
import { View, StyleSheet, ScrollView, ActivityIndicator } from "react-native";
import React, { useCallback, useRef, useState, useEffect } from "react";
import { View, StyleSheet, ScrollView, ActivityIndicator, TouchableOpacity, BackHandler } from "react-native";
import { ThemedText } from "@/components/ThemedText";
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
import { getCommonResponsiveStyles } from "@/utils/ResponsiveStyles";
@@ -20,7 +20,7 @@ interface CustomScrollViewProps {
const CustomScrollView: React.FC<CustomScrollViewProps> = ({
data,
renderItem,
numColumns, // 现在可选,如果不提供将使用响应式默认值
numColumns,
loading = false,
loadingMore = false,
error = null,
@@ -29,8 +29,27 @@ const CustomScrollView: React.FC<CustomScrollViewProps> = ({
emptyMessage = "暂无内容",
ListFooterComponent,
}) => {
const scrollViewRef = useRef<ScrollView>(null);
const firstCardRef = useRef<any>(null); // <--- 新增
const [showScrollToTop, setShowScrollToTop] = useState(false);
const responsiveConfig = useResponsiveLayout();
const commonStyles = getCommonResponsiveStyles(responsiveConfig);
const { deviceType } = responsiveConfig;
// 添加返回键处理逻辑
useEffect(() => {
if (deviceType === 'tv') {
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
if (showScrollToTop) {
scrollToTop();
return true; // 阻止默认的返回行为
}
return false; // 允许默认的返回行为
});
return () => backHandler.remove();
}
}, [showScrollToTop,deviceType]);
// 使用响应式列数,如果没有明确指定的话
const effectiveColumns = numColumns || responsiveConfig.columns;
@@ -40,6 +59,9 @@ const CustomScrollView: React.FC<CustomScrollViewProps> = ({
const { layoutMeasurement, contentOffset, contentSize } = nativeEvent;
const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - loadMoreThreshold;
// 显示/隐藏返回顶部按钮
setShowScrollToTop(contentOffset.y > 200);
if (isCloseToBottom && !loadingMore && onEndReached) {
onEndReached();
}
@@ -47,6 +69,14 @@ const CustomScrollView: React.FC<CustomScrollViewProps> = ({
[onEndReached, loadingMore, loadMoreThreshold]
);
const scrollToTop = () => {
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
// 滚动动画结束后聚焦第一个卡片
setTimeout(() => {
firstCardRef.current?.focus();
}, 500); // 500ms 适配大多数动画时长
};
const renderFooter = () => {
if (ListFooterComponent) {
if (React.isValidElement(ListFooterComponent)) {
@@ -124,47 +154,72 @@ const CustomScrollView: React.FC<CustomScrollViewProps> = ({
width: responsiveConfig.cardWidth,
marginRight: responsiveConfig.spacing,
},
scrollToTopButton: {
position: 'absolute',
right: responsiveConfig.spacing,
bottom: responsiveConfig.spacing * 2,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
padding: responsiveConfig.spacing,
borderRadius: responsiveConfig.spacing,
opacity: showScrollToTop ? 1 : 0,
},
});
return (
<ScrollView
contentContainerStyle={dynamicStyles.listContent}
onScroll={handleScroll}
scrollEventThrottle={16}
showsVerticalScrollIndicator={responsiveConfig.deviceType !== 'tv'}
>
{data.length > 0 ? (
<>
{rows.map((row, rowIndex) => {
const isFullRow = row.length === effectiveColumns;
const rowStyle = isFullRow ? dynamicStyles.fullRowContainer : dynamicStyles.partialRowContainer;
<View style={{ flex: 1 }}>
<ScrollView
ref={scrollViewRef}
contentContainerStyle={dynamicStyles.listContent}
onScroll={handleScroll}
scrollEventThrottle={16}
showsVerticalScrollIndicator={responsiveConfig.deviceType !== 'tv'}
>
{data.length > 0 ? (
<>
{rows.map((row, rowIndex) => {
const isFullRow = row.length === effectiveColumns;
const rowStyle = isFullRow ? dynamicStyles.fullRowContainer : dynamicStyles.partialRowContainer;
return (
<View key={rowIndex} style={[dynamicStyles.rowContainer, rowStyle]}>
{row.map((item, itemIndex) => {
const actualIndex = rowIndex * effectiveColumns + itemIndex;
const isLastItemInPartialRow = !isFullRow && itemIndex === row.length - 1;
const itemStyle = isLastItemInPartialRow ? dynamicStyles.itemContainer : dynamicStyles.itemWithMargin;
return (
<View key={rowIndex} style={[dynamicStyles.rowContainer, rowStyle]}>
{row.map((item, itemIndex) => {
const actualIndex = rowIndex * effectiveColumns + itemIndex;
const isLastItemInPartialRow = !isFullRow && itemIndex === row.length - 1;
const itemStyle = isLastItemInPartialRow ? dynamicStyles.itemContainer : dynamicStyles.itemWithMargin;
return (
<View key={actualIndex} style={isFullRow ? dynamicStyles.itemContainer : itemStyle}>
{renderItem({ item, index: actualIndex })}
</View>
);
})}
</View>
);
})}
{renderFooter()}
</>
) : (
<View style={commonStyles.center}>
<ThemedText>{emptyMessage}</ThemedText>
</View>
const cardProps = {
key: actualIndex,
style: isFullRow ? dynamicStyles.itemContainer : itemStyle,
};
return (
<View {...cardProps}>
{renderItem({ item, index: actualIndex })}
</View>
);
})}
</View>
);
})}
{renderFooter()}
</>
) : (
<View style={commonStyles.center}>
<ThemedText>{emptyMessage}</ThemedText>
</View>
)}
</ScrollView>
{deviceType!=='tv' && (
<TouchableOpacity
style={dynamicStyles.scrollToTopButton}
onPress={scrollToTop}
activeOpacity={0.8}
>
<ThemedText></ThemedText>
</TouchableOpacity>
)}
</ScrollView>
</View>
);
};
export default CustomScrollView;