Files
mindfulness/client/components/onboarding/OnboardingLayout.tsx
2026-03-03 19:55:53 +08:00

206 lines
5.9 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, { useRef, useEffect } from 'react';
import { View, StyleSheet, SafeAreaView, TouchableOpacity, StatusBar, Text, Image, Platform, Animated, Easing, useWindowDimensions } from 'react-native';
import { useTranslation } from 'react-i18next';
import { OnboardingColors, OnboardingFont } from '@/constants/OnboardingTheme';
const TRANSITION_OFFSET = 24;
const TRANSITION_DURATION = 280;
interface OnboardingLayoutProps {
children: React.ReactNode;
title?: string;
currentStep: number;
totalSteps: number;
onSkip: () => void;
onBack?: () => void;
showBackButton?: boolean;
/** 用户名字仅在名字步骤之后的第一个问题currentStep === 1且非空时显示招呼语 */
userName?: string;
}
export function OnboardingLayout({
children,
title,
currentStep,
totalSteps,
onSkip,
onBack,
showBackButton = false,
userName = '',
}: OnboardingLayoutProps) {
const { t } = useTranslation();
const { width, height } = useWindowDimensions();
const isTablet = Platform.OS === 'ios' && Math.min(width, height) >= 768;
const contentMaxWidth = isTablet ? 620 : undefined;
const showGreeting = currentStep === 1 && userName.trim().length > 0;
const displayName = userName.trim();
const prevStepRef = useRef(currentStep);
const isFirstRenderRef = useRef(true);
const translateX = useRef(new Animated.Value(0)).current;
const opacity = useRef(new Animated.Value(1)).current;
useEffect(() => {
if (isFirstRenderRef.current) {
isFirstRenderRef.current = false;
prevStepRef.current = currentStep;
return;
}
if (prevStepRef.current === currentStep) return;
const direction = currentStep > prevStepRef.current ? 'forward' : 'back';
prevStepRef.current = currentStep;
const startX = direction === 'forward' ? TRANSITION_OFFSET : -TRANSITION_OFFSET;
translateX.setValue(startX);
opacity.setValue(0.72);
Animated.parallel([
Animated.timing(translateX, {
toValue: 0,
duration: TRANSITION_DURATION,
useNativeDriver: true,
easing: Easing.out(Easing.cubic),
}),
Animated.timing(opacity, {
toValue: 1,
duration: TRANSITION_DURATION,
useNativeDriver: true,
easing: Easing.out(Easing.cubic),
}),
]).start();
}, [currentStep, translateX, opacity]);
return (
<View style={styles.container}>
<StatusBar barStyle="dark-content" />
<SafeAreaView style={styles.safeArea}>
{/* Header: Back & Skip */}
<View style={[styles.header, contentMaxWidth ? { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' } : null]}>
<View style={styles.headerLeft}>
{showBackButton && onBack && (
<TouchableOpacity onPress={onBack} style={styles.iconButton}>
<Image
source={require('@/assets/images/icon/back_icon.png')}
style={styles.backIcon}
/>
</TouchableOpacity>
)}
</View>
<TouchableOpacity onPress={onSkip} style={styles.skipButton}>
<Text style={styles.skipText}>{t('onboarding.skipAll')}</Text>
<Image
source={require('@/assets/images/icon/skip_icon.png')}
style={styles.skipIcon}
/>
</TouchableOpacity>
</View>
{/* Title & Progress Row名字步骤后第一步且名字非空时显示招呼语 + 问题) */}
<View style={[styles.titleRow, contentMaxWidth ? { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' } : null]}>
<View style={styles.titleBlock}>
{showGreeting && (
<Text style={styles.greetingText}>{t('onboardingSurvey.greeting', { name: displayName })}</Text>
)}
<Text style={styles.questionTitle}>{title}</Text>
</View>
<Text style={styles.progressText}>({currentStep}/{totalSteps})</Text>
</View>
{/* Contentstep 切换时滑动 + 淡入 */}
<Animated.View
style={[
styles.content,
contentMaxWidth ? { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' } : null,
{
opacity,
transform: [{ translateX }],
},
]}
>
{children}
</Animated.View>
</SafeAreaView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: OnboardingColors.background,
},
safeArea: {
flex: 1,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 20,
height: 44,
},
headerLeft: {
width: 44,
height: 44,
justifyContent: 'center',
},
iconButton: {
padding: 8,
},
backIcon: {
width: 19,
height: 19,
resizeMode: 'contain',
},
skipButton: {
flexDirection: 'row',
alignItems: 'center',
padding: 8,
},
skipText: {
fontSize: 13,
color: OnboardingColors.textMuted,
fontFamily: Platform.OS === 'ios' ? 'STIX Two Text' : 'serif',
marginRight: 4,
},
skipIcon: {
width: 10,
height: 4,
resizeMode: 'contain',
},
titleRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
paddingHorizontal: 20,
marginTop: 20,
marginBottom: 8,
},
titleBlock: {
flex: 1,
justifyContent: 'flex-end',
},
greetingText: {
fontSize: 22,
color: OnboardingColors.questionTitle,
fontFamily: OnboardingFont.question,
marginBottom: 4,
},
questionTitle: {
fontSize: 22,
color: OnboardingColors.questionTitle,
fontFamily: OnboardingFont.question,
},
progressText: {
fontSize: 18,
color: OnboardingColors.textProgress,
fontFamily: OnboardingFont.question,
marginLeft: 10,
},
content: {
flex: 1,
paddingHorizontal: 20,
},
});