import { useWindowDimensions } from 'react-native' import { Gesture } from 'react-native-gesture-handler' import Animated, { Extrapolation, WithSpringConfig, interpolate, scrollTo, useAnimatedRef, useAnimatedScrollHandler, useAnimatedStyle, useDerivedValue, useSharedValue, withSpring, } from 'react-native-reanimated' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { AnimatedView } from 'src/common/ui/AnimatedPrimitives' import friction from 'src/utils/friction' import { Text, View } from 'tamagui' const springConfig: WithSpringConfig = { damping: 50, mass: 0.3, stiffness: 120, overshootClamping: true, restSpeedThreshold: 0.3, restDisplacementThreshold: 0.3, } interface SplitScreenProps { borderRadius: number handleHeight: number topSnapPointHeight: number bottomSnapPointHeight: number inBetweenSnapPoints: ({ height }: { height: number }) => number[] velocityThreshold?: number maxVelocityThreshold?: number } const useSplitScreen = ({ handleHeight, inBetweenSnapPoints, topSnapPointHeight, bottomSnapPointHeight, velocityThreshold = 400, maxVelocityThreshold = 2500, }: Omit) => { const { height } = useWindowDimensions() const snapPoints = [ bottomSnapPointHeight, ...inBetweenSnapPoints({ height }), height - topSnapPointHeight - handleHeight, ] const currentSnapPointIndex = useSharedValue(0) const startingHeight = useSharedValue(0) const translateY = useSharedValue(0) const bottomComponentHeight = useSharedValue(snapPoints[0]) const isDraggingHandle = useSharedValue(false) const scrollViewRef = useAnimatedRef() const scrollPosition = useSharedValue(0) /** * Progress of the bottom component between snap points from 0...N to simplify animation calculations */ const snapPointProgress = useDerivedValue(() => { const value = interpolate(bottomComponentHeight.value, snapPoints, [ ...Array(snapPoints.length).keys(), ]) return value }) const panGesture = Gesture.Pan() .minDistance(10) .onStart((e) => { isDraggingHandle.value = true startingHeight.value = bottomComponentHeight.value }) .onUpdate((e) => { translateY.value = startingHeight.value - e.translationY if (translateY.value < snapPoints[0]) { const distance = snapPoints[0] - translateY.value bottomComponentHeight.value = snapPoints[0] - friction(distance) } else if (translateY.value > snapPoints[snapPoints.length - 1]) { const distance = snapPoints[snapPoints.length - 1] - translateY.value bottomComponentHeight.value = snapPoints[snapPoints.length - 1] - friction(distance) } else { bottomComponentHeight.value = startingHeight.value - e.translationY } const closestSnapPoint = snapPoints.reduce((prev, curr) => { return Math.abs(curr - bottomComponentHeight.value) < Math.abs(prev - bottomComponentHeight.value) ? curr : prev }) currentSnapPointIndex.value = snapPoints.indexOf(closestSnapPoint) }) .onEnd((e) => { isDraggingHandle.value = false let closestSnapPoint if (Math.abs(e.velocityY) > maxVelocityThreshold) { // If velocity is high, snap to first or last snap point if (e.velocityY < 0) { // Swipe up - Move to the last snap point closestSnapPoint = snapPoints[snapPoints.length - 1] } else { // Swipe down - Move to the first snap point closestSnapPoint = snapPoints[0] } } else if (Math.abs(e.velocityY) > velocityThreshold) { // Determine direction of swipe if ( e.velocityY < 0 && currentSnapPointIndex.value < snapPoints.length - 1 ) { // Swipe up - Move to the next higher snap point closestSnapPoint = snapPoints[currentSnapPointIndex.value + 1] } else if (e.velocityY > 0 && currentSnapPointIndex.value > 0) { // Swipe down - Move to the next lower snap point closestSnapPoint = snapPoints[currentSnapPointIndex.value - 1] } else { // If velocity is high but we're at the ends, stay at the current snap point closestSnapPoint = snapPoints[currentSnapPointIndex.value] } } else { // If velocity is below the threshold, find the closest snap point closestSnapPoint = snapPoints.reduce((prev, curr) => { return Math.abs(curr - bottomComponentHeight.value) < Math.abs(prev - bottomComponentHeight.value) ? curr : prev }) } bottomComponentHeight.value = withSpring(closestSnapPoint, springConfig) currentSnapPointIndex.value = snapPoints.indexOf(closestSnapPoint) }) const topComponentStyles = useAnimatedStyle(() => { const scale = interpolate( snapPointProgress.value, [snapPoints.length - 1, snapPoints.length], [1, 0.5], { extrapolateLeft: Extrapolation.CLAMP, extrapolateRight: Extrapolation.EXTEND, } ) return { height: bottomComponentHeight.value, transform: [{ scale }], } }) const bottomComponentStyles = useAnimatedStyle(() => { const scale = interpolate(snapPointProgress.value, [0, -1], [1, 0.5], { extrapolateLeft: Extrapolation.CLAMP, extrapolateRight: Extrapolation.EXTEND, }) return { height: bottomComponentHeight.value, transform: [{ scale }], } }) const innerTopIdleComponentStyles = useAnimatedStyle(() => { const opacity = interpolate( snapPointProgress.value, [snapPoints.length - 1, snapPoints.length - 1 - 1 / 3], [1, 0], { extrapolateLeft: Extrapolation.CLAMP, extrapolateRight: Extrapolation.EXTEND, } ) return { opacity, } }) const innerBottomIdleComponentStyles = useAnimatedStyle(() => { const opacity = interpolate(snapPointProgress.value, [1 / 3, 0], [0, 1], { extrapolateLeft: Extrapolation.CLAMP, extrapolateRight: Extrapolation.EXTEND, }) return { opacity, } }) // Small test for scrollview - Maybe using a scrollview is not the best idea // Simulate ScrollView with PanGesture ? const scrollHandler = useAnimatedScrollHandler({ onEndDrag: (event) => { scrollPosition.value = event.contentOffset.y }, onMomentumEnd: (event) => { scrollPosition.value = event.contentOffset.y }, }) useDerivedValue(() => { if (!isDraggingHandle.value) return scrollTo( scrollViewRef, 0, scrollPosition.value + (translateY.value - startingHeight.value) * 0.1, false ) }) return { panGesture, topComponentStyles, bottomComponentStyles, innerTopIdleComponentStyles, innerBottomIdleComponentStyles, scrollViewRef, scrollHandler, } } export const SplitScreen = ({ borderRadius, handleHeight, inBetweenSnapPoints, topSnapPointHeight, bottomSnapPointHeight, velocityThreshold, maxVelocityThreshold, }: SplitScreenProps) => { const insets = useSafeAreaInsets() const { panGesture, topComponentStyles, bottomComponentStyles, innerTopIdleComponentStyles, innerBottomIdleComponentStyles, scrollViewRef, scrollHandler, } = useSplitScreen({ handleHeight, inBetweenSnapPoints, topSnapPointHeight, bottomSnapPointHeight, velocityThreshold, maxVelocityThreshold, }) return ( Some Text {[...Array(70).keys()].map((i) => ( Some Text ))} Idle Top Bottom component Idle Bottom ) }