Skip to content

Instantly share code, notes, and snippets.

@smontlouis
Created November 13, 2023 06:53
Show Gist options
  • Save smontlouis/a49da8c5e2f38dbdb64e7016ec319afb to your computer and use it in GitHub Desktop.
Save smontlouis/a49da8c5e2f38dbdb64e7016ec319afb to your computer and use it in GitHub Desktop.
Amie in-app split screen in react-native
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
}
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]}
/>
)
}
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>
)
}
@fukemy
Copy link

fukemy commented Jan 15, 2024

Hi, can u share about this import:

import { AnimatedView } from 'src/common/ui/AnimatedPrimitives'
import { Text, View } from 'tamagui'

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment