UNPKG

react-native-swipe-cards

Version:
549 lines (451 loc) 15.7 kB
/* Gratefully copied from https://github.com/brentvatne/react-native-animated-demo-tinder */ 'use strict'; import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { StyleSheet, Text, View, Animated, PanResponder, Dimensions, Image } from 'react-native'; import clamp from 'clamp'; import Defaults from './Defaults.js'; const viewport = Dimensions.get('window') const SWIPE_THRESHOLD = 120; const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: 'transparent', }, yup: { borderColor: 'green', borderWidth: 2, position: 'absolute', padding: 20, bottom: 20, borderRadius: 5, right: 0, }, yupText: { fontSize: 16, color: 'green', }, maybe: { borderColor: 'blue', borderWidth: 2, position: 'absolute', padding: 20, bottom: 20, borderRadius: 5, right: 20, }, maybeText: { fontSize: 16, color: 'blue', }, nope: { borderColor: 'red', borderWidth: 2, position: 'absolute', bottom: 20, padding: 20, borderRadius: 5, left: 0, }, nopeText: { fontSize: 16, color: 'red', } }); //Components could be unloaded and loaded and we will loose the users currentIndex, we can persist it here. let currentIndex = {}; let guid = 0; export default class SwipeCards extends Component { static propTypes = { cards: PropTypes.array, cardKey: PropTypes.string, hasMaybeAction: PropTypes.bool, loop: PropTypes.bool, onLoop: PropTypes.func, allowGestureTermination: PropTypes.bool, stack: PropTypes.bool, stackGuid: PropTypes.string, stackDepth: PropTypes.number, stackOffsetX: PropTypes.number, stackOffsetY: PropTypes.number, renderNoMoreCards: PropTypes.func, showYup: PropTypes.bool, showMaybe: PropTypes.bool, showNope: PropTypes.bool, handleYup: PropTypes.func, handleMaybe: PropTypes.func, handleNope: PropTypes.func, yupText: PropTypes.string, yupView: PropTypes.element, maybeText: PropTypes.string, maybeView: PropTypes.element, nopeText: PropTypes.string, noView: PropTypes.element, onClickHandler: PropTypes.func, renderCard: PropTypes.func, cardRemoved: PropTypes.func, dragY: PropTypes.bool, smoothTransition: PropTypes.bool }; static defaultProps = { cards: [], cardKey: 'key', hasMaybeAction: false, loop: false, onLoop: () => null, allowGestureTermination: true, stack: false, stackDepth: 5, stackOffsetX: 25, stackOffsetY: 0, showYup: true, showMaybe: true, showNope: true, handleYup: (card) => null, handleMaybe: (card) => null, handleNope: (card) => null, nopeText: "Nope!", maybeText: "Maybe!", yupText: "Yup!", onClickHandler: () => { alert('tap') }, onDragStart: () => {}, onDragRelease: () => {}, cardRemoved: (ix) => null, renderCard: (card) => null, style: styles.container, dragY: true, smoothTransition: false }; constructor(props) { super(props); //Use a persistent variable to track currentIndex instead of a local one. this.guid = this.props.guid || guid++; if (!currentIndex[this.guid]) currentIndex[this.guid] = 0; this.state = { pan: new Animated.ValueXY(0), enter: new Animated.Value(0.5), cards: [].concat(this.props.cards), card: this.props.cards[currentIndex[this.guid]], }; this.lastX = 0; this.lastY = 0; this.cardAnimation = null; this._panResponder = PanResponder.create({ onMoveShouldSetPanResponderCapture: (e, gestureState) => { if (Math.abs(gestureState.dx) > 3 || Math.abs(gestureState.dy) > 3) { this.props.onDragStart(); return true; } return false; }, onPanResponderGrant: (e, gestureState) => { this.state.pan.setOffset({ x: this.state.pan.x._value, y: this.state.pan.y._value }); this.state.pan.setValue({ x: 0, y: 0 }); }, onPanResponderTerminationRequest: (evt, gestureState) => this.props.allowGestureTermination, onPanResponderMove: Animated.event([ null, { dx: this.state.pan.x, dy: this.props.dragY ? this.state.pan.y : 0 }, ]), onPanResponderRelease: (e, {vx, vy, dx, dy}) => { this.props.onDragRelease() this.state.pan.flattenOffset(); let velocity; if (Math.abs(dx) <= 5 && Math.abs(dy) <= 5) //meaning the gesture did not cover any distance { this.props.onClickHandler(this.state.card) } if (vx > 0) { velocity = clamp(vx, 3, 5); } else if (vx < 0) { velocity = clamp(vx * -1, 3, 5) * -1; } else { velocity = dx < 0 ? -3 : 3; } const hasSwipedHorizontally = Math.abs(this.state.pan.x._value) > SWIPE_THRESHOLD const hasSwipedVertically = Math.abs(this.state.pan.y._value) > SWIPE_THRESHOLD if (hasSwipedHorizontally || (hasSwipedVertically && this.props.hasMaybeAction)) { let cancelled = false; const hasMovedRight = hasSwipedHorizontally && this.state.pan.x._value > 0 const hasMovedLeft = hasSwipedHorizontally && this.state.pan.x._value < 0 const hasMovedUp = hasSwipedVertically && this.state.pan.y._value < 0 if (hasMovedRight) { cancelled = this.props.handleYup(this.state.card); } else if (hasMovedLeft) { cancelled = this.props.handleNope(this.state.card); } else if (hasMovedUp && this.props.hasMaybeAction) { cancelled = this.props.handleMaybe(this.state.card); } else { cancelled = true } //Yup or nope was cancelled, return the card to normal. if (cancelled) { this._resetPan(); return; }; this.props.cardRemoved(currentIndex[this.guid]); if (this.props.smoothTransition) { this._advanceState(); } else { this.cardAnimation = Animated.decay(this.state.pan, { velocity: { x: velocity, y: vy }, deceleration: 0.98 }); this.cardAnimation.start(status => { if (status.finished) this._advanceState(); else this._resetState(); this.cardAnimation = null; } ); } } else { this._resetPan(); } } }); } _forceLeftSwipe() { this.cardAnimation = Animated.timing(this.state.pan, { toValue: { x: -500, y: 0 }, }).start(status => { if (status.finished) this._advanceState(); else this._resetState(); this.cardAnimation = null; } ); this.props.cardRemoved(currentIndex[this.guid]); } _forceUpSwipe() { this.cardAnimation = Animated.timing(this.state.pan, { toValue: { x: 0, y: 500 }, }).start(status => { if (status.finished) this._advanceState(); else this._resetState(); this.cardAnimation = null; } ); this.props.cardRemoved(currentIndex[this.guid]); } _forceRightSwipe() { this.cardAnimation = Animated.timing(this.state.pan, { toValue: { x: 500, y: 0 }, }).start(status => { if (status.finished) this._advanceState(); else this._resetState(); this.cardAnimation = null; } ); this.props.cardRemoved(currentIndex[this.guid]); } _goToNextCard() { currentIndex[this.guid]++; // Checks to see if last card. // If props.loop=true, will start again from the first card. if (currentIndex[this.guid] > this.state.cards.length - 1 && this.props.loop) { this.props.onLoop(); currentIndex[this.guid] = 0; } this.setState({ card: this.state.cards[currentIndex[this.guid]] }); } _goToPrevCard() { this.state.pan.setValue({ x: 0, y: 0 }); this.state.enter.setValue(0); this._animateEntrance(); currentIndex[this.guid]--; if (currentIndex[this.guid] < 0) { currentIndex[this.guid] = 0; } this.setState({ card: this.state.cards[currentIndex[this.guid]] }); } componentDidMount() { this._animateEntrance(); } _animateEntrance() { Animated.spring( this.state.enter, { toValue: 1, friction: 8 } ).start(); } componentWillReceiveProps(nextProps) { if (nextProps.cards !== this.props.cards) { if (this.cardAnimation) { this.cardAnimation.stop(); this.cardAnimation = null; } currentIndex[this.guid] = 0; this.setState({ cards: [].concat(nextProps.cards), card: nextProps.cards[0] }); } } _resetPan() { Animated.spring(this.state.pan, { toValue: { x: 0, y: 0 }, friction: 4 }).start(); } _resetState() { this.state.pan.setValue({ x: 0, y: 0 }); this.state.enter.setValue(0); this._animateEntrance(); } _advanceState() { this.state.pan.setValue({ x: 0, y: 0 }); this.state.enter.setValue(0); this._animateEntrance(); this._goToNextCard(); } /** * Returns current card object */ getCurrentCard() { return this.state.cards[currentIndex[this.guid]]; } renderNoMoreCards() { if (this.props.renderNoMoreCards) { return this.props.renderNoMoreCards(); } return <Defaults.NoMoreCards />; } /** * Renders the cards as a stack with props.stackDepth cards deep. */ renderStack() { if (!this.state.card) { return this.renderNoMoreCards(); } //Get the next stack of cards to render. let cards = this.state.cards.slice(currentIndex[this.guid], currentIndex[this.guid] + this.props.stackDepth).reverse(); return cards.map((card, i) => { let offsetX = this.props.stackOffsetX * cards.length - i * this.props.stackOffsetX; let lastOffsetX = offsetX + this.props.stackOffsetX; let offsetY = this.props.stackOffsetY * cards.length - i * this.props.stackOffsetY; let lastOffsetY = offsetY + this.props.stackOffsetY; let opacity = 0.25 + (0.75 / cards.length) * (i + 1); let lastOpacity = 0.25 + (0.75 / cards.length) * i; let scale = 0.85 + (0.15 / cards.length) * (i + 1); let lastScale = 0.85 + (0.15 / cards.length) * i; let style = { position: 'absolute', top: this.state.enter.interpolate({ inputRange: [0, 1], outputRange: [lastOffsetY, offsetY] }), left: this.state.enter.interpolate({ inputRange: [0, 1], outputRange: [lastOffsetX, offsetX] }), opacity: this.props.smoothTransition ? 1 : this.state.enter.interpolate({ inputRange: [0, 1], outputRange: [lastOpacity, opacity] }), transform: [{ scale: this.state.enter.interpolate({ inputRange: [0, 1], outputRange: [lastScale, scale] }) }], elevation: i * 10 }; //Is this the top card? If so animate it and hook up the pan handlers. if (i + 1 === cards.length) { let {pan} = this.state; let [translateX, translateY] = [pan.x, pan.y]; let rotate = pan.x.interpolate({ inputRange: [-200, 0, 200], outputRange: ["-30deg", "0deg", "30deg"] }); let opacity = this.props.smoothTransition ? 1 : pan.x.interpolate({ inputRange: [-200, 0, 200], outputRange: [0.5, 1, 0.5] }); let animatedCardStyles = { ...style, transform: [ { translateX: translateX }, { translateY: translateY }, { rotate: rotate }, { scale: this.state.enter.interpolate({ inputRange: [0, 1], outputRange: [lastScale, scale] }) } ] }; return <Animated.View key={card[this.props.cardKey]} style={[styles.card, animatedCardStyles]} {... this._panResponder.panHandlers}> {this.props.renderCard(this.state.card)} </Animated.View>; } return <Animated.View key={card[this.props.cardKey]} style={style}>{this.props.renderCard(card)}</Animated.View>; }); } renderCard() { if (!this.state.card) { return this.renderNoMoreCards(); } let {pan, enter} = this.state; let [translateX, translateY] = [pan.x, pan.y]; let rotate = pan.x.interpolate({ inputRange: [-200, 0, 200], outputRange: ["-30deg", "0deg", "30deg"] }); let opacity = pan.x.interpolate({ inputRange: [-200, 0, 200], outputRange: [0.5, 1, 0.5] }); let scale = enter; let animatedCardStyles = { transform: [{ translateX }, { translateY }, { rotate }, { scale }], opacity }; return <Animated.View key={"top"} style={[styles.card, animatedCardStyles]} {... this._panResponder.panHandlers}> {this.props.renderCard(this.state.card)} </Animated.View>; } renderNope() { let {pan} = this.state; let nopeOpacity = pan.x.interpolate({ inputRange: [-SWIPE_THRESHOLD, -(SWIPE_THRESHOLD/2)], outputRange: [1, 0], extrapolate: 'clamp' }); let nopeScale = pan.x.interpolate({ inputRange: [-SWIPE_THRESHOLD, 0], outputRange: [1, 0], extrapolate: 'clamp' }); let animatedNopeStyles = { transform: [{ scale: nopeScale }], opacity: nopeOpacity }; if (this.props.renderNope) { return this.props.renderNope(pan); } if (this.props.showNope) { const inner = this.props.noView ? this.props.noView : <Text style={[styles.nopeText, this.props.nopeTextStyle]}>{this.props.nopeText}</Text> return <Animated.View style={[styles.nope, this.props.nopeStyle, animatedNopeStyles]}> {inner} </Animated.View>; } return null; } renderMaybe() { if (!this.props.hasMaybeAction) return null; let {pan} = this.state; let maybeOpacity = pan.y.interpolate({ inputRange: [-SWIPE_THRESHOLD, -(SWIPE_THRESHOLD/2)], outputRange: [1, 0], extrapolate: 'clamp' }); let maybeScale = pan.x.interpolate({ inputRange: [-SWIPE_THRESHOLD, 0, SWIPE_THRESHOLD], outputRange: [0, 1, 0], extrapolate: 'clamp' }); let animatedMaybeStyles = { transform: [{ scale: maybeScale }], opacity: maybeOpacity }; if (this.props.renderMaybe) { return this.props.renderMaybe(pan); } if (this.props.showMaybe) { const inner = this.props.maybeView ? this.props.maybeView : <Text style={[styles.maybeText, this.props.maybeTextStyle]}>{this.props.maybeText}</Text> return <Animated.View style={[styles.maybe, this.props.maybeStyle, animatedMaybeStyles]}> {inner} </Animated.View>; } return null; } renderYup() { let {pan} = this.state; let yupOpacity = pan.x.interpolate({ inputRange: [(SWIPE_THRESHOLD/2), SWIPE_THRESHOLD], outputRange: [0, 1], extrapolate: 'clamp' }); let yupScale = pan.x.interpolate({ inputRange: [0, SWIPE_THRESHOLD], outputRange: [0.5, 1], extrapolate: 'clamp' }); let animatedYupStyles = { transform: [{ scale: yupScale }], opacity: yupOpacity }; if (this.props.renderYup) { return this.props.renderYup(pan); } if (this.props.showYup) { const inner = this.props.yupView ? this.props.yupView : <Text style={[styles.yupText, this.props.yupTextStyle]}>{this.props.yupText}</Text>; return <Animated.View style={[styles.yup, this.props.yupStyle, animatedYupStyles]}> {inner} </Animated.View>; } return null; } render() { return ( <View style={styles.container}> {this.props.stack ? this.renderStack() : this.renderCard()} {this.renderNope()} {this.renderMaybe()} {this.renderYup()} </View> ); } }