Skip to content

Instantly share code, notes, and snippets.

@felippewick
Forked from smontlouis/SplitScreen.tsx
Created November 13, 2023 21:55
Show Gist options
  • Select an option

  • Save felippewick/5164a61ab120843b4b3508106d9ff63c to your computer and use it in GitHub Desktop.

Select an option

Save felippewick/5164a61ab120843b4b3508106d9ff63c to your computer and use it in GitHub Desktop.

Revisions

  1. @smontlouis smontlouis created this gist Nov 13, 2023.
    343 changes: 343 additions & 0 deletions SplitScreen.tsx
    Original file line number Diff line number Diff line change
    @@ -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>
    )
    }
    20 changes: 20 additions & 0 deletions friction.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,20 @@
    export default function friction(value: number) {
    'worklet'

    const MAX_FRICTION = 30
    const MAX_VALUE = 100

    const res = Math.max(
    1,
    Math.min(
    MAX_FRICTION,
    1 + (Math.abs(value) * (MAX_FRICTION - 1)) / MAX_VALUE
    )
    )

    if (value < 0) {
    return -res
    }

    return res
    }
    14 changes: 14 additions & 0 deletions index.tsx
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,14 @@
    import { SplitScreen } from './SplitScreen'

    export default function Page() {
    // TODO - provide top and bottom components in children
    return (
    <SplitScreen
    borderRadius={30}
    handleHeight={30}
    topSnapPointHeight={120}
    bottomSnapPointHeight={100}
    inBetweenSnapPoints={({ height }) => [height / 3, height / 1.5]}
    />
    )
    }