|
|
@@ -0,0 +1,343 @@ |
|
|
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<SplitScreenProps, 'borderRadius'>) => { |
|
|
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<Animated.ScrollView>() |
|
|
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 ( |
|
|
<View flex={1} bg="black"> |
|
|
<AnimatedView |
|
|
flex={1} |
|
|
bg="white" |
|
|
borderBottomStartRadius={borderRadius} |
|
|
borderBottomEndRadius={borderRadius} |
|
|
zIndex={1} |
|
|
style={topComponentStyles} |
|
|
overflow="hidden" |
|
|
> |
|
|
<Animated.ScrollView |
|
|
ref={scrollViewRef} |
|
|
onScroll={scrollHandler} |
|
|
contentContainerStyle={{ |
|
|
paddingTop: insets.top, |
|
|
alignItems: 'center', |
|
|
justifyContent: 'center', |
|
|
gap: 10, |
|
|
}} |
|
|
> |
|
|
<Text>Some Text</Text> |
|
|
{[...Array(70).keys()].map((i) => ( |
|
|
<Text key={i}>Some Text</Text> |
|
|
))} |
|
|
</Animated.ScrollView> |
|
|
<AnimatedView |
|
|
height={topSnapPointHeight} |
|
|
alignItems="center" |
|
|
justifyContent="center" |
|
|
pos="absolute" |
|
|
bottom={0} |
|
|
left={0} |
|
|
right={0} |
|
|
bg="white" |
|
|
paddingTop={insets.top} |
|
|
style={innerTopIdleComponentStyles} |
|
|
> |
|
|
<Text>Idle Top</Text> |
|
|
</AnimatedView> |
|
|
</AnimatedView> |
|
|
<AnimatedView |
|
|
zIndex={2} |
|
|
h={handleHeight} |
|
|
bg="black" |
|
|
alignItems="center" |
|
|
justifyContent="center" |
|
|
> |
|
|
<View height={7} width={60} bg="white" borderRadius={10} opacity={0.8} /> |
|
|
<GestureDetector gesture={panGesture}> |
|
|
<AnimatedView |
|
|
pos="absolute" |
|
|
top={-15} |
|
|
bottom={-15} |
|
|
right={0} |
|
|
left={0} |
|
|
/> |
|
|
</GestureDetector> |
|
|
</AnimatedView> |
|
|
<AnimatedView |
|
|
bg="white" |
|
|
borderTopStartRadius={borderRadius} |
|
|
borderTopEndRadius={borderRadius} |
|
|
pos="relative" |
|
|
zIndex={1} |
|
|
overflow="hidden" |
|
|
style={bottomComponentStyles} |
|
|
> |
|
|
<AnimatedView height={500} alignItems="center" justifyContent="center"> |
|
|
<Text>Bottom component</Text> |
|
|
</AnimatedView> |
|
|
<AnimatedView |
|
|
height={bottomSnapPointHeight} |
|
|
alignItems="center" |
|
|
justifyContent="center" |
|
|
pos="absolute" |
|
|
top={0} |
|
|
left={0} |
|
|
right={0} |
|
|
bg="white" |
|
|
paddingBottom={insets.bottom} |
|
|
style={innerBottomIdleComponentStyles} |
|
|
> |
|
|
<Text>Idle Bottom</Text> |
|
|
</AnimatedView> |
|
|
</AnimatedView> |
|
|
</View> |
|
|
) |
|
|
} |