Skip to content

Instantly share code, notes, and snippets.

@Glazzes
Created November 22, 2024 19:24
Show Gist options
  • Save Glazzes/de1f0b260a2fe043ecf76641f2824aa4 to your computer and use it in GitHub Desktop.
Save Glazzes/de1f0b260a2fe043ecf76641f2824aa4 to your computer and use it in GitHub Desktop.
React Native Teardrop Slider
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