Multi-color color wheel follow edge problem

208 Views Asked by At

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;
0

There are 0 best solutions below