reanimated-color-picker
Version:
A Pure JavaScript Color Picker for React Native
370 lines (361 loc) • 11.6 kB
JavaScript
import React from 'react';
import { Image, ImageBackground, StyleSheet } from 'react-native';
import Animated, { useAnimatedStyle, useDerivedValue, useSharedValue, withTiming } from 'react-native-reanimated';
import colorKit from '../../../colorKit/index';
import usePickerContext from '../../../AppContext';
import { PanelCore } from '../PanelCore';
import { styles } from '../../../styles';
import Thumb from '../../Thumb/Thumb';
import { clamp, ConditionalRendering } from '../../../utils';
import { Panel3ContextProvider } from './Panel3Context';
/** @see [Panel3](https://alabsi91.github.io/reanimated-color-picker/api/panels/panel3/) */
export function Panel3({
renderCenterLine = false,
centerChannel = 'saturation',
gestures = [],
style = {},
rotate = 0,
children,
...props
}) {
const { hueValue, saturationValue, brightnessValue, onGestureChange, onGestureEnd, ...ctx } = usePickerContext();
const thumbShape = props.thumbShape ?? ctx.thumbShape;
const thumbSize = props.thumbSize ?? ctx.thumbSize;
const thumbColor = props.thumbColor ?? ctx.thumbColor;
const boundedThumb = props.boundedThumb ?? ctx.boundedThumb;
const renderThumb = props.renderThumb ?? ctx.renderThumb;
const thumbStyle = props.thumbStyle ?? ctx.thumbStyle ?? {};
const thumbScaleAnimationValue = props.thumbScaleUpValue ?? ctx.thumbScaleAnimationValue;
const thumbScaleAnimationDuration = props.thumbScaleUpDuration ?? ctx.thumbScaleAnimationDuration;
const thumbInnerStyle = props.thumbInnerStyle ?? ctx.thumbInnerStyle ?? {};
const adaptSpectrum = props.adaptSpectrum ?? ctx.adaptSpectrum;
const isGestureActive = useSharedValue(false);
const width = useSharedValue(0);
const handleScale = useSharedValue(1);
const lastHslSaturationValue = useSharedValue(0);
const borderRadius = 2000;
// We need to keep track of the HSL saturation value because, when the luminance is 0 or 100,
// converting to/from HSV causes the previous saturation value to be lost.
const hsl = useDerivedValue(() => {
const hsvColor = {
h: hueValue.value,
s: saturationValue.value,
v: brightnessValue.value,
};
const { h, s, l } = colorKit.runOnUI().HSL(hsvColor).object(false);
if (l === 100 || l === 0) {
return {
h,
s: lastHslSaturationValue.value,
l,
};
}
lastHslSaturationValue.value = s;
return {
h,
s,
l,
};
}, [hueValue, saturationValue, brightnessValue]);
const centerChannelValue = useDerivedValue(() => {
if (centerChannel === 'brightness') {
return brightnessValue.value;
}
if (centerChannel === 'hsl-saturation') {
return hsl.value.s;
}
return saturationValue.value;
}, [brightnessValue, saturationValue, hsl]);
const thumbAnimatedStyle = useAnimatedStyle(() => {
const center = width.value / 2 - (boundedThumb ? thumbSize / 2 : 0);
const rotatedHue = (hueValue.value - rotate) % 360;
const distance = (centerChannelValue.value / 100) * (width.value / 2 - (boundedThumb ? thumbSize / 2 : 0));
const angle = (rotatedHue * Math.PI) / 180;
const posY = width.value - (Math.sin(angle) * distance + center) - (boundedThumb ? thumbSize : thumbSize / 2);
const posX = width.value - (Math.cos(angle) * distance + center) - (boundedThumb ? thumbSize : thumbSize / 2);
return {
transform: [
{
translateX: posX,
},
{
translateY: posY,
},
{
scale: handleScale.value,
},
{
rotate: rotatedHue + 90 + 'deg',
},
],
};
}, [width, centerChannelValue, hueValue, handleScale, boundedThumb, thumbSize, rotate]);
const spectrumStyle = useAnimatedStyle(() => {
if (!adaptSpectrum) {
return {};
}
if (centerChannel === 'brightness') {
return {
backgroundColor: `rgba(255, 255, 255, ${1 - saturationValue.value / 100})`,
};
}
if (centerChannel === 'hsl-saturation') {
if (hsl.value.l < 50) {
return {
backgroundColor: `rgba(0, 0, 0, ${1 - hsl.value.l / 50})`,
};
}
return {
backgroundColor: `rgba(255, 255, 255, ${(hsl.value.l - 50) / 50})`,
};
}
return {
backgroundColor: `rgba(0, 0, 0, ${1 - brightnessValue.value / 100})`,
};
}, [saturationValue, brightnessValue, hsl, adaptSpectrum, centerChannel]);
const centerLineStyle = useAnimatedStyle(() => {
if (!renderCenterLine) {
return {};
}
const lineThickness = 1;
const center = width.value / 2 - (boundedThumb ? thumbSize / 2 : 0);
const rotatedHue = (hueValue.value - rotate) % 360;
const distance = (centerChannelValue.value / 100) * center;
const angle = (rotatedHue + 180) % 360; // reversed angle
return {
top: (width.value - lineThickness) / 2,
left: (width.value - distance) / 2,
height: lineThickness,
width: distance,
transform: [
{
rotate: angle + 'deg',
},
{
translateX: distance / 2,
},
],
};
}, [width, hueValue, centerChannelValue, renderCenterLine, boundedThumb, thumbSize, rotate]);
const onBegin = ({ x, y }) => {
'worklet';
const radius = width.value / 2;
const dx = radius - x;
const dy = radius - y;
const pressDistance = Math.sqrt(dx * dx + dy * dy);
// Check if the press is outside the circle
if (pressDistance > radius) {
isGestureActive.value = false;
return;
}
isGestureActive.value = true;
handleScale.value = withTiming(thumbScaleAnimationValue, {
duration: thumbScaleAnimationDuration,
});
};
const onUpdate = (newXValue, newYValue) => {
'worklet';
if (hueValue.value === newXValue && centerChannelValue.value === newYValue) {
return;
}
hueValue.value = newXValue;
if (centerChannel === 'hsl-saturation') {
// To prevent locking this slider when the luminance is 0 or 100,
// this should not affect the resulting color, as the value will be rounded.
const l = hsl.value.l === 0 ? 0.01 : hsl.value.l === 100 ? 99.99 : hsl.value.l;
const { s, v } = colorKit
.runOnUI()
.HSV({
h: hsl.value.h,
s: newYValue,
l,
})
.object(false);
saturationValue.value = s;
brightnessValue.value = v;
}
// Vertical channel is brightness
else if (centerChannel === 'brightness') {
brightnessValue.value = newYValue;
}
// Vertical channel is saturation
else {
saturationValue.value = newYValue;
}
onGestureChange();
};
const onGestureUpdate = ({ x, y }) => {
'worklet';
if (!isGestureActive.value) return;
const center = (width.value - (boundedThumb ? thumbSize : 0)) / 2;
const dx = center - x + (boundedThumb ? thumbSize / 2 : 0);
const dy = center - y + (boundedThumb ? thumbSize / 2 : 0);
const radius = clamp(Math.sqrt(dx * dx + dy * dy), center); // distance from center
const theta = Math.atan2(dy, dx) * (180 / Math.PI); // [0 - 180] range
const angle = theta < 0 ? 360 + theta : theta; // [0 - 360] range
const radiusPercent = radius / center;
const newHueValue = (angle + rotate) % 360;
const newChannelValue = radiusPercent * 100;
onUpdate(newHueValue, newChannelValue);
};
const onEnd = () => {
'worklet';
isGestureActive.value = false;
handleScale.value = withTiming(1, {
duration: thumbScaleAnimationDuration,
});
onGestureEnd();
};
const getAdaptiveColor = hsva => {
'worklet';
if (adaptSpectrum) {
return hsva;
}
switch (centerChannel) {
case 'saturation':
return {
h: hsva.h,
s: hsva.s,
v: 100,
};
case 'brightness':
return {
h: hsva.h,
s: 100,
v: hsva.v,
};
case 'hsl-saturation': {
const { h, s } = hsl.value;
return {
h,
s,
l: 50,
};
}
default:
return hsva;
}
};
return /*#__PURE__*/ React.createElement(
Panel3ContextProvider,
{
value: {
width,
adaptSpectrum,
centerChannel,
centerChannelValue,
thumbShape,
thumbColor,
thumbStyle,
thumbInnerStyle,
renderThumb,
boundedThumb,
renderCenterLine,
thumbSize,
rotate,
},
},
/*#__PURE__*/ React.createElement(
PanelCore,
{
style: [
styles.panelContainer,
style,
{
position: 'relative',
aspectRatio: 1,
borderWidth: 0,
padding: 0,
borderRadius,
},
],
label: props.accessibilityLabel ?? `Hue and ${centerChannel} wheel slider`,
hint: props.accessibilityHint ?? `Double tap to switch between hue and ${centerChannel}`,
labelX: 'Hue',
currentXValue: hueValue,
maxXValue: 360,
labelY: centerChannel,
currentYValue: centerChannelValue,
width: width,
gestures: gestures,
onGestureUpdate: onGestureUpdate,
onBegin: onBegin,
onUpdate: onUpdate,
onEnd: onEnd,
},
/*#__PURE__*/ React.createElement(
ImageBackground,
{
source: require('../../../assets/circularHue.png'),
style: styles.panelImage,
imageStyle: {
transform: [
{
rotate: -rotate + 'deg',
},
],
},
resizeMode: 'stretch',
'aria-hidden': true,
},
/*#__PURE__*/ React.createElement(
ConditionalRendering,
{
if: adaptSpectrum && centerChannel === 'brightness',
},
/*#__PURE__*/ React.createElement(Animated.View, {
style: [
{
borderRadius,
},
spectrumStyle,
StyleSheet.absoluteFill,
],
}),
),
/*#__PURE__*/ React.createElement(Image, {
source: require('../../../assets/blackRadial.png'),
style: styles.panelImage,
tintColor: centerChannel === 'saturation' ? '#fff' : centerChannel === 'hsl-saturation' ? '#888' : undefined,
resizeMode: 'stretch',
}),
/*#__PURE__*/ React.createElement(
ConditionalRendering,
{
if: adaptSpectrum && (centerChannel === 'saturation' || centerChannel === 'hsl-saturation'),
},
/*#__PURE__*/ React.createElement(Animated.View, {
style: [
{
borderRadius,
},
spectrumStyle,
StyleSheet.absoluteFill,
],
}),
),
),
/*#__PURE__*/ React.createElement(
ConditionalRendering,
{
if: renderCenterLine,
},
/*#__PURE__*/ React.createElement(Animated.View, {
style: [styles.panel3Line, centerLineStyle],
'aria-hidden': true,
}),
),
children,
/*#__PURE__*/ React.createElement(Thumb, {
thumbShape: thumbShape,
thumbSize: thumbSize,
thumbColor: thumbColor,
renderThumb: renderThumb,
innerStyle: thumbInnerStyle,
thumbAnimatedStyle: thumbAnimatedStyle,
style: thumbStyle,
getAdaptiveColor: getAdaptiveColor,
}),
),
);
}