UNPKG

react-native-circle-list

Version:

A Javascript implementation of a circular list, for React Native

673 lines (553 loc) 24.2 kB
import React, { PureComponent } from 'react' import { Animated, Dimensions, PanResponder, Text } from 'react-native' import PropTypes from 'prop-types' import { CircleListLayout } from './CircleListLayout' const { width } = Dimensions.get('screen') const { abs, acos, cos, PI, sin } = Math export class CircleList extends PureComponent { static defaultProps = { data: [], elementCount: 12, flatness: 0, initialRotationOffset: (3 * PI) / 2, radius: (1.2 * width) / 2, selectedItemScale: 1.15, swipeSpeedMultiplier: 60, visibilityPadding: 3, } static propTypes = { data: PropTypes.array.isRequired, containerStyle: PropTypes.object, elementCount: PropTypes.number, flatness: PropTypes.number, initialRotationOffset: PropTypes.number, innerRef: PropTypes.func, keyExtractor: PropTypes.func.isRequired, onScroll: PropTypes.func, onScrollBegin: PropTypes.func, onScrollEnd: PropTypes.func, radius: PropTypes.number, renderItem: PropTypes.func.isRequired, selectedItemScale: PropTypes.number, swipeSpeedMultiplier: PropTypes.number, visibilityPadding: PropTypes.number, } constructor(props) { super(props) const { data, elementCount, visibilityPadding } = props const dataWithIndexes = this._assignIndexes(data) const elementCountWithMin = Math.max(elementCount, 12) const elementCountWithMax = Math.min(elementCountWithMin, 40) this.dataIndex = 0 this.rotationOffset = 0 this.selectedIndex = 0 this.state = { breakpoints: this._getBreakpoints(elementCountWithMax, (2 * PI) / elementCountWithMax), data: dataWithIndexes, dataIndexLeft: data.length - visibilityPadding - 1, dataIndexRight: visibilityPadding + 1, displayData: this._getOffsetData(dataWithIndexes, elementCountWithMax), elementCount: elementCountWithMax, insertionIndexLeft: elementCountWithMax - visibilityPadding - 1, insertionIndexRight: visibilityPadding + 1, rotationIndex: 0, scrolling: false, theta: (2 * PI) / elementCountWithMax, visibleDataBounds: this._getVisibleElements(), } this._innerRef = this._innerRef.bind(this) this._panResponder = PanResponder.create({ // Ask to be the responder: onStartShouldSetPanResponder: (_, gestureState) => { const { dx, dy } = gestureState return dx !== 0 || dy !== 0 }, onStartShouldSetPanResponderCapture: (_, gestureState) => { const { dx, dy } = gestureState return dx !== 0 || dy !== 0 }, onMoveShouldSetPanResponder: (_, gestureState) => { const { dx, dy } = gestureState return dx !== 0 || dy !== 0 }, onMoveShouldSetPanResponderCapture: (_, gestureState) => { const { dx, dy } = gestureState return dx !== 0 || dy !== 0 }, onPanResponderGrant: () => null, onPanResponderMove: (_, gestureState) => { const { dx, moveX } = gestureState // Don't do anything if not a swipe gesture if (dx === 0) { return } const { radius, selectedItemScale, swipeSpeedMultiplier } = this.props const { breakpoints, displayData, rotationIndex, scrolling, theta } = this.state if (!scrolling) { this._onScrollBegin(this.dataIndex) this.setState({ scrolling: true }) } const { rotationOffset } = this const direction = dx < 0 ? 'LEFT' : 'RIGHT' const xNew = radius - moveX const directionFactor = dx > 0 ? -1 : 1 const thetaOffset = isNaN(acos(xNew / radius)) ? 0 : (swipeSpeedMultiplier / 1000) * directionFactor * acos(xNew / radius) // Reset rotation offset after one full revolution in either direction const resetOffset = rotationOffset > 2 * PI ? rotationOffset - 2 * PI : rotationOffset < -2 * PI ? rotationOffset + 2 * PI : rotationOffset // Get updated rotation index const newRotationIndex = this._getClosestIndex( resetOffset - thetaOffset, breakpoints, theta, direction ) // Only get new data index if rotation index has changed if (newRotationIndex !== rotationIndex) { const newDataIndex = this._getDataIndex(direction) const [insertionIndexLeft, insertionIndexRight] = this._getInsertionIndex( direction, 'ELEMENTS' ) const [dataIndexLeft, dataIndexRight] = this._getInsertionIndex( direction, 'DATA' ) const displayData = this._getDisplayData( dataIndexLeft, dataIndexRight, insertionIndexLeft, insertionIndexRight ) const visibleDataBounds = this._getVisibleElements() this.setState({ dataIndexLeft, dataIndexRight, displayData, insertionIndexLeft, insertionIndexRight, rotationIndex: newRotationIndex, visibleDataBounds, }) this.dataIndex = newDataIndex this.rotationOffset = resetOffset - thetaOffset this.selectedIndex = newRotationIndex this._onScroll(this.dataIndex) return displayData.forEach((_, index) => { const { translateX, translateY } = this._getTransforms(index) this.state[`scale${index}`].setValue( index === this.selectedIndex ? selectedItemScale : 1 ) this.state[`translateX${index}`].setValue(translateX) this.state[`translateY${index}`].setValue(translateY) }) } this.rotationOffset = resetOffset - thetaOffset displayData.forEach((_, index) => { const { translateX, translateY } = this._getTransforms(index) this.state[`scale${index}`].setValue( index === this.selectedIndex ? selectedItemScale : 1 ) this.state[`translateX${index}`].setValue(translateX) this.state[`translateY${index}`].setValue(translateY) }) this._onScroll(this.dataIndex) }, onPanResponderTerminationRequest: () => true, onPanResponderRelease: (_, gestureState) => { const { dx, vx } = gestureState // Don't do anything if not a swipe gesture if (dx === 0) { return } const { selectedItemScale } = this.props const { breakpoints, displayData, rotationIndex, theta } = this.state const direction = dx < 0 ? 'LEFT' : 'RIGHT' const selectedIndex = this._getClosestIndex( this.rotationOffset, breakpoints, theta, direction ) // Only get snap animations if rotation index has changed if (selectedIndex !== this.rotationIndex) { // Calculate offset to snap to nearest index const snapOffset = 2 * PI - breakpoints[selectedIndex] this.rotationOffset = snapOffset this.setState({ rotationIndex: selectedIndex }) const animations = displayData.map((_, index) => { const { translateX, translateY } = this._getTransforms(index) this.state[`scale${index}`].setValue( index === this.selectedIndex ? selectedItemScale : 1 ) const xSpring = Animated.spring(this.state[`translateX${index}`], { toValue: translateX, velocity: abs(vx), useNativeDriver: true, }) const ySpring = Animated.spring(this.state[`translateY${index}`], { toValue: translateY, velocity: abs(vx), useNativeDriver: true, }) return Animated.parallel([xSpring, ySpring]) }) Animated.parallel(animations).start(() => this.setState({ scrolling: false })) return this._onScrollEnd(this.dataIndex) } this.setState({ scrolling: false }) this._onScrollEnd(this.dataIndex) }, onPanResponderTerminate: () => null, onShouldBlockNativeResponder: () => true, }) } _assignIndexes = data => { if (!data) { return } return data.map((item, index) => ({ ...item, _dataIndex: index, })) } _calcHeight = () => { const { radius } = this.props const { elementCount } = this.state return ((12 / elementCount) * 1.8 * radius) / 2 } _getBreakpoints = (elementCount, separationAngle) => { const _calc = (breakpoints, count) => { const newBreakpoints = breakpoints.concat(count * separationAngle) if (count < elementCount - 1) { return _calc(newBreakpoints, count + 1) } return newBreakpoints } return _calc([], 0) } _getClosestIndex = (offset, breakpoints, separationAngle, direction) => { const offsets = breakpoints.map((_, index) => { if (offset >= 0) { if (index === 0 && direction === 'LEFT') { return 2 * PI - abs(breakpoints.length * separationAngle - offset) } return abs((breakpoints.length - index) * separationAngle - offset) } return abs(offset + index * separationAngle) }) return offsets.indexOf(Math.min(...offsets)) } _getDataIndex = direction => { const { data } = this.state const { length } = data if (direction === 'LEFT') { const incrementedIndex = this.dataIndex + 1 return incrementedIndex >= length ? incrementedIndex - length : incrementedIndex } if (direction === 'RIGHT') { const decrementedIndex = this.dataIndex - 1 return decrementedIndex < 0 ? decrementedIndex + length : decrementedIndex } } _getDisplayData = (dataIndexLeft, dataIndexRight, insertionIndexLeft, insertionIndexRight) => { const { data, displayData } = this.state return Object.assign([...displayData], { [insertionIndexLeft]: data[dataIndexLeft], [insertionIndexRight]: data[dataIndexRight], }) } _getInsertionIndex = (direction, type) => { const { data, dataIndexLeft, dataIndexRight, elementCount, insertionIndexLeft, insertionIndexRight, } = this.state // Set wrapping bounds based on type argument const indexLeft = type === 'DATA' ? dataIndexLeft : insertionIndexLeft const indexRight = type === 'DATA' ? dataIndexRight : insertionIndexRight const length = type === 'DATA' ? data.length : elementCount // Increment index for left swipe, wrap if index greater than length if (direction === 'LEFT') { const incrementedIndexLeft = indexLeft + 1 const incrementedIndexRight = indexRight + 1 return [ incrementedIndexLeft >= length ? incrementedIndexLeft - length : incrementedIndexLeft, incrementedIndexRight >= length ? incrementedIndexRight - length : incrementedIndexRight, ] // Decrement index for right swipe, wrap if less than zero } if (direction === 'RIGHT') { const decrementedIndexLeft = indexLeft - 1 const decrementedIndexRight = indexRight - 1 return [ decrementedIndexLeft < 0 ? length + decrementedIndexLeft : decrementedIndexLeft, decrementedIndexRight < 0 ? length + decrementedIndexRight : decrementedIndexRight, ] } } _getOffsetData = (data, elementCount) => { const { length } = data return [...data.slice(0, elementCount / 2), ...data.slice(length - elementCount / 2)] } _getScrollToIndex = index => { const { data } = this.state const { length } = data if (index > this.dataIndex) { if (index - this.dataIndex < length - index + this.dataIndex) { return { direction: 'LEFT', stepCount: index - this.dataIndex, } } return { direction: 'RIGHT', stepCount: length - index + this.dataIndex, } } if (this.dataIndex - index < length - this.dataIndex + index) { return { direction: 'RIGHT', stepCount: this.dataIndex - index, } } return { direction: 'LEFT', stepCount: data.length - this.dataIndex + index, } } _getTransforms = index => { const { flatness, initialRotationOffset, radius } = this.props const { theta } = this.state const thetaOffset = 2 * PI * index + (this.rotationOffset + initialRotationOffset) const translateX = radius * cos(index * theta + thetaOffset) const translateY = (1 - flatness) * radius * sin(index * theta + thetaOffset) + (1 - flatness) * radius return { translateX, translateY } } _getVisibleElements = () => { const { data, visibilityPadding } = this.props const { length } = data const leftBound = this.dataIndex - visibilityPadding - 1 const leftBoundAdjusted = leftBound < 0 ? length + leftBound : leftBound const rightBound = this.dataIndex + visibilityPadding + 1 const rightBoundAdjusted = rightBound > length ? rightBound - length : rightBound const _getBounds = (currentIndex, boundsArray) => { const newIndex = currentIndex + 1 const newBoundsArray = boundsArray.concat(newIndex) if (newIndex < rightBoundAdjusted) { return _getBounds(newIndex, newBoundsArray) } return newBoundsArray } if (leftBoundAdjusted > rightBoundAdjusted) { const _getLeftBounds = (currentIndex, boundsArray) => { const newIndex = currentIndex + 1 const newBoundsArray = boundsArray.concat(newIndex) if (newIndex < length) { return _getLeftBounds(newIndex, newBoundsArray) } return newBoundsArray } const _getRightBounds = (currentIndex, boundsArray) => { const newIndex = currentIndex + 1 const newBoundsArray = boundsArray.concat(newIndex) if (newIndex < rightBoundAdjusted) { return _getRightBounds(newIndex, newBoundsArray) } return newBoundsArray } const leftBounds = _getLeftBounds(leftBoundAdjusted, [leftBoundAdjusted]) const rightBounds = _getRightBounds(0, [0]) return [...leftBounds, ...rightBounds] } return _getBounds(leftBoundAdjusted, [leftBoundAdjusted]) } _innerRef = () => { const { innerRef } = this.props innerRef && innerRef(this) } _keyExtractor = (item, index) => { const { keyExtractor } = this.props return keyExtractor(item, index) } _onScroll = index => { const { onScroll } = this.props onScroll && onScroll(index) } _onScrollBegin = index => { const { onScrollBegin } = this.props onScrollBegin && onScrollBegin(index) } _onScrollEnd = index => { const { onScrollEnd } = this.props onScrollEnd && onScrollEnd(index) } _renderItem = ({ item, index }) => { const { renderItem } = this.props return renderItem({ item, index }) } _setElementPositions = () => { const { displayData } = this.state this._innerRef() const transforms = displayData.reduce((acc, _, index) => { const { selectedItemScale } = this.props const { translateX, translateY } = this._getTransforms(index) return { ...acc, [`scale${index}`]: new Animated.Value( index === this.selectedIndex ? selectedItemScale : 1 ), [`translateX${index}`]: new Animated.Value(translateX), [`translateY${index}`]: new Animated.Value(translateY), } }, {}) this.setState({ ...transforms }) } scrollToIndex = (index, stepDuration = 30) => { if (index === this.dataIndex) { return } this._onScrollBegin(this.dataIndex) const { selectedItemScale } = this.props const { breakpoints, displayData, rotationIndex, theta } = this.state const { direction, stepCount } = this._getScrollToIndex(index) const step = currentCount => { const newCount = currentCount + 1 const resetOffset = this.rotationOffset > 2 * PI ? this.rotationOffset - 2 * PI : this.rotationOffset < -2 * PI ? this.rotationOffset + 2 * PI : this.rotationOffset this.dataIndex = this._getDataIndex(direction) // Overshoot on last step of scroll for spring effect const thetaOffset = newCount < stepCount ? theta : theta * 1.07 this.rotationOffset = direction === 'RIGHT' ? resetOffset + thetaOffset : resetOffset - thetaOffset this.selectedIndex = this._getClosestIndex( this.rotationOffset, breakpoints, theta, direction ) const animations = displayData.map((_, index) => { const { translateX, translateY } = this._getTransforms(index) this.state[`scale${index}`].setValue( index === this.selectedIndex ? selectedItemScale : 1 ) const xTiming = Animated.timing(this.state[`translateX${index}`], { toValue: translateX, duration: stepDuration, useNativeDriver: true, }) const yTiming = Animated.timing(this.state[`translateY${index}`], { toValue: translateY, duration: stepDuration, useNativeDriver: true, }) return Animated.parallel([xTiming, yTiming]) }) Animated.parallel(animations).start(() => { const [insertionIndexLeft, insertionIndexRight] = this._getInsertionIndex( direction, 'ELEMENTS' ) const [dataIndexLeft, dataIndexRight] = this._getInsertionIndex(direction, 'DATA') const displayData = this._getDisplayData( dataIndexLeft, dataIndexRight, insertionIndexLeft, insertionIndexRight ) const newRotationIndex = this.selectedIndex const visibleDataBounds = this._getVisibleElements() if (newRotationIndex !== rotationIndex) this.setState({ dataIndexLeft, dataIndexRight, displayData, insertionIndexLeft, insertionIndexRight, rotationIndex: newRotationIndex, visibleDataBounds, }) if (newCount < stepCount) { return step(newCount) } const selectedIndex = this._getClosestIndex( this.rotationOffset, breakpoints, theta, direction ) const snapOffset = 2 * PI - breakpoints[selectedIndex] this.rotationOffset = snapOffset this.setState({ rotationIndex: selectedIndex }) const finalAnimations = displayData.map((_, index) => { const { translateX, translateY } = this._getTransforms(index) this.state[`scale${index}`].setValue( index === this.selectedIndex ? selectedItemScale : 1 ) const xSpring = Animated.spring(this.state[`translateX${index}`], { toValue: translateX, velocity: abs(0.03 * stepDuration), useNativeDriver: true, }) const ySpring = Animated.spring(this.state[`translateY${index}`], { toValue: translateY, velocity: abs(0.03 * stepDuration), useNativeDriver: true, }) return Animated.parallel([xSpring, ySpring]) }) Animated.parallel(finalAnimations).start(() => { this.setState({ scrolling: false }) }) return this._onScrollEnd(this.dataIndex) }) } step(0) } componentDidMount() { this._setElementPositions() } componentDidUpdate(prevProps) { const { flatness } = this.props if (prevProps.flatness !== flatness) { this._setElementPositions() } } render() { const { containerStyle, radius } = this.props const { data, displayData, theta, visibleDataBounds } = this.state return ( <CircleListLayout calcHeight={this._calcHeight} containerStyle={containerStyle} data={data} displayData={displayData} keyExtractor={this._keyExtractor} panHandlers={this._panResponder.panHandlers} radius={radius} renderItem={this._renderItem} state={this.state} theta={theta} visibleDataBounds={visibleDataBounds} /> ) } }