diff --git a/src/components/onboarding/ShapeAnimation.tsx b/src/components/onboarding/ShapeAnimation.tsx index 141a3e56..6a5c9d55 100644 --- a/src/components/onboarding/ShapeAnimation.tsx +++ b/src/components/onboarding/ShapeAnimation.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo } from 'react'; +import React, { useEffect } from 'react'; import { useWindowDimensions, StyleSheet } from 'react-native'; import { Blur, @@ -7,12 +7,12 @@ import { Circle, Extrapolate, interpolate, + interpolateColors, LinearGradient, Path, RadialGradient, usePathValue, vec, - Group, } from '@shopify/react-native-skia'; import { Easing, @@ -20,6 +20,7 @@ import { withRepeat, withTiming, SharedValue, + useDerivedValue, } from 'react-native-reanimated'; import { @@ -31,15 +32,12 @@ import { ALL_SHAPES_Z, } from './shapes'; -// Number of shapes -const SHAPES_COUNT = ALL_SHAPES.length; - -// Color palettes for each shape (gradient: start, middle, end) -const COLOR_PALETTES = [ - ['#FFD700', '#FFA500', '#FF6B00'], // Star: Gold → Orange - ['#7C3AED', '#A855F7', '#EC4899'], // Plugin: Purple → Pink - ['#00D9FF', '#06B6D4', '#0EA5E9'], // Search: Cyan → Blue - ['#FF006E', '#F43F5E', '#FB7185'], // Heart: Pink → Rose +// Color palettes for each shape (gradient stops) +const COLOR_STOPS = [ + { start: '#FFD700', end: '#FF6B00' }, // Star: Gold → Orange + { start: '#7C3AED', end: '#EC4899' }, // Plugin: Purple → Pink + { start: '#00D9FF', end: '#0EA5E9' }, // Search: Cyan → Blue + { start: '#FF006E', end: '#FB7185' }, // Heart: Pink → Rose ]; // ============ 3D UTILITIES ============ @@ -65,144 +63,57 @@ interface ShapeAnimationProps { scrollX: SharedValue; } -// Single colored path component -const ColoredPath = ({ - morphPath, - colorIndex, - scrollX, - windowWidth, - windowHeight, -}: { - morphPath: any; - colorIndex: number; - scrollX: SharedValue; - windowWidth: number; - windowHeight: number; -}) => { - const colors = COLOR_PALETTES[colorIndex]; - - // Create opacity value using Skia's interpolate inside usePathValue pattern - const opacityPath = usePathValue((skPath) => { - 'worklet'; - // Calculate opacity based on scroll position - const shapeWidth = windowWidth; - const slideStart = colorIndex * shapeWidth; - const slideMid = slideStart; - const slideEnd = (colorIndex + 1) * shapeWidth; - const prevSlideEnd = (colorIndex - 1) * shapeWidth; - - // Opacity peaks at 1 when on this slide, fades to 0 on adjacent slides - let opacity = 0; - if (colorIndex === 0) { - // First slide: 1 at start, fade out to next - opacity = interpolate( - scrollX.value, - [0, shapeWidth], - [1, 0], - Extrapolate.CLAMP - ); - } else if (colorIndex === COLOR_PALETTES.length - 1) { - // Last slide: fade in from previous, stay at 1 - opacity = interpolate( - scrollX.value, - [prevSlideEnd, slideMid], - [0, 1], - Extrapolate.CLAMP - ); - } else { - // Middle slides: fade in from previous, fade out to next - opacity = interpolate( - scrollX.value, - [prevSlideEnd, slideMid, slideEnd], - [0, 1, 0], - Extrapolate.CLAMP - ); - } - - // Store opacity in path for use - we'll read it via a trick - // This is a workaround since we can't directly animate opacity - skPath.addCircle(-1000 - opacity * 100, -1000, 1); // Hidden marker - return skPath; - }); - - return ( - - - - - - - ); -}; - export const ShapeAnimation: React.FC = ({ scrollX }) => { const iTime = useSharedValue(0.0); const { width: windowWidth, height: windowHeight } = useWindowDimensions(); - // Create paths for each color layer with opacity baked in - const createPathForIndex = (colorIndex: number) => { - return usePathValue(skPath => { - 'worklet'; - const centerX = windowWidth / 2; - const centerY = windowHeight * 0.65; - const distance = 350; + // Pre-compute input range once + const shapeWidth = windowWidth; + const inputRange = ALL_SHAPES.map((_, idx) => shapeWidth * idx); - // Calculate opacity for this color layer - const shapeWidth = windowWidth; - const slideStart = colorIndex * shapeWidth; - const prevSlideEnd = (colorIndex - 1) * shapeWidth; - const nextSlideStart = (colorIndex + 1) * shapeWidth; + // Single optimized path - all 4 shapes batched into one Skia Path + const morphPath = usePathValue(skPath => { + 'worklet'; + const centerX = windowWidth / 2; + const centerY = windowHeight * 0.65; + const distance = 350; - let opacity = 0; - if (colorIndex === 0) { - opacity = interpolate(scrollX.value, [0, shapeWidth], [1, 0], Extrapolate.CLAMP); - } else if (colorIndex === COLOR_PALETTES.length - 1) { - opacity = interpolate(scrollX.value, [prevSlideEnd, slideStart], [0, 1], Extrapolate.CLAMP); - } else { - opacity = interpolate(scrollX.value, [prevSlideEnd, slideStart, nextSlideStart], [0, 1, 0], Extrapolate.CLAMP); - } + for (let i = 0; i < N_POINTS; i++) { + // Interpolate 3D coordinates between all shapes + const baseX = interpolate(scrollX.value, inputRange, ALL_SHAPES_X[i], Extrapolate.CLAMP); + const baseY = interpolate(scrollX.value, inputRange, ALL_SHAPES_Y[i], Extrapolate.CLAMP); + const baseZ = interpolate(scrollX.value, inputRange, ALL_SHAPES_Z[i], Extrapolate.CLAMP); - // Skip drawing if not visible - if (opacity < 0.01) return skPath; + // Apply 3D rotation + let p: Point3D = { x: baseX, y: baseY, z: baseZ }; + p = rotateX(p, 0.2); // Fixed X tilt + p = rotateY(p, iTime.value); // Animated Y rotation - // Input range for all shapes - const inputRange = new Array(ALL_SHAPES.length) - .fill(0) - .map((_, idx) => shapeWidth * idx); + // Perspective projection + const scale = distance / (distance + p.z); + const screenX = centerX + p.x * scale; + const screenY = centerY + p.y * scale; - for (let i = 0; i < N_POINTS; i++) { - const baseX = interpolate(scrollX.value, inputRange, ALL_SHAPES_X[i], Extrapolate.CLAMP); - const baseY = interpolate(scrollX.value, inputRange, ALL_SHAPES_Y[i], Extrapolate.CLAMP); - const baseZ = interpolate(scrollX.value, inputRange, ALL_SHAPES_Z[i], Extrapolate.CLAMP); + // Depth-based radius for parallax effect + const radius = Math.max(0.2, 0.5 * scale); + skPath.addCircle(screenX, screenY, radius); + } - let p: Point3D = { x: baseX, y: baseY, z: baseZ }; - p = rotateX(p, 0.2); - p = rotateY(p, iTime.value); + return skPath; + }); - const scale = distance / (distance + p.z); - const screenX = centerX + p.x * scale; - const screenY = centerY + p.y * scale; + // Interpolate gradient colors based on scroll position + const gradientColors = useDerivedValue(() => { + const startColors = COLOR_STOPS.map(c => c.start); + const endColors = COLOR_STOPS.map(c => c.end); - // Scale radius by opacity for smooth color transition - const radius = Math.max(0.1, 0.5 * scale * opacity); - skPath.addCircle(screenX, screenY, radius); - } + const start = interpolateColors(scrollX.value, inputRange, startColors); + const end = interpolateColors(scrollX.value, inputRange, endColors); - return skPath; - }); - }; + return [start, end]; + }); - // Create all 4 color layer paths - const path0 = createPathForIndex(0); - const path1 = createPathForIndex(1); - const path2 = createPathForIndex(2); - const path3 = createPathForIndex(3); - - // Rotation animation + // Rotation animation - infinite loop useEffect(() => { iTime.value = 0; iTime.value = withRepeat( @@ -224,7 +135,7 @@ export const ShapeAnimation: React.FC = ({ scrollX }) => { height: windowHeight, }, ]}> - {/* Background radial gradient blurred */} + {/* Background glow */} = ({ scrollX }) => { - {/* Layer 0: Gold (Star) */} - + {/* Single optimized path with interpolated gradient */} + - - - - {/* Layer 1: Purple (Plugin) */} - - - - - - {/* Layer 2: Cyan (Search) */} - - - - - - {/* Layer 3: Pink (Heart) */} - - diff --git a/src/screens/OnboardingScreen.tsx b/src/screens/OnboardingScreen.tsx index f807ea5a..10016c02 100644 --- a/src/screens/OnboardingScreen.tsx +++ b/src/screens/OnboardingScreen.tsx @@ -300,8 +300,8 @@ const OnboardingScreen = () => { - {/* Shape Animation Background */} - + {/* Shape Animation Background - iOS only */} + {Platform.OS === 'ios' && } {/* Header */}