-
-
Save felippewick/5164a61ab120843b4b3508106d9ff63c to your computer and use it in GitHub Desktop.
Amie in-app split screen in react-native
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]} | |
/> | |
) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | |
) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment