I am trying to implement a multi-color color wheel, which lets users drag multiple pickers to change their colors.
The issue here is that, when the user starts dragging one of the pickers and keeps dragging to the edge of the wheel, the dragging gets canceled as soon as the picker hits the edge.
The needed implementation is to keep the dragging going when outside the wheel, but let the picker follow the edge of the wheel until the user lifts the thumb.
I already implemented the outBounds method to detect if the gesture is out of the wheel, but every attempt I did, trying to set the picker to follow the edge using Math.cos and Math.sin has failed.
Any help will be appreciated. Thanks.
Code:
import React, { Component } from 'react';
import { Animated, Image, Dimensions, PanResponder, StyleSheet, View, Text } from 'react-native';
import colorsys from 'colorsys';
import wheelPng from './color_wheel.png';
import pickerPng from './picker.png';
import colors from '../../../common/colors';
import { isSmallerDevice } from '../../../helpers/layoutFunctions';
class ColorWheel extends Component {
static defaultProps = {
thumbSize: 40,
initialColor: '#ffffff',
onColorChange: () => { },
}
constructor(props) {
super(props)
this.state = {
offset: { x: 0, y: 0 },
currentColor: props.initialColor,
colors: props.colors,
pans: props.colors.map(color => new Animated.ValueXY()),
activeIndex: null,
radius: 0,
renew: false,
spring: new Animated.Value(1)
}
}
static getDerivedStateFromProps(nextProps, prevState) {
let update = { ...prevState };
if (nextProps.colors && nextProps.colors.length && nextProps.colors !== prevState.colors) {
if (nextProps.colors.length > prevState.colors.length) {
update.colors = nextProps.colors;
update.pans = [...prevState.pans, new Animated.ValueXY()];
update.renew = true;
}
}
return update;
}
componentDidUpdate(prevProps, prevState) {
if (this.state.renew) {
this.renewResponders();
this.props.colors.forEach((col, index) => {
this.forceUpdate(col);
});
}
}
componentDidMount = () => {
this.renewResponders();
}
renewResponders = () => {
const { colors } = this.props;
this._panResponders = colors.map((color, index) => this.createResponder(color, index));
this.setState({ renew: false });
}
createResponder = (color, index) => {
const responder = PanResponder.create({
onPanResponderTerminationRequest: () => false,
onStartShouldSetPanResponderCapture: ({ nativeEvent }) => {
this.state.spring.setValue(1.3);
const { onSwiperDisabled } = this.props;
onSwiperDisabled && onSwiperDisabled();
if (this.outBounds(nativeEvent)) return
this.updateColor({ index, nativeEvent })
this.setState({ panHandlerReady: true })
this.state.pans[index].setValue({
x: -this.state.left + nativeEvent.pageX - this.props.thumbSize / 2,
y: -this.state.top + nativeEvent.pageY - this.props.thumbSize / 2 - 40,
})
return true
},
onStartShouldSetPanResponder: (e, gestureState) => true,
onMoveShouldSetPanResponderCapture: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderGrant: () => true,
onPanResponderMove: (event, gestureState) => {
this.setState({ activeIndex: index });
if (this.outBounds(gestureState)) return
this.resetPanHandler(index)
return Animated.event(
[
null,
{
dx: this.state.pans[index].x,
dy: this.state.pans[index].y,
},
],
{ listener: (ev) => this.updateColor({ nativeEvent: ev.nativeEvent, index }), useNativeDriver: false },
)(event, gestureState)
},
onPanResponderRelease: ({ nativeEvent }) => {
const { onSwiperEnabled } = this.props;
onSwiperEnabled && onSwiperEnabled();
this.state.pans[index].flattenOffset()
const { radius } = this.calcPolar(nativeEvent)
if (radius < 0.1) {
this.forceUpdate('#ffffff', index)
}
Animated.spring(this.state.spring, {
toValue: 1,
stiffness: 400,
damping: 10,
useNativeDriver: false,
}).start(() => {
this.setState({ panHandlerReady: true, activeIndex: null })
});
if (this.props.onColorChangeComplete) {
this.props.onColorChangeComplete({ index, color: this.state.hsv });
}
},
})
return { color, responder };
}
onLayout() {
setTimeout(() => {
this.self && this.measureOffset()
}, 200);
}
measureOffset() {
/*
* const {x, y, width, height} = nativeEvent.layout
* onlayout values are different than measureInWindow
* x and y are the distances to its previous element
* but in measureInWindow they are relative to the window
*/
this.self.measureInWindow((x, y, width, height) => {
const window = Dimensions.get('window')
const absX = x % width
const radius = Math.min(width, height) / 2
const offset = {
x: absX + width / 2,
y: y % window.height + height / 2,
}
this.setState({
offset,
radius,
height,
width,
top: y % window.height,
left: absX,
});
//
this.forceUpdate(this.state.currentColor)
});
}
calcPolar(gestureState) {
const {
pageX, pageY, moveX, moveY,
} = gestureState
const [x, y] = [pageX || moveX, pageY || moveY]
const [dx, dy] = [x - this.state.offset.x, y - this.state.offset.y]
return {
deg: Math.atan2(dy, dx) * (-180 / Math.PI),
// pitagoras r^2 = x^2 + y^2 normalized
radius: Math.sqrt(dy * dy + dx * dx) / this.state.radius,
}
}
outBounds(gestureState) {
const { radius } = this.calcPolar(gestureState);
return radius > 1
}
resetPanHandler(index) {
if (!this.state.panHandlerReady) {
return
}
this.setState({ panHandlerReady: false })
this.state.pans[index].setOffset({
x: this.state.pans[index].x._value,
y: this.state.pans[index].y._value,
})
this.state.pans[index].setValue({ x: 0, y: 0 })
}
calcCartesian(deg, radius) {
const r = radius * this.state.radius; // was normalized
const rad = Math.PI * deg / 180;
const x = r * Math.cos(rad);
const y = r * Math.sin(rad);
return {
left: this.state.width / 2 + x,
top: this.state.height / 2 - y,
}
}
updateColor = ({ nativeEvent, index }) => {
const { deg, radius } = this.calcPolar(nativeEvent);
const hsv = { h: deg, s: 100 * radius, v: 100 };
this.setState({ hsv });
this.props.onColorChange({ index, color: hsv });
}
forceUpdate = (color, index) => {
const { h, s, v } = colorsys.hex2Hsv(color);
const { left, top } = this.calcCartesian(h, s / 100);
this.props.onColorChange({ color: { h, s, v }, index });
if (index)
this.state.pans[index].setValue({
x: left - this.props.thumbSize / 2,
y: top - this.props.thumbSize / 2,
});
else
this.props.colors.forEach((col, index) => {
this.animatedUpdate(col, index);
});
}
animatedUpdate = (color, index) => {
const { h, s, v } = colorsys.hex2Hsv(color);
const { left, top } = this.calcCartesian(h, s / 100)
// this.setState({ currentColor: color })
// this.props.onColorChange({ h, s, v })
Animated.spring(this.state.pans[index], {
toValue: {
x: left - this.props.thumbSize / 2,
y: top - this.props.thumbSize / 2 - 40,
},
useNativeDriver: false
}).start()
}
render() {
const { radius, activeIndex } = this.state
const thumbStyle = [
styles.circle,
this.props.thumbStyle,
{
position: 'absolute',
width: this.props.thumbSize,
height: this.props.thumbSize,
borderRadius: this.props.thumbSize / 2,
// backgroundColor: this.state.currentColor,
opacity: this.state.offset.x === 0 ? 0 : 1,
flexDirection: 'row',
alignItems: 'center',
alignContent: 'center',
justifyContent: 'center',
},
]
const { colors } = this.props;
// const panHandlers = this._panResponder && this._panResponder.panHandlers || {}
return (
<View
ref={node => {
this.self = node
}}
onLayout={nativeEvent => this.onLayout(nativeEvent)}
style={[styles.coverResponder, this.props.style]}>
{!!radius && <Image
style={[styles.img,
{
height: radius * 2,
width: radius * 2
}]}
source={wheelPng}
/>}
{colors && colors.map((color, index) =>
<Animated.View key={index} style={[this.state.pans[index].getLayout(), thumbStyle, { zIndex: activeIndex === index ? 9 : 3, transform: [{ scale: activeIndex === index ? this.state.spring : 1 }] }]} {...this._panResponders && this._panResponders[index] && this._panResponders[index].responder.panHandlers}>
<Animated.Image
style={[
{
height: this.props.thumbSize * 2,
width: this.props.thumbSize * 2,
resizeMode: 'contain',
position: 'absolute',
tintColor: '#000000'
}]}
source={pickerPng}
/>
<Animated.View style={[styles.circle, {
position: 'absolute',
top: -8,
left: 2,
width: this.props.thumbSize,
height: this.props.thumbSize,
borderRadius: this.props.thumbSize / 2,
backgroundColor: color,
opacity: this.state.offset.x === 0 ? 0 : 1,
flexDirection: 'row',
alignItems: 'center',
alignContent: 'center',
justifyContent: 'center'
}]} >
<Text style={isSmallerDevice ? styles.smallerDeviceCountText : styles.countText}>{index + 1}</Text>
</Animated.View>
</Animated.View>
)}
</View>
)
}
}
const styles = StyleSheet.create({
coverResponder: {
flex: 1,
alignItems: 'center',
justifyContent: 'center'
},
img: {
alignSelf: 'center',
},
circle: {
position: 'absolute',
backgroundColor: '#000000',
// borderWidth: 3,
// borderColor: '#EEEEEE',
elevation: 3,
shadowColor: 'rgb(46, 48, 58)',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.8,
shadowRadius: 2,
},
countText: {
flex: 1,
textAlign: 'center',
fontFamily: 'Rubik-Bold',
fontSize: 20,
color: colors.titleMain
},
smallerDeviceCountText: {
flex: 1,
textAlign: 'center',
fontFamily: 'Rubik-Bold',
fontSize: 16,
color: colors.titleMain
}
})
export default ColorWheel;