react-native-swipe-cards-deck
Version:
Swipe cards for stylishly passing & failing a card
627 lines (551 loc) • 17.3 kB
JavaScript
"use strict";
import React, { Component } from "react";
import PropTypes from "prop-types";
import { View, Animated, PanResponder, ViewPropTypes } from "react-native";
import Defaults from "./Defaults";
import clamp from "clamp";
import { styles } from "./Styles";
//Components could be unloaded and loaded and we will loose the users currentIndex, we can persist it here.
let currentIndex = {};
let guid = 0;
const actionShape = PropTypes.shape({
show: PropTypes.bool,
view: PropTypes.element, // takes priority over text + color
containerStyle: ViewPropTypes.style,
textStyle: ViewPropTypes.style,
text: PropTypes.string,
color: PropTypes.string,
onAction: PropTypes.func, // triggered on action, given card data, must return true if success
});
const defaultActionsProp = {
yup: { show: true, text: "Yup!", color: "green" },
nope: { show: true, text: "Nope!", color: "red" },
maybe: { show: true, text: "Maybe!", color: "orange" },
};
const mergeActionProps = (actionsProps) => ({
yup: { ...defaultActionsProp.yup, ...actionsProps.yup },
nope: { ...defaultActionsProp.nope, ...actionsProps.nope },
maybe: { ...defaultActionsProp.maybe, ...actionsProps.maybe },
});
export default class SwipeCards extends Component {
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),
panResetAnime: null,
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.mergedActionsProps = mergeActionProps(this.props.actions);
this.cardAnimation = null;
this._panResponder = PanResponder.create({
onMoveShouldSetPanResponderCapture: (e, gestureState) => {
if (Math.abs(gestureState.dx) > 3 || Math.abs(gestureState.dy) > 3) {
if (this.props.onDragStart) 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 : new Animated.Value(0),
},
],
{ useNativeDriver: false }
),
onPanResponderRelease: async (e, { vx, vy, dx, dy }) => {
if (this.props.onDragRelease) 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
if (this.props.onClickHandler)
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) > this.props.swipeThreshold;
const hasSwipedVertically =
Math.abs(this.state.pan.y._value) > this.props.swipeThreshold;
if (
hasSwipedHorizontally ||
(hasSwipedVertically && this.props.hasMaybeAction)
) {
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;
let cancelled = false;
if (hasMovedRight && this.mergedActionsProps.yup.onAction) {
cancelled = !(await this.mergedActionsProps.yup.onAction(
this.state.card
));
} else if (hasMovedLeft && this.mergedActionsProps.nope.onAction) {
cancelled = !(await this.mergedActionsProps.nope.onAction(
this.state.card
));
} else if (
hasMovedUp &&
this.props.hasMaybeAction &&
this.mergedActionsProps.maybe.onAction
) {
cancelled = !(await this.mergedActionsProps.maybe.onAction(
this.state.card
));
}
//Yup or nope was cancelled, return the card to normal.
if (cancelled) {
this._resetPan();
return;
}
if (this.props.cardRemoved)
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,
useNativeDriver: true,
});
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 },
useNativeDriver: true,
}).start((status) => {
if (status.finished) this._advanceState();
else this._resetState();
this.cardAnimation = null;
});
if (this.props.cardRemoved) this.props.cardRemoved(currentIndex[this.guid]);
}
_forceUpSwipe() {
this.cardAnimation = Animated.timing(this.state.pan, {
toValue: { x: 0, y: -500 },
useNativeDriver: true,
}).start((status) => {
if (status.finished) this._advanceState();
else this._resetState();
this.cardAnimation = null;
});
if (this.props.cardRemoved) this.props.cardRemoved(currentIndex[this.guid]);
}
_forceRightSwipe() {
this.cardAnimation = Animated.timing(this.state.pan, {
toValue: { x: 500, y: 0 },
useNativeDriver: true,
}).start((status) => {
if (status.finished) this._advanceState();
else this._resetState();
this.cardAnimation = null;
});
if (this.props.cardRemoved) this.props.cardRemoved(currentIndex[this.guid]);
}
swipeMaybe = () => this._forceUpSwipe();
swipeYup = () => this._forceRightSwipe();
swipeNope = () => this._forceLeftSwipe();
_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
) {
if (this.props.onLoop) 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,
useNativeDriver: true,
}).start();
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.cards !== this.props.cards) {
if (this.cardAnimation) {
this.cardAnimation.stop();
this.cardAnimation = null;
}
currentIndex[this.guid] = 0;
this.setState({
cards: [].concat(this.props.cards),
card: this.props.cards[0],
});
this._resetState();
}
if (prevProps.actions !== this.props.actions) {
this.mergedActionsProps = mergeActionProps(this.props.actions);
}
}
_resetPan() {
if (this.state.panResetAnime) this.state.panResetAnime.reset();
const anime = Animated.spring(this.state.pan, {
toValue: { x: 0, y: 0 },
friction: 4,
useNativeDriver: true,
});
this.state.panResetAnime = anime;
anime.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() {
return this.props.renderNoMoreCards() || <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",
opacity: this.props.smoothTransition
? 1
: this.state.enter.interpolate({
inputRange: [0, 1],
outputRange: [lastOpacity, opacity],
}),
elevation: i * 10,
},
{
transform: [
{
translateY: this.state.enter.interpolate({
inputRange: [0, 1],
outputRange: [lastOffsetY, offsetY],
}),
},
{
translateX: this.state.enter.interpolate({
inputRange: [0, 1],
outputRange: [lastOffsetX, offsetX],
}),
},
{
scale: this.state.enter.interpolate({
inputRange: [0, 1],
outputRange: [lastScale, scale],
}),
},
],
},
];
//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,
elevation: i * 10,
transform: [
{ translateX: translateX },
{ translateY: translateY },
{ rotate: rotate },
{
scale: this.state.enter.interpolate({
inputRange: [0, 1],
outputRange: [lastScale, scale],
}),
},
],
};
return (
<Animated.View
key={
this.props.keyExtractor
? this.props.keyExtractor(card)
: String(i)
}
style={[styles.card, animatedCardStyles]}
{...this._panResponder.panHandlers}
>
{this.props.renderCard(this.state.card)}
</Animated.View>
);
}
return (
<Animated.View
key={
this.props.keyExtractor ? this.props.keyExtractor(card) : String(i)
}
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={
this.props.keyExtractor
? this.props.keyExtractor(this.state.card)
: undefined
}
style={[styles.card, animatedCardStyles]}
{...this._panResponder.panHandlers}
>
{this.props.renderCard(this.state.card)}
</Animated.View>
);
}
renderAction(opacity, scale, props) {
let animatedStyles = {
transform: [{ scale: scale }],
opacity: opacity,
};
return (
<Animated.View
style={[
styles.action,
{ borderColor: props.color },
animatedStyles,
props.containerStyle,
]}
>
{props.view ? (
props.view
) : (
<Defaults.ActionView
text={props.text}
color={props.color}
style={props.textStyle}
/>
)}
</Animated.View>
);
}
renderNope() {
let { pan } = this.state;
let nopeOpacity = pan.x.interpolate({
inputRange: [
-this.props.swipeThreshold,
-(this.props.swipeThreshold / 2),
],
outputRange: [1, 0],
extrapolate: "clamp",
});
let nopeScale = pan.x.interpolate({
inputRange: [-this.props.swipeThreshold, 0],
outputRange: [1, 0],
extrapolate: "clamp",
});
return this.renderAction(
nopeOpacity,
nopeScale,
this.mergedActionsProps.nope
);
}
renderMaybe() {
let { pan } = this.state;
let maybeOpacity = pan.y.interpolate({
inputRange: [
-this.props.swipeThreshold,
-(this.props.swipeThreshold / 2),
],
outputRange: [1, 0],
extrapolate: "clamp",
});
let maybeScale = pan.x.interpolate({
inputRange: [-this.props.swipeThreshold, 0, this.props.swipeThreshold],
outputRange: [0, 1, 0],
extrapolate: "clamp",
});
return this.renderAction(
maybeOpacity,
maybeScale,
this.mergedActionsProps.maybe
);
}
renderYup() {
let { pan } = this.state;
let yupOpacity = pan.x.interpolate({
inputRange: [this.props.swipeThreshold / 2, this.props.swipeThreshold],
outputRange: [0, 1],
extrapolate: "clamp",
});
let yupScale = pan.x.interpolate({
inputRange: [0, this.props.swipeThreshold],
outputRange: [0.5, 1],
extrapolate: "clamp",
});
return this.renderAction(yupOpacity, yupScale, this.mergedActionsProps.yup);
}
render() {
return (
<View style={[styles.container, this.props.style]}>
{this.props.stack ? this.renderStack() : this.renderCard()}
{this.mergedActionsProps.nope.show && this.renderNope()}
{this.props.hasMaybeAction &&
this.mergedActionsProps.maybe.show &&
this.renderMaybe()}
{this.mergedActionsProps.yup.show && this.renderYup()}
</View>
);
}
}
SwipeCards.propTypes = {
cards: PropTypes.array.isRequired,
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,
actions: PropTypes.shape({
yup: actionShape,
nope: actionShape,
maybe: actionShape,
}),
onClickHandler: PropTypes.func,
onDragStart: PropTypes.func,
onDragRelease: PropTypes.func,
cardRemoved: PropTypes.func,
renderCard: PropTypes.func.isRequired,
style: ViewPropTypes.style,
dragY: PropTypes.bool,
smoothTransition: PropTypes.bool,
keyExtractor: PropTypes.func.isRequired,
swipeThreshold: PropTypes.number,
};
SwipeCards.defaultProps = {
cards: [],
hasMaybeAction: false,
loop: false,
allowGestureTermination: true,
stack: false,
stackDepth: 5,
stackOffsetX: 25,
stackOffsetY: 0,
actions: defaultActionsProp,
dragY: true,
smoothTransition: false,
swipeThreshold: 120,
};