UNPKG

@starodubenko/react-native-card-stack-swiper

Version:
551 lines (483 loc) 16.1 kB
import React, { Component } from 'react'; import PropTypes from 'prop-types' import { polyfill } from 'react-lifecycles-compat'; import { View, Animated, PanResponder, Dimensions, Text, Platform } from 'react-native'; const { height, width } = Dimensions.get('window'); class CardStack extends Component { static distance(x, y) { const a = Math.abs(x); const b = Math.abs(y); const c = Math.sqrt((a * a) + (b * b)); return c; } constructor(props) { super(props); this.state = { drag: new Animated.ValueXY({ x: 0, y: 0 }), dragDistance: new Animated.Value(0), sindex: 0, // index to the next card to be renderd mod card.length cardA: null, cardB: null, topCard: 'cardA', cards: [], touchStart: 0, }; this.distance = this.constructor.distance; this._getDirection = (gestureState) => { const { disableTopSwipe, disableLeftSwipe, disableRightSwipe, disableBottomSwipe, } = this.props; let swipeDirection = (gestureState.dx < 0) ? width * -1.5 : width * 1.5; if (swipeDirection < 0 && !disableLeftSwipe) { return 'left'; } else if (swipeDirection > 0 && !disableRightSwipe) { return 'right'; } swipeDirection = (gestureState.dy < 0) ? height * -1 : height; if (swipeDirection < 0 && !disableTopSwipe) { return 'top'; } else if (swipeDirection > 0 && !disableBottomSwipe) { return 'bottom'; } }; this._panResponder = PanResponder.create({ onStartShouldSetPanResponder: (evt, gestureState) => false, onStartShouldSetPanResponderCapture: (evt, gestureState) => false, onMoveShouldSetPanResponder: (evt, gestureState) => { const isVerticalSwipe = Math.sqrt( Math.pow(gestureState.dx, 2) < Math.pow(gestureState.dy, 2) ) if (!this.props.verticalSwipe && isVerticalSwipe) { return false } return Math.sqrt(Math.pow(gestureState.dx, 2) + Math.pow(gestureState.dy, 2)) > 10 }, onMoveShouldSetPanResponderCapture: (evt, gestureState) => { const isVerticalSwipe = Math.sqrt( Math.pow(gestureState.dx, 2) < Math.pow(gestureState.dy, 2) ) if (!this.props.verticalSwipe && isVerticalSwipe) { return false } return Math.sqrt(Math.pow(gestureState.dx, 2) + Math.pow(gestureState.dy, 2)) > 10 }, onPanResponderGrant: (evt, gestureState) => { this.props.onSwipeStart(); this.setState({ touchStart: new Date().getTime() }); }, onPanResponderMove: (evt, gestureState) => { const { verticalSwipe, horizontalSwipe } = this.props; const dragDistance = this.distance((horizontalSwipe) ? gestureState.dx : 0, (verticalSwipe) ? gestureState.dy : 0); this.state.dragDistance.setValue(dragDistance); this.state.drag.setValue({ x: (horizontalSwipe) ? gestureState.dx : 0, y: (verticalSwipe) ? gestureState.dy : 0 }); this.props.onDrag(dragDistance); this.props.onDirectionChanged(this._getDirection(gestureState)); }, onPanResponderTerminationRequest: (evt, gestureState) => true, onPanResponderRelease: (evt, gestureState) => { this.props.onSwipeEnd(); const currentTime = new Date().getTime(); const swipeDuration = currentTime - this.state.touchStart; const { verticalThreshold, horizontalThreshold, disableTopSwipe, disableLeftSwipe, disableRightSwipe, disableBottomSwipe, } = this.props; if (((Math.abs(gestureState.dx) > horizontalThreshold) || (Math.abs(gestureState.dx) > horizontalThreshold * 0.3 && swipeDuration < 150) ) && this.props.horizontalSwipe) { const swipeDirection = (gestureState.dx < 0) ? width * -1.5 : width * 1.5; if (swipeDirection < 0 && !disableLeftSwipe) { this._nextCard('left', swipeDirection, gestureState.dy, this.props.duration); } else if (swipeDirection > 0 && !disableRightSwipe) { this._nextCard('right', swipeDirection, gestureState.dy, this.props.duration); } else { this._resetCard(); } } else if (((Math.abs(gestureState.dy) > verticalThreshold) || (Math.abs(gestureState.dy) > verticalThreshold * 0.8 && swipeDuration < 150) ) && this.props.verticalSwipe) { const swipeDirection = (gestureState.dy < 0) ? height * -1 : height; if (swipeDirection < 0 && !disableTopSwipe) { this._nextCard('top', gestureState.dx, swipeDirection, this.props.duration); } else if (swipeDirection > 0 && !disableBottomSwipe) { this._nextCard('bottom', gestureState.dx, swipeDirection, this.props.duration); } else { this._resetCard(); } } else { this._resetCard(); } }, onPanResponderTerminate: (evt, gestureState) => { }, onShouldBlockNativeResponder: (evt, gestureState) => { return true; }, }); } componentDidUpdate(prevProps) { if (!this._isSameChildren(this.props.children, prevProps.children)) { let aIndex = (this.state.topCard == 'cardA') ? this.mod(this.state.sindex - 2, this.props.children.length) : this.mod(this.state.sindex - 1, this.props.children.length); let bIndex = (this.state.topCard == 'cardB') ? this.mod(this.state.sindex - 2, this.props.children.length) : this.mod(this.state.sindex - 1, this.props.children.length); this.setState({ cards: this.props.children, cardA: this.props.children[aIndex], cardB: this.props.children[bIndex] }); } } componentDidMount() { this.initDeck(); } _isSameChildren(a, b) { if (typeof a !=typeof b) return false; if (typeof a === 'undefined') return false; if (a.length != b.length) return false; for (let i in a) { if (a[i].key != b[i].key) { return false } } return true } initDeck() { // check if we only have 1 child if (typeof this.props.children !== 'undefined' && !Array.isArray(this.props.children)) { this.setState({ cards: [this.props.children], cardA: this.props.children, cardB: null, sindex: 2, }); } else if (Array.isArray(this.props.children)) { this.setState({ cards: this.props.children, cardA: this.props.children[0], cardB: this.props.children[1], sindex: 2, }); } } _resetCard() { Animated.timing( this.state.dragDistance, { toValue: 0, duration: this.props.duration, } ).start(); Animated.spring( this.state.drag, { toValue: { x: 0, y: 0 }, duration: this.props.duration, } ).start(); } goBackFromTop() { this._goBack('top'); } goBackFromRight() { this._goBack('right'); } goBackFromLeft() { this._goBack('left'); } goBackFromBottom() { this._goBack('bottom'); } mod(n, m) { return ((n % m) + m) % m; } _goBack(direction) { const { cards, sindex, topCard } = this.state; if ((sindex - 3) < 0 && !this.props.loop) return; const previusCardIndex = this.mod(sindex - 3, cards.length) let update = {}; if (topCard === 'cardA') { update = { ...update, cardB: cards[previusCardIndex] } } else { update = { ...update, cardA: cards[previusCardIndex], } } this.setState({ ...update, topCard: (topCard === 'cardA') ? 'cardB' : 'cardA', sindex: sindex - 1 }, () => { switch (direction) { case 'top': this.state.drag.setValue({ x: 0, y: -height }); this.state.dragDistance.setValue(height); break; case 'left': this.state.drag.setValue({ x: -width, y: 0 }); this.state.dragDistance.setValue(width); break; case 'right': this.state.drag.setValue({ x: width, y: 0 }); this.state.dragDistance.setValue(width); break; case 'bottom': this.state.drag.setValue({ x: 0, y: height }); this.state.dragDistance.setValue(width); break; default: } Animated.spring( this.state.dragDistance, { toValue: 0, duration: this.props.duration, } ).start(); Animated.spring( this.state.drag, { toValue: { x: 0, y: 0 }, duration: this.props.duration, } ).start(); }) } swipeTop(onSwiped, d = null) { this._nextCard('top', 0, -height, d || this.props.duration, onSwiped); } swipeBottom(onSwiped, d = null) { this._nextCard('bottom', 0, height, d || this.props.duration, onSwiped); } swipeRight(onSwiped, d = null) { this._nextCard('right', width * 1.5, 0, d || this.props.duration, onSwiped); } swipeLeft(onSwiped, d = null) { this._nextCard('left', -width * 1.5, 0, d || this.props.duration, onSwiped); } _nextCard(direction, x, y, duration = 400, onSwiped = () => {}) { const { verticalSwipe, horizontalSwipe, loop } = this.props; const { sindex, cards, topCard } = this.state; // index for the next card to be renderd const nextCard = (loop) ? (Math.abs(sindex) % cards.length) : sindex; // index of the swiped card const index = (loop) ? this.mod(nextCard - 2, cards.length) : nextCard - 2; if (index === cards.length - 1) { this.props.onSwipedAll(); } if ((sindex - 2 < cards.length) || (loop)) { Animated.spring( this.state.dragDistance, { toValue: 220, duration, } ).start(); Animated.timing( this.state.drag, { toValue: { x: (horizontalSwipe) ? x : 0, y: (verticalSwipe) ? y : 0 }, duration, } ).start(() => { onSwiped(); const newTopCard = (topCard === 'cardA') ? 'cardB' : 'cardA'; let update = {}; if (newTopCard === 'cardA') { update = { ...update, cardB: cards[nextCard] }; } if (newTopCard === 'cardB') { update = { ...update, cardA: cards[nextCard], }; } this.state.drag.setValue({ x: 0, y: 0 }); this.state.dragDistance.setValue(0); this.setState({ ...update, topCard: newTopCard, sindex: nextCard + 1 }); this.props.onSwiped(index); switch (direction) { case 'left': this.props.onSwipedLeft(index); if (this.state.cards[index].props.onSwipedLeft) this.state.cards[index].props.onSwipedLeft(); break; case 'right': this.props.onSwipedRight(index); if (this.state.cards[index].props.onSwipedRight) this.state.cards[index].props.onSwipedRight(); break; case 'top': this.props.onSwipedTop(index); if (this.state.cards[index].props.onSwipedTop) this.state.cards[index].props.onSwipedTop(); break; case 'bottom': this.props.onSwipedBottom(index); if (this.state.cards[index].props.onSwipedBottom) this.state.cards[index].props.onSwipedBottom(); break; default: } }); } } /** * @description CardB’s click feature is trigger the CardA on the card stack. (Solved on Android) * @see https://facebook.github.io/react-native/docs/view#pointerevents */ _setPointerEvents(topCard, topCardName) { return { pointerEvents: topCard === topCardName ? "auto" : "none" } } getCardStyles = (cardName) => { const { secondCardZoom, disableArcX } = this.props; const { drag, dragDistance, topCard } = this.state; const scale = dragDistance.interpolate({ inputRange: [ 0, 10, 220], outputRange: [secondCardZoom, secondCardZoom, 1], extrapolate: 'clamp', }); const rotate = drag.x.interpolate({ inputRange: [width * -1.5, 0, width * 1.5], outputRange: this.props.outputRotationRange, extrapolate: 'clamp', }); const transitionStyles = disableArcX ? { transform: [ { rotate: (topCard === cardName) ? rotate : '0deg' }, { translateY: (topCard === cardName) ? drag.y : 0 }, { scale: (topCard === cardName) ? 1 : scale }, ], left: (topCard === cardName) ? drag.x : 0, } : { transform: [ { rotate: (topCard === cardName) ? rotate : '0deg' }, { translateX: (topCard === cardName) ? drag.x : 0 }, { translateY: (topCard === cardName) ? drag.y : 0 }, { scale: (topCard === cardName) ? 1 : scale }, ] }; return { position: 'absolute', zIndex: (topCard === cardName) ? 4 : 2, ...Platform.select({ android: { elevation: (topCard === cardName) ? 4 : 2, } }), ...transitionStyles, } }; render() { const { renderNoMoreCards, swipeBackgroundComponent } = this.props; const { cardA, cardB, topCard } = this.state; return ( <View {...this._panResponder.panHandlers} style={[{ position: 'relative' }, this.props.style]}> <View style={this.props.contentStyle}> {renderNoMoreCards()} <Animated.View {...this._setPointerEvents(topCard, 'cardB')} style={this.getCardStyles('cardB')}> {cardB} </Animated.View> {swipeBackgroundComponent} <Animated.View {...this._setPointerEvents(topCard, 'cardA')} style={this.getCardStyles('cardA')}> {cardA} </Animated.View> </View> </View> ); } } CardStack.propTypes = { children: PropTypes.oneOfType([PropTypes.array, PropTypes.object]).isRequired, style: PropTypes.oneOfType([PropTypes.number, PropTypes.object, PropTypes.array]), secondCardZoom: PropTypes.number, loop: PropTypes.bool, renderNoMoreCards: PropTypes.oneOfType([PropTypes.func, PropTypes.element]), onSwipeStart: PropTypes.func, onSwipeEnd: PropTypes.func, onSwiped: PropTypes.func, onSwipedLeft: PropTypes.func, onSwipedRight: PropTypes.func, onSwipedTop: PropTypes.func, onSwipedBottom: PropTypes.func, onSwipedAll: PropTypes.func, disableBottomSwipe: PropTypes.bool, disableLeftSwipe: PropTypes.bool, disableRightSwipe: PropTypes.bool, disableTopSwipe: PropTypes.bool, disableArcX: PropTypes.bool, verticalSwipe: PropTypes.bool, verticalThreshold: PropTypes.number, horizontalSwipe: PropTypes.bool, horizontalThreshold: PropTypes.number, outputRotationRange: PropTypes.array, duration: PropTypes.number, swipeBackgroundComponent: PropTypes.any, onDrag: PropTypes.func, onDirectionChanged: PropTypes.func, } CardStack.defaultProps = { style: {}, contentStyle: {}, secondCardZoom: 0.95, loop: false, renderNoMoreCards: () => { return (<Text>No More Cards</Text>) }, onSwipeStart: () => null, onSwipeEnd: () => null, onSwiped: () => { }, onSwipedLeft: () => { }, onSwipedRight: () => { }, onSwipedTop: () => { }, onSwipedBottom: () => { }, onSwipedAll: async () => { }, disableBottomSwipe: false, disableLeftSwipe: false, disableRightSwipe: false, disableTopSwipe: false, disableArcX: false, verticalSwipe: true, verticalThreshold: height / 4, horizontalSwipe: true, horizontalThreshold: width / 2, outputRotationRange: ['-15deg', '0deg', '15deg'], duration: 300, swipeBackgroundComponent: null, onDrag: () => {}, onDirectionChanged: () => {}, } polyfill(CardStack); export default CardStack;