feat: 完善个人中心与小组件功能,优化 Onboarding 交互与手势体验
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useState } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { Pressable } from 'react-native';
|
||||
import { useEffect, useLayoutEffect, useMemo, useState, useCallback, useRef } from 'react';
|
||||
import { StyleSheet, View, Dimensions, Text, Pressable, PanResponder, Animated as RNAnimated } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigation } from 'expo-router';
|
||||
import { useNavigation, useFocusEffect } from 'expo-router';
|
||||
import Animated, {
|
||||
Easing,
|
||||
runOnJS,
|
||||
@@ -27,9 +26,10 @@ import ThemeModal from '@/components/home/ThemeModal';
|
||||
|
||||
import ThemeIcon from '@/assets/images/home/theme.svg';
|
||||
import MyIcon from '@/assets/images/home/my.svg';
|
||||
import LikeOutlineIcon from '@/assets/images/home/like.svg';
|
||||
import LikeFilledIcon from '@/assets/images/home/like_filled.svg';
|
||||
import HateIcon from '@/assets/images/home/hate.svg';
|
||||
import LikeIcon from '@/assets/images/icon/like_icon.svg';
|
||||
|
||||
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { t } = useTranslation();
|
||||
@@ -44,19 +44,27 @@ export default function HomeScreen() {
|
||||
|
||||
const item = useMemo(() => MOCK_CONTENT[index % MOCK_CONTENT.length], [index]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const mode = await getThemeMode();
|
||||
const profile = await getUserProfile();
|
||||
if (cancelled) return;
|
||||
setThemeModeState(mode);
|
||||
setProfileName(profile.name);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
// 动画相关 Shared Values
|
||||
const translateY = useSharedValue(0);
|
||||
const opacity = useSharedValue(1);
|
||||
const likeScale = useSharedValue(1);
|
||||
|
||||
// 每次进入页面或页面获得焦点时刷新个人信息
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const mode = await getThemeMode();
|
||||
const profile = await getUserProfile();
|
||||
if (cancelled) return;
|
||||
setThemeModeState(mode);
|
||||
setProfileName(profile.name);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [])
|
||||
);
|
||||
|
||||
const backgroundColor = themeMode === 'color' ? '#F3D0E1' : '#F4D6C2';
|
||||
|
||||
@@ -83,60 +91,109 @@ export default function HomeScreen() {
|
||||
});
|
||||
}, [backgroundColor, navigation, t]);
|
||||
|
||||
const likeScale = useSharedValue(1);
|
||||
const hateScale = useSharedValue(1);
|
||||
const textAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: translateY.value }],
|
||||
opacity: opacity.value,
|
||||
}));
|
||||
|
||||
const likeAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: likeScale.value }],
|
||||
}));
|
||||
|
||||
const hateAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: hateScale.value }],
|
||||
}));
|
||||
// 切换到下一条文案的统一动画逻辑
|
||||
const triggerNextContent = useCallback(() => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
|
||||
function playLikeAnimationAndThen(next: () => void) {
|
||||
// 1. 当前文案向上移动并消失
|
||||
translateY.value = withTiming(-40, { duration: 300, easing: Easing.out(Easing.quad) });
|
||||
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
||||
if (finished) {
|
||||
// 2. 切换数据索引
|
||||
runOnJS(setIndex)(index + 1);
|
||||
runOnJS(setLikeFilled)(false);
|
||||
|
||||
// 3. 准备下一条文案:先瞬移到下方 40pt
|
||||
translateY.value = 40;
|
||||
|
||||
// 4. 下一条文案向上移动到原位并显现
|
||||
translateY.value = withTiming(0, { duration: 400, easing: Easing.out(Easing.back(1)) });
|
||||
opacity.value = withTiming(1, { duration: 400 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(setBusy)(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [busy, index, translateY, opacity]);
|
||||
|
||||
const lastTapRef = useRef<number>(0);
|
||||
|
||||
// 使用 Ref 解决 PanResponder 闭包陷阱,确保手势回调能拿到最新的 state 和 function
|
||||
const handlersRef = useRef({ onPressLike, triggerNextContent });
|
||||
useEffect(() => {
|
||||
handlersRef.current = { onPressLike, triggerNextContent };
|
||||
}, [onPressLike, triggerNextContent]);
|
||||
|
||||
// 使用系统自带的 PanResponder 代替第三方手势库
|
||||
const panResponder = useRef(
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true, // 允许开始捕获
|
||||
onMoveShouldSetPanResponder: (_, gestureState) => {
|
||||
// 只有当垂直滑动距离大于 20 时才接管位移手势
|
||||
return Math.abs(gestureState.dy) > 20;
|
||||
},
|
||||
onPanResponderRelease: (_, gestureState) => {
|
||||
const now = Date.now();
|
||||
const DOUBLE_TAP_DELAY = 300;
|
||||
|
||||
// 1. 双击逻辑判定
|
||||
if (now - lastTapRef.current < DOUBLE_TAP_DELAY) {
|
||||
// 判定为双击
|
||||
if (Math.abs(gestureState.dx) < 10 && Math.abs(gestureState.dy) < 10) {
|
||||
runOnJS(handlersRef.current.onPressLike)();
|
||||
lastTapRef.current = 0; // 重置
|
||||
return;
|
||||
}
|
||||
}
|
||||
lastTapRef.current = now;
|
||||
|
||||
// 2. 上滑逻辑判定
|
||||
if (gestureState.dy < -50) { // 上滑超过 50pt
|
||||
runOnJS(handlersRef.current.triggerNextContent)();
|
||||
}
|
||||
},
|
||||
})
|
||||
).current;
|
||||
|
||||
async function onPressLike() {
|
||||
if (busy) return;
|
||||
setLikeFilled(true);
|
||||
|
||||
// 1. 获取当前日期
|
||||
const now = new Date();
|
||||
const dateStr = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')}`;
|
||||
|
||||
// 2. 保存到收藏夹,包含当前背景信息
|
||||
await addFavorite({
|
||||
id: item.id,
|
||||
date: dateStr,
|
||||
themeMode: themeMode,
|
||||
background: backgroundColor, // 目前存储的是颜色值
|
||||
});
|
||||
|
||||
// 3. 爱心缩放动画
|
||||
likeScale.value = withSequence(
|
||||
withTiming(0.92, { duration: 90, easing: Easing.out(Easing.cubic) }),
|
||||
withTiming(1.14, { duration: 120, easing: Easing.out(Easing.cubic) }),
|
||||
withTiming(1, { duration: 140, easing: Easing.out(Easing.cubic) }, (finished) => {
|
||||
if (finished) runOnJS(next)();
|
||||
withTiming(0.8, { duration: 100 }),
|
||||
withTiming(1.2, { duration: 150 }),
|
||||
withTiming(1, { duration: 100 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(triggerNextContent)();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function playHateAnimationAndThen(next: () => void) {
|
||||
hateScale.value = withSequence(
|
||||
withTiming(0.92, { duration: 90, easing: Easing.out(Easing.cubic) }),
|
||||
withTiming(1.06, { duration: 110, easing: Easing.out(Easing.cubic) }),
|
||||
withTiming(1, { duration: 120, easing: Easing.out(Easing.cubic) }, (finished) => {
|
||||
if (finished) runOnJS(next)();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function onPressLike() {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
playLikeAnimationAndThen(async () => {
|
||||
await setReaction(item.id, 'like');
|
||||
await addFavorite(item.id);
|
||||
setIndex((i) => i + 1);
|
||||
setTimeout(() => setLikeFilled(false), 220);
|
||||
setBusy(false);
|
||||
});
|
||||
}
|
||||
|
||||
function onPressHate() {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
playHateAnimationAndThen(async () => {
|
||||
await setReaction(item.id, 'dislike');
|
||||
setIndex((i) => i + 1);
|
||||
setBusy(false);
|
||||
});
|
||||
}
|
||||
|
||||
async function onSelectTheme(next: ThemeMode) {
|
||||
setThemeModeState(next);
|
||||
await setThemeMode(next);
|
||||
@@ -144,40 +201,24 @@ export default function HomeScreen() {
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor }]}>
|
||||
<View style={styles.card}>
|
||||
<Animated.Text style={styles.text}>{item.text}</Animated.Text>
|
||||
</View>
|
||||
<View style={[styles.container, { backgroundColor }]} {...panResponder.panHandlers}>
|
||||
<Animated.View style={[styles.card, textAnimatedStyle]}>
|
||||
<Text style={styles.text}>{item.text}</Text>
|
||||
</Animated.View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Animated.View style={[styles.reactionButton, hateAnimatedStyle]}>
|
||||
<Pressable
|
||||
onPress={onPressHate}
|
||||
onPressIn={() => (hateScale.value = withTiming(0.92, { duration: 80 }))}
|
||||
onPressOut={() => (hateScale.value = withTiming(1, { duration: 120 }))}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('home.dislike')}
|
||||
hitSlop={10}
|
||||
style={styles.reactionInner}
|
||||
>
|
||||
<HateIcon width={30} height={30} />
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View style={[styles.reactionButton, likeAnimatedStyle]}>
|
||||
<Pressable
|
||||
onPress={onPressLike}
|
||||
onPressIn={() => (likeScale.value = withTiming(0.92, { duration: 80 }))}
|
||||
onPressOut={() => (likeScale.value = withTiming(1, { duration: 120 }))}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('home.like')}
|
||||
hitSlop={10}
|
||||
hitSlop={20}
|
||||
style={styles.reactionInner}
|
||||
>
|
||||
{likeFilled ? (
|
||||
<LikeFilledIcon width={30} height={30} />
|
||||
<LikeFilledIcon width={35} height={36} />
|
||||
) : (
|
||||
<LikeOutlineIcon width={30} height={30} />
|
||||
<LikeIcon width={35} height={36} />
|
||||
)}
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
@@ -220,7 +261,7 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
justifyContent: 'center',
|
||||
gap: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
headerRight: {
|
||||
flexDirection: 'row',
|
||||
@@ -236,37 +277,31 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
},
|
||||
card: {
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
backgroundColor: 'rgba(255,255,255,0.65)',
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: '#E5E7EB',
|
||||
paddingHorizontal: 30,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
text: {
|
||||
fontSize: 20,
|
||||
lineHeight: 28,
|
||||
fontSize: 22,
|
||||
lineHeight: 32,
|
||||
color: '#5E2A28',
|
||||
fontWeight: '700',
|
||||
textAlign: 'center',
|
||||
},
|
||||
actions: {
|
||||
position: 'absolute',
|
||||
bottom: SCREEN_HEIGHT * 0.16,
|
||||
left: 0,
|
||||
right: 0,
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
reactionButton: {
|
||||
width: 58,
|
||||
height: 58,
|
||||
borderRadius: 29,
|
||||
backgroundColor: 'rgba(255,255,255,0.75)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
reactionInner: {
|
||||
width: 58,
|
||||
height: 58,
|
||||
borderRadius: 29,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user