import React, { Component } from "react"; import PropTypes from "prop-types" import { View, Animated, PanResponder, Dimensions, Text, Platform } from "react-native"; const { height, width } = Dimensions.get("window"); class CardStack extends Component { static distance(x, y) { return Math.hypot(x, y); } 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._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 movedX = gestureState.moveX - gestureState.x0; const movedY = gestureState.moveY - gestureState.y0; this.props.onSwipe(movedX, movedY); 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 }); }, 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.6 && 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 (typeof this.props.children === "undefined") return; if (!this._isSameChildren(this.props.children, prevProps.children)) { const children = Array.isArray(this.props.children) ? this.props.children : [this.props.children]; const aIndex = (this.state.topCard == "cardA") ? this._getIndex(this.state.sindex - 2, children.length) : this._getIndex(this.state.sindex - 1, children.length); const bIndex = (this.state.topCard == "cardB") ? this._getIndex(this.state.sindex - 2, children.length) : this._getIndex(this.state.sindex - 1, children.length); this.setState({ cards: children, cardA: children[aIndex] || null, cardB: children[bIndex] || null }); } } _getIndex(index, cards){ return this.props.loop ? this.mod(index, cards): index; } componentDidMount() { this.initDeck(); } _isSameChildren(a, b) { if (typeof a != typeof b) return false; if (typeof a === "undefined") return false; if (Array.isArray(a) && Array.isArray(b)) { if (a.length != b.length) return false; for (const i in a) { if (a[i].key != b[i].key) { return false } } return true; } if (a.key !== b.key) return false; return true } initDeck() { if (typeof this.props.children === "undefined") return; const { children, loop } = this.props; const cards = Array.isArray(children) ? children : [children]; const initialIndexA = this.props.initialIndex < cards.length ? this.props.initialIndex : 0; const initialIndexB = loop ? this.mod(initialIndexA + 1, cards.length) : initialIndexA + 1; const cardA = cards[initialIndexA] || null; const cardB = cards[initialIndexB] || null; this.setState({ cards, cardA, cardB, sindex: initialIndexB + 1, }); } _resetCard() { Animated.timing( this.state.dragDistance, { toValue: 0, duration: this.props.duration, useNativeDriver: this.props.useNativeDriver || false, } ).start(); Animated.spring( this.state.drag, { toValue: { x: 0, y: 0 }, duration: this.props.duration, useNativeDriver: this.props.useNativeDriver || false, } ).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, useNativeDriver: this.props.useNativeDriver || false, } ).start(); Animated.spring( this.state.drag, { toValue: { x: 0, y: 0 }, duration: this.props.duration, useNativeDriver: this.props.useNativeDriver || false, } ).start(); }) } swipeTop(d = null) { this._nextCard("top", 0, -height, d || this.props.duration); } swipeBottom(d = null) { this._nextCard("bottom", 0, height, d || this.props.duration); } swipeRight(d = null) { this._nextCard("right", width * 1.5, 0, d || this.props.duration); } swipeLeft(d = null) { this._nextCard("left", -width * 1.5, 0, d || this.props.duration); } _nextCard(direction, x, y, duration = 400) { 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, useNativeDriver: this.props.useNativeDriver || false, } ).start(); Animated.timing( this.state.drag, { toValue: { x: (horizontalSwipe) ? x : 0, y: (verticalSwipe) ? y : 0 }, duration, useNativeDriver: this.props.useNativeDriver || false, } ).start(() => { 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] && this.state.cards[index].props.onSwipedLeft) this.state.cards[index] && this.state.cards[index].props.onSwipedLeft(); break; case "right": this.props.onSwipedRight(index); if (this.state.cards[index] && this.state.cards[index].props.onSwipedRight) this.state.cards[index].props.onSwipedRight(); break; case "top": this.props.onSwipedTop(index); if (this.state.cards[index] && this.state.cards[index].props.onSwipedTop) this.state.cards[index].props.onSwipedTop(); break; case "bottom": this.props.onSwipedBottom(index); if (this.state.cards[index] && 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" } } render() { const { secondCardZoom, renderNoMoreCards } = this.props; const { drag, dragDistance, cardA, cardB, topCard, sindex } = 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", }); return ( {renderNoMoreCards()} {cardB} {cardA} ); } } CardStack.propTypes = { children: PropTypes.oneOfType([PropTypes.array, PropTypes.object]).isRequired, style: PropTypes.oneOfType([PropTypes.number, PropTypes.object, PropTypes.array]), cardContainerStyle: PropTypes.oneOfType([PropTypes.number, PropTypes.object, PropTypes.array]), secondCardZoom: PropTypes.number, loop: PropTypes.bool, initialIndex: PropTypes.number, 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, onSwiped: PropTypes.func, onSwipedAll: PropTypes.func, onSwipe: PropTypes.func, disableBottomSwipe: PropTypes.bool, disableLeftSwipe: PropTypes.bool, disableRightSwipe: PropTypes.bool, disableTopSwipe: PropTypes.bool, verticalSwipe: PropTypes.bool, verticalThreshold: PropTypes.number, horizontalSwipe: PropTypes.bool, horizontalThreshold: PropTypes.number, outputRotationRange: PropTypes.array, duration: PropTypes.number } CardStack.defaultProps = { style: {}, cardContainerStyle: {}, secondCardZoom: 0.95, loop: false, initialIndex: 0, renderNoMoreCards: () => { return (No More Cards) }, onSwipeStart: () => null, onSwipeEnd: () => null, onSwiped: () => { }, onSwipedLeft: () => { }, onSwipedRight: () => { }, onSwipedTop: () => { }, onSwipedBottom: () => { }, onSwipedAll: async () => { }, onSwipe: () => { }, disableBottomSwipe: false, disableLeftSwipe: false, disableRightSwipe: false, disableTopSwipe: false, verticalSwipe: true, verticalThreshold: height / 4, horizontalSwipe: true, horizontalThreshold: width / 2, outputRotationRange: ["-15deg", "0deg", "15deg"], duration: 300 } export default CardStack;