Created
November 22, 2024 19:24
-
-
Save Glazzes/de1f0b260a2fe043ecf76641f2824aa4 to your computer and use it in GitHub Desktop.
React Native Teardrop Slider
This file contains hidden or 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 React from 'react'; | |
import { StyleSheet, View, type ViewStyle } from 'react-native'; | |
import Animated, { | |
Extrapolation, | |
clamp, | |
interpolate, | |
useAnimatedReaction, | |
useAnimatedStyle, | |
useDerivedValue, | |
useSharedValue, | |
withTiming, | |
} from 'react-native-reanimated'; | |
import { Gesture, GestureDetector } from 'react-native-gesture-handler'; | |
import { Canvas, Path, Skia, rect } from '@shopify/react-native-skia'; | |
type ConicSliderProps = { | |
upperRadius: number; | |
lowerRadius: number; | |
distance: number; | |
color: string; | |
onUpdate?: (value: number) => void; | |
}; | |
const RAG2DEG = 180 / Math.PI; | |
/** | |
* @component | |
* @property props.upperRadius Radius of the upper circle | |
* @property props.lowerRadius Radius of the lower circle | |
* @property props.distance Distance between the circunference of both circles | |
* @property props.onUpdate Worklet function fired as the slider moves, receives a value between 0 and 1 | |
* 0 at the bottom and 1 at the top | |
*/ | |
const ConicSlider: React.FC<ConicSliderProps> = ({ | |
upperRadius, | |
lowerRadius, | |
distance, | |
color, | |
onUpdate, | |
}) => { | |
const canvasHeight = upperRadius * 2 + lowerRadius * 2 + distance; | |
const upperBound = -1 * (canvasHeight / 2 - upperRadius); | |
const lowerBound = canvasHeight / 2 - lowerRadius; | |
const progress = useSharedValue<number>(0); | |
const radius = useSharedValue<number>(lowerRadius); | |
const size = useSharedValue<number>(lowerRadius); | |
const translate = useSharedValue<number>(0); | |
const offset = useSharedValue<number>(0); | |
useAnimatedReaction( | |
() => translate.value, | |
(value) => { | |
const output = interpolate( | |
value, | |
[upperBound, lowerBound], | |
[1, 0], | |
Extrapolation.CLAMP | |
); | |
onUpdate?.(output); | |
}, | |
[translate] | |
); | |
const path = useDerivedValue(() => { | |
const skPath = Skia.Path.Make(); | |
// Common tangent of two circles calculations | |
const cRadius = radius.value; | |
const hipotenuse = canvasHeight - lowerRadius - cRadius; | |
const adjacent = cRadius - lowerRadius; | |
const angle = Math.acos(adjacent / hipotenuse); | |
const remainingAngle = Math.PI / 2 - angle; | |
// Upper section | |
const upperCenterX = upperRadius; | |
const upperCenterY = radius.value; | |
const upperLeftAngle = Math.PI + remainingAngle; | |
const upperLX = upperCenterX + cRadius * Math.cos(upperLeftAngle); | |
const upperLY = upperCenterY + -1 * (cRadius * Math.sin(upperLeftAngle)); | |
const upperRightAngle = -1 * remainingAngle; | |
const upperRX = upperCenterX + cRadius * Math.cos(upperRightAngle); | |
const upperRY = upperCenterY + -1 * (cRadius * Math.sin(upperRightAngle)); | |
// Lower section | |
const bottomCenterX = upperRadius; | |
const bottomCenterY = upperRadius * 2 + lowerRadius + distance; | |
const bottomLeftAngle = Math.PI + remainingAngle; | |
const bottomLX = bottomCenterX + lowerRadius * Math.cos(bottomLeftAngle); | |
const bottomLY = | |
bottomCenterY + -1 * (lowerRadius * Math.sin(bottomLeftAngle)); | |
const bottomRightAngle = -1 * remainingAngle; | |
const bottomRX = bottomCenterX + lowerRadius * Math.cos(bottomRightAngle); | |
const bottomRY = | |
bottomCenterY + -1 * (lowerRadius * Math.sin(bottomRightAngle)); | |
const x = upperRadius - cRadius; | |
const start = (Math.PI - remainingAngle) * RAG2DEG; | |
const end = (Math.PI + remainingAngle * 2) * RAG2DEG; | |
skPath.addArc(rect(x, 0, cRadius * 2, cRadius * 2), start, end); | |
skPath.moveTo(upperRX, upperRY); | |
skPath.lineTo(upperLX, upperLY); | |
skPath.lineTo(bottomLX, bottomLY); | |
skPath.lineTo(bottomRX, bottomRY); | |
skPath.lineTo(upperRX, upperRY); | |
const start2 = remainingAngle * RAG2DEG; | |
const end2 = (Math.PI - remainingAngle * 2) * RAG2DEG; | |
skPath.addArc( | |
rect( | |
upperRadius - lowerRadius, | |
upperRadius * 2 + distance, | |
lowerRadius * 2, | |
lowerRadius * 2 | |
), | |
start2, | |
end2 | |
); | |
return skPath; | |
}, [lowerRadius]); | |
const panGesture = Gesture.Pan() | |
.onStart(() => { | |
progress.value = withTiming(1); | |
radius.value = withTiming(upperRadius); | |
offset.value = translate.value; | |
}) | |
.onUpdate((e) => { | |
const to = offset.value + e.translationY; | |
const finalSize = interpolate( | |
to, | |
[upperBound, lowerBound], | |
[upperRadius, lowerRadius], | |
Extrapolation.CLAMP | |
); | |
translate.value = clamp(to, upperBound, lowerBound); | |
size.value = lowerRadius + (finalSize - lowerRadius) * progress.value; | |
}) | |
.onEnd(() => { | |
radius.value = withTiming(lowerRadius); | |
progress.value = withTiming(0); | |
size.value = withTiming(lowerRadius); | |
}); | |
const canvasStyle: ViewStyle = { | |
width: upperRadius * 2, | |
height: canvasHeight, | |
}; | |
const indicatorStyles = useAnimatedStyle(() => { | |
return { | |
width: size.value * 2, | |
height: size.value * 2, | |
borderRadius: size.value, | |
backgroundColor: 'white', | |
position: 'absolute', | |
transform: [{ translateY: translate.value }], | |
}; | |
}, [size, translate]); | |
return ( | |
<View style={styles.center}> | |
<Canvas style={canvasStyle} pointerEvents="none"> | |
<Path path={path} color={color} strokeWidth={1} style={'fill'} /> | |
</Canvas> | |
<GestureDetector gesture={panGesture}> | |
<Animated.View style={indicatorStyles} /> | |
</GestureDetector> | |
</View> | |
); | |
}; | |
const App = () => { | |
const onUpdate = (value: number) => { | |
'worklet'; | |
console.log(value); | |
}; | |
return ( | |
<View style={[styles.root, styles.center]}> | |
<ConicSlider | |
upperRadius={75} | |
lowerRadius={15} | |
distance={200} | |
onUpdate={onUpdate} | |
color={'rgba(255, 255, 255, 0.5)'} | |
/> | |
</View> | |
); | |
}; | |
const styles = StyleSheet.create({ | |
root: { | |
flex: 1, | |
backgroundColor: '#000', | |
}, | |
center: { | |
justifyContent: 'center', | |
alignItems: 'center', | |
}, | |
}); | |
export default App; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment