import React from "react"; import PropTypes from "prop-types"; import { Animated, Easing, PanResponder, View } from "react-native"; import { SQUARE_DIMENSIONS } from "./util"; import { STYLES } from "./styles"; import { Icons } from "./components/Icons"; import { CircleBlueGradient } from "./components/CircleBlueGradient"; import { CircleTouchable } from "./components/CircleTouchable"; import { SwipeArrowHint } from "./icons/SwipeArrowHint"; import { Circle } from "./icons/Circle"; import { debounce } from "debounce"; export default class ReactNativeRingPicker extends React.Component { static DEFAULT_ICON = (color) => <Circle color={color}/>; static propTypes = { onPress: PropTypes.func, girthAngle: PropTypes.number, iconHideOnTheBackDuration: PropTypes.number, icons: PropTypes.arrayOf(Object, String), showArrowHint: PropTypes.bool, style: PropTypes.object, styleIconText: PropTypes.object, defaultIconColor: PropTypes.string, isExpDistCorrection: PropTypes.bool, noExpDistCorrectionDegree: PropTypes.number }; static defaultProps = { onPress: (iconId) => {}, girthAngle: 120, iconHideOnTheBackDuration: 250, icons: [{id: "action_1", title: "action_1"}, "action_2", "action_3", "action_4", "action_5"], showArrowHint: true, style: {}, styleIconText: {}, defaultIconColor: undefined, isExpDistCorrection: true, noExpDistCorrectionDegree: 15 }; constructor(props) { super(props); let icons = this.mapPropsIconsToAnimatedOnes(); this.state = { pan: new Animated.Value(0), icons: icons, showArrowHint: this.props.showArrowHint, currentSnappedIcon: this.getCurrentSnappedMiddleIcon(icons), ICON_PATH_RADIUS: 0, XY_AXES_COORDINATES: { X: 0, Y: 0, PAGE_Y: 0, PAGE_X: 0 }, CURRENT_ICON_SHIFT: 0 }; this.INDEX_EXTRACTORS = {}; this.GIRTH_ANGLE = this.props.girthAngle; this.AMOUNT_OF_ICONS = icons.length; this.ICON_POSITION_ANGLE = this.GIRTH_ANGLE / this.AMOUNT_OF_ICONS; // 2*π*r / 360 this.STEP_LENGTH_TO_1_ANGLE = 0; this.DIRECTIONS = { CLOCKWISE: "CLOCKWISE", COUNTERCLOCKWISE: "COUNTERCLOCKWISE" }; this.CIRCLE_SECTIONS = { TOP_LEFT: "TOP_LEFT", TOP_RIGHT: "TOP_RIGHT", BOTTOM_LEFT: "BOTTOM_LEFT", BOTTOM_RIGHT: "BOTTOM_RIGHT" }; this.CURRENT_CIRCLE_SECTION = null; this.CURRENT_DIRECTION = null; this.CURRENT_VECTOR_DIFFERENCE_LENGTH = 0; this.PREVIOUS_POSITION = { X: 0, Y: 0 }; this.ICON_HIDE_ON_THE_BACK_DURATION = this.props.iconHideOnTheBackDuration; this.ALL_ICONS_FINISH_ANIMATIONS = { promises: this.state.icons.reduce((promises, icon) => {promises[icon.id] = null; return promises}, {}), resolvers: this.state.icons.reduce((resolvers, icon) => {resolvers[icon.id] = null; return resolvers}, {}) }; this._panResponder = PanResponder.create({ onMoveShouldSetResponderCapture: () => true, //Tell iOS that we are allowing the movement onMoveShouldSetPanResponderCapture: () => true, // Same here, tell iOS that we allow dragging onPanResponderGrant: (e, gestureState) => { this.hideArrowHint(); this.resetCurrentValues(); this.setPreviousDifferenceLengths(0 ,0); this.state.pan.setValue(this.state.pan._value); }, onPanResponderMove: (e, gestureState) => { this.defineCurrentSection(gestureState.moveX, gestureState.moveY); this.checkPreviousDifferenceLengths(gestureState.dx, gestureState.dy); this.state.pan.setValue(this.CURRENT_VECTOR_DIFFERENCE_LENGTH); this.setState({ ...this.state, CURRENT_ICON_SHIFT: this.CURRENT_VECTOR_DIFFERENCE_LENGTH / this.STEP_LENGTH_TO_1_ANGLE }, () => this.calculateIconCurrentPositions(gestureState.vx)); }, onPanResponderRelease: (evt, gestureState) => { let lastGesture = {...gestureState}; this.createFinishAnimationPromisesAndResolveIfIconsAreNotMovingAlready(); Promise .all(this.getFinishAnimationPromises()) .then(() => this.snapNearestIconToVerticalAxis(lastGesture)); } }); } getCurrentSnappedMiddleIcon(icons) { return icons.filter((icon) => icon.index === 0)[0]; } getFinishAnimationPromises() { return this.state.icons.map((icon) => this.ALL_ICONS_FINISH_ANIMATIONS.promises[icon.id]); } createFinishAnimationPromisesAndResolveIfIconsAreNotMovingAlready() { this.state.icons.forEach((icon) => { this.ALL_ICONS_FINISH_ANIMATIONS.promises[icon.id] = new Promise((resolve) => this.ALL_ICONS_FINISH_ANIMATIONS.resolvers[icon.id] = resolve); !icon.position.x._animation && this.ALL_ICONS_FINISH_ANIMATIONS.resolvers[icon.id](); }); } snapNearestIconToVerticalAxis(lastGesture) { let {minDistanceToVerticalAxis, minDistanceToHorizontalAxis, sign, currentSnappedIcon} = this.getMinDistanceToVerticalAxisAndSnappedIcon(); [minDistanceToVerticalAxis, minDistanceToHorizontalAxis] = this.updateMinimalDistanceExponentialDeflection(minDistanceToVerticalAxis, minDistanceToHorizontalAxis, currentSnappedIcon); this.updateCurrentDirectionBasedOnNearestIconPosition(sign); this.setAdditiveMovementLength((sign * minDistanceToVerticalAxis), -minDistanceToHorizontalAxis); this.setPreviousDifferenceLengths(lastGesture.dx + (sign * minDistanceToVerticalAxis), lastGesture.dy + minDistanceToHorizontalAxis); this.animateAllIconsToMatchVerticalAxis(currentSnappedIcon); } getMinDistanceToVerticalAxisAndSnappedIcon() { let minDistanceToVerticalAxis = this.STEP_LENGTH_TO_1_ANGLE * 360; let minDistanceToHorizontalAxis = this.STEP_LENGTH_TO_1_ANGLE * 360; let sign = 1; let currentSnappedIcon = null; let yCoordinateFromCssStyling = STYLES.iconContainer.top - SQUARE_DIMENSIONS.ICON_PADDING_FROM_WHEEL; this.state.icons.forEach((icon) => { let iconXCenterCoordinate = icon.position.x.__getValue() + STYLES.icon.width / 2; let iconYCenterCoordinate = icon.position.y.__getValue(); let distanceToXAxis = Math.abs((iconXCenterCoordinate) - (this.state.XY_AXES_COORDINATES.X - this.state.XY_AXES_COORDINATES.PAGE_X)); let distanceToYAxis = Math.abs(yCoordinateFromCssStyling - iconYCenterCoordinate); if (distanceToYAxis <= minDistanceToHorizontalAxis) { minDistanceToHorizontalAxis = distanceToYAxis; } if (distanceToXAxis <= minDistanceToVerticalAxis) { if (iconXCenterCoordinate > (this.state.XY_AXES_COORDINATES.X - this.state.XY_AXES_COORDINATES.PAGE_X)) { sign = -1; } else if (iconXCenterCoordinate < (this.state.XY_AXES_COORDINATES.X - this.state.XY_AXES_COORDINATES.PAGE_X)) { sign = 1; } else { sign = 0; minDistanceToVerticalAxis = 0; } minDistanceToVerticalAxis = distanceToXAxis; currentSnappedIcon = icon; } }); return { minDistanceToVerticalAxis, minDistanceToHorizontalAxis, sign, currentSnappedIcon }; } updateCurrentDirectionBasedOnNearestIconPosition(sign) { if (sign > 0) { this.CURRENT_DIRECTION = this.DIRECTIONS.CLOCKWISE; } else { this.CURRENT_DIRECTION = this.DIRECTIONS.COUNTERCLOCKWISE; } }; adjustMinimalExponentialDistanceCorrection(angle, minV, minH) { if (!this.props.isExpDistCorrection) { return [minV, minH]; } let currentAngle = Math.round(angle); let lowestBoundaryDegree = 270 - this.props.noExpDistCorrectionDegree; let highestBoundaryDegree = 270 + this.props.noExpDistCorrectionDegree; if (currentAngle < lowestBoundaryDegree || currentAngle > highestBoundaryDegree) { let number = (15 - 0.004165 * Math.pow((currentAngle - 270), 2)) * this.STEP_LENGTH_TO_1_ANGLE; return [minV - number, minH - Math.sqrt(number) / 2]; } return [minV, minH]; } /** * if current angle is lower than 270 center angle minus 15 degrees gap, that implies parabolic distance * from this. 30 degrees center gap - adjust minimal distance to vertical axis regarding this parabolic distance */ updateMinimalDistanceExponentialDeflection(minDistanceToVerticalAxis, minDistanceToHorizontalAxis, currentSnappedIcon) { const id = currentSnappedIcon.id; const index = currentSnappedIcon.index; let minV = minDistanceToVerticalAxis; let minH = minDistanceToHorizontalAxis; let currentAngle = (270 + this.state.CURRENT_ICON_SHIFT + (this.INDEX_EXTRACTORS[id] || 0) + (index * this.ICON_POSITION_ANGLE)); [minV, minH] = this.adjustMinimalExponentialDistanceCorrection(currentAngle, minV, minH); return [ minV, minH ] } animateAllIconsToMatchVerticalAxis(currentSnappedIcon) { Animated.spring(this.state.pan, { toValue : this.CURRENT_VECTOR_DIFFERENCE_LENGTH, easing : Easing.linear, speed : 12 // useNativeDriver: true // if this is used - the last click after previous release will twist back nad forward }).start(); this.setState({ ...this.state, CURRENT_ICON_SHIFT : this.CURRENT_VECTOR_DIFFERENCE_LENGTH / this.STEP_LENGTH_TO_1_ANGLE, currentSnappedIcon : currentSnappedIcon }, () => this.calculateIconCurrentPositions()); } /** * "some icon" * * id: "some icon" * * title: "some icon" * * el: DEFAULT_ICON * * {id: "test id"} * * id: "test id" * * title: "test id" * * el: DEFAULT_ICON * * {id: "test id", title: "some title"} * * id: "test id" * * title: "some title" * * el: DEFAULT_ICON * * {title: "some title"} * * id: "default" * * title: "some title" * * el: DEFAULT_ICON * * <Search/> * * id: "search" * * title: "search" * * el: <Search /> * * <Search id={"search_id"}/> * * id: "search_id" * * title: "search" * * el: <Search /> * * <Search title={"find"}/> * * id: "search" * * title: "find" * * el: <Search /> * * <Search id={"search_id"} title={"find"}/> * * id: "search_id" * * title: "find" * * el: <Search /> * * @returns {{el: React.Element, isShown: boolean, index: string, id: string, position: Animated.ValueXY, title: string}[]} */ mapPropsIconsToAnimatedOnes() { function getId(propIcon) { if (React.isValidElement(propIcon)) { return propIcon.props?.id || propIcon.type.name.toLowerCase(); } return typeof propIcon === "object" ? propIcon.id || "default" : propIcon; } function getTitle(propIcon) { if (React.isValidElement(propIcon)) { return propIcon.props?.title || propIcon.type.name.toLowerCase(); } return typeof propIcon === "object" ? propIcon.title || propIcon.id : propIcon; } function getIndex(index, array) { return index - Math.trunc(array.length / 2); } let getEl = (propIcon) => { return React.isValidElement(propIcon) ? propIcon : ReactNativeRingPicker.DEFAULT_ICON(this.props.defaultIconColor); }; return this.props.icons.map((propIcon, index, array) => ({ id : getId(propIcon), title : getTitle(propIcon), isShown : true, index : getIndex(index, array), el : getEl(propIcon), position : new Animated.ValueXY() })); } rotateOnInputPixelDistanceMatchingRadianShift() { return [ { transform: [ { rotate: this.state.pan.interpolate({inputRange: [-(this.GIRTH_ANGLE * this.STEP_LENGTH_TO_1_ANGLE), 0, this.GIRTH_ANGLE * this.STEP_LENGTH_TO_1_ANGLE], outputRange: [`-${this.GIRTH_ANGLE}deg`, "0deg", `${this.GIRTH_ANGLE}deg`]}) } ] } ] }; goToCurrentFocusedPage = () => { this.state.currentSnappedIcon && this.props.onPress(this.state.currentSnappedIcon.id); }; defineAxesCoordinatesOnLayoutDisplacement = () => { this._wheelNavigator.measure((x, y, width, height, pageX, pageY) => { this.setState({ ...this.state, ICON_PATH_RADIUS: height / 2 + STYLES.icon.height / 2 + SQUARE_DIMENSIONS.ICON_PADDING_FROM_WHEEL, XY_AXES_COORDINATES: { X: pageX + (width / 2), Y: pageY + (height / 2), PAGE_Y: pageY, PAGE_X: pageX } }); this.STEP_LENGTH_TO_1_ANGLE = 2 * Math.PI * this.state.ICON_PATH_RADIUS / 360; this.calculateIconCurrentPositions(); }); }; defineAxesCoordinatesOnLayoutChangeByStylesOrScreenRotation = () => { this.defineAxesCoordinatesOnLayoutDisplacement(); }; defineCurrentSection(x, y) { let yAxis = y < this.state.XY_AXES_COORDINATES.Y ? "TOP" : "BOTTOM"; let xAxis = x < this.state.XY_AXES_COORDINATES.X ? "LEFT" : "RIGHT"; this.CURRENT_CIRCLE_SECTION = this.CIRCLE_SECTIONS[`${yAxis}_${xAxis}`]; } resetCurrentValues() { this.CURRENT_CIRCLE_SECTION = null; this.CURRENT_DIRECTION = null; this.PREVIOUS_POSITION.X = 0; this.PREVIOUS_POSITION.Y = 0; } setPreviousDifferenceLengths(x, y) { this.PREVIOUS_POSITION.X = x; this.PREVIOUS_POSITION.Y = y; } checkPreviousDifferenceLengths(x, y) { if (this.CURRENT_CIRCLE_SECTION === null) { return; } let differenceX = x - this.PREVIOUS_POSITION.X; let differenceY = y - this.PREVIOUS_POSITION.Y; let getCurrentDirectionForYForLeftHemisphere = (diffY) => { if (diffY < 0) { return this.DIRECTIONS.CLOCKWISE; } if (diffY > 0) { return this.DIRECTIONS.COUNTERCLOCKWISE; } }; let getCurrentDirectionForXForTopHemisphere = (diffX) => { if (diffX < 0) { return this.DIRECTIONS.COUNTERCLOCKWISE; } if (diffX > 0) { return this.DIRECTIONS.CLOCKWISE; } }; let getCurrentDirectionForYForRightHemisphere = (diffY) => { if (diffY < 0) { return this.DIRECTIONS.COUNTERCLOCKWISE; } if (diffY > 0) { return this.DIRECTIONS.CLOCKWISE; } }; let getCurrentDirectionForXForBottomHemisphere = (diffX) => { if (diffX < 0) { return this.DIRECTIONS.CLOCKWISE; } if (diffX > 0) { return this.DIRECTIONS.COUNTERCLOCKWISE; } }; function getCurrentDirectionForTopLeftQuadrant(diffX, diffY) { if (diffX === 0) { return getCurrentDirectionForYForLeftHemisphere(diffY); } return getCurrentDirectionForXForTopHemisphere(diffX); } function getCurrentDirectionForTopRightQuadrant(diffX, diffY) { if (diffX === 0) { return getCurrentDirectionForYForRightHemisphere(diffY); } return getCurrentDirectionForXForTopHemisphere(diffX); } function getCurrentDirectionForBottomLeftQuadrant(diffX, diffY) { if (diffX === 0) { return getCurrentDirectionForYForLeftHemisphere(diffY); } return getCurrentDirectionForXForBottomHemisphere(diffX); } function getCurrentDirectionForBottomRightQuadrant(diffX, diffY) { if (diffX === 0) { return getCurrentDirectionForYForRightHemisphere(diffY); } return getCurrentDirectionForXForBottomHemisphere(diffX); } switch (this.CURRENT_CIRCLE_SECTION) { case this.CIRCLE_SECTIONS.TOP_LEFT: this.CURRENT_DIRECTION = getCurrentDirectionForTopLeftQuadrant(differenceX, differenceY); break; case this.CIRCLE_SECTIONS.TOP_RIGHT: this.CURRENT_DIRECTION = getCurrentDirectionForTopRightQuadrant(differenceX, differenceY); break; case this.CIRCLE_SECTIONS.BOTTOM_LEFT: this.CURRENT_DIRECTION = getCurrentDirectionForBottomLeftQuadrant(differenceX, differenceY); break; case this.CIRCLE_SECTIONS.BOTTOM_RIGHT: this.CURRENT_DIRECTION = getCurrentDirectionForBottomRightQuadrant(differenceX, differenceY); break; } this.setAdditiveMovementLength(differenceX, differenceY); this.setPreviousDifferenceLengths(x, y); } setAdditiveMovementLength(x, y) { let absoluteHypotenuseLength = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2)); if (this.CURRENT_DIRECTION === this.DIRECTIONS.CLOCKWISE) { this.CURRENT_VECTOR_DIFFERENCE_LENGTH += absoluteHypotenuseLength; } if (this.CURRENT_DIRECTION === this.DIRECTIONS.COUNTERCLOCKWISE) { this.CURRENT_VECTOR_DIFFERENCE_LENGTH -= absoluteHypotenuseLength; } } adjustCurrentIconAngleExponentially(angle) { let currentIconAngle = Math.round(angle); if (!this.props.isExpDistCorrection) { return currentIconAngle; } let lowestBoundaryDegree = 270 - this.props.noExpDistCorrectionDegree; let highestBoundaryDegree = 270 + this.props.noExpDistCorrectionDegree; if (currentIconAngle < lowestBoundaryDegree) { return currentIconAngle - (15 - 0.004165 * Math.pow((currentIconAngle - 270), 2)); } else if (currentIconAngle > highestBoundaryDegree) { return currentIconAngle + (15 - 0.004165 * Math.pow((currentIconAngle - 270), 2)); } else { return currentIconAngle; } } calculateIconCurrentPosition(icon) { let currentIconAngle = this.calculateCurrentIconAngle(icon); // the Y coordinate where the center of the circle is higher than the coordinates of Icons, this is actually similar {+X:-Y} section of coordinate net // and INVERTED, this is basically if we'd have an upside-down screen always // console.log(10 - 0.00277 * Math.pow((currentIconAngle - 270), 2)); // console.log(20 - 0.00666 * Math.pow((currentIconAngle - 270), 2)); // console.log(15 - 0.005 * Math.pow((currentIconAngle - 270), 2)); // this is necessary deviation since angle sometimes would be 269.931793549246 or 270.002727265348 degrees /** * y=15-1/200*(x-270)^2 * * this parabolic gap matches the maximum value of 15 degrees for the angle interval of {-60°:60°}, -270° shift makes it possible to calculate the gap for X interval of {210°:330°} * * this is parabolic acceleration, basically - further the position from 270 degrees - more would be the gap from the vertical axis - thus creating the distance from center aligned icon */ currentIconAngle = this.adjustCurrentIconAngleExponentially(currentIconAngle); return { top: this.state.XY_AXES_COORDINATES.Y - this.state.XY_AXES_COORDINATES.PAGE_Y + this.state.ICON_PATH_RADIUS * Math.sin(currentIconAngle * (Math.PI / 180)), left: this.state.XY_AXES_COORDINATES.X - this.state.XY_AXES_COORDINATES.PAGE_X - STYLES.icon.width / 2 + this.state.ICON_PATH_RADIUS * Math.cos(currentIconAngle * (Math.PI / 180)) }; } calculateCurrentIconAngle(icon) { const id = icon.id; const index = icon.index; if (!this.INDEX_EXTRACTORS[id]) { this.INDEX_EXTRACTORS[id] = 0; } let currentAngle = (270 + this.state.CURRENT_ICON_SHIFT + this.INDEX_EXTRACTORS[id] + (index * this.ICON_POSITION_ANGLE)); if (currentAngle < 270 - this.GIRTH_ANGLE / 2) { this.hideIconWhileMovingBehindCircle(id); this.INDEX_EXTRACTORS[id] += this.GIRTH_ANGLE; return currentAngle + this.GIRTH_ANGLE; } if (currentAngle > 270 + this.GIRTH_ANGLE / 2) { this.hideIconWhileMovingBehindCircle(id); this.INDEX_EXTRACTORS[id] -= this.GIRTH_ANGLE; return currentAngle - this.GIRTH_ANGLE; } return currentAngle; } calculateIconCurrentPositions(dx) { function extractCorrectRestDisplacementThreshold(dx) { if (!dx || (dx => 0 && dx <= 1)) { return 1; } return 10; } this.state.icons.forEach((icon) => { let coordinates = this.calculateIconCurrentPosition(icon); Animated.spring(icon.position, { toValue : { x : coordinates.left, y : coordinates.top }, easing : Easing.linear, speed : 30, restSpeedThreshold : 10, bounciness : 0, restDisplacementThreshold : extractCorrectRestDisplacementThreshold(dx) }).start((finish) => finish.finished && typeof this.ALL_ICONS_FINISH_ANIMATIONS.resolvers[icon.id] === "function" && this.ALL_ICONS_FINISH_ANIMATIONS.resolvers[icon.id]()); }); } hideIconWhileMovingBehindCircle(key) { this.setIconDisplayState(key, false); let timeout = setTimeout(() => { this.setIconDisplayState(key, true); clearTimeout(timeout); }, this.ICON_HIDE_ON_THE_BACK_DURATION); } setIconDisplayState(key, state) { this.setState({ ...this.state, icons: [...this.state.icons.map((icon) => { if (icon.id === key) { icon.isShown = state } return icon; })] }); } hideArrowHint() { this.state.showArrowHint && this.setState({ ...this.state, showArrowHint: false }); } render() { let { onPress, style, styleIconText } = this.props; return ( <View style={style} onLayout={debounce(this.defineAxesCoordinatesOnLayoutChangeByStylesOrScreenRotation, 100)}> <Icons icons={this.state.icons} onPress={onPress} styleIconText={styleIconText}/> <View style={[STYLES.wheel]} ref={component => this._wheelNavigator = component} onLayout={this.defineAxesCoordinatesOnLayoutDisplacement}> {this.state.showArrowHint && <View style={STYLES.swipeArrowHint}><SwipeArrowHint /></View>} <Animated.View style={this.rotateOnInputPixelDistanceMatchingRadianShift()} {...this._panResponder.panHandlers}> <CircleBlueGradient /> </Animated.View> <View style={STYLES.wheelTouchableCenter}> <CircleTouchable onPress={this.goToCurrentFocusedPage}/> </View> </View> </View> ); } }