react-smooth-slider
Version:
407 lines (336 loc) • 11.7 kB
JavaScript
import React from 'react';
import PT from 'prop-types';
import TouchHelper from './TouchHelper';
import { DIRECTION, SCROLL_AMOUNT_STR } from './constants';
class Slider extends React.Component {
static propTypes = {
children: PT.func,
offset: PT.number,
className: PT.string,
components: PT.arrayOf(PT.element),
allowVerticalScrolling: PT.bool,
scrollToPrevCard: PT.func,
scrollToNextCard: PT.func,
isScrolling: PT.func,
id: PT.string.isRequired,
scrollAmount: PT.oneOfType([
PT.number,
PT.oneOf(Object.values(SCROLL_AMOUNT_STR)),
]),
refKey: PT.string, // eslint-disable-line
};
static defaultProps = {
offset: 0,
allowVerticalScrolling: false,
scrollAmount: 'visible',
refKey: 'ref',
};
// Update cards in state from props
static getDerivedStateFromProps(props, state) {
return {
...state,
cards: Slider.createCards(props, state.cardRefs),
};
}
// Clone components from props with reference properties
static createCards(props, cardRefs) {
return props.components.map((card, i) => {
const _props = {
id: `card-${i}-${props.id}`,
[props.refKey]: cardRefs[i],
};
let elementProps = {};
// Class / functional components
if (!card.type.$$typeof) {
elementProps.cardProps = _props;
} else {
// Direct component
elementProps = _props;
}
return React.cloneElement(card, elementProps);
});
}
constructor(props) {
super(props);
if (props.children != null && typeof props.children !== 'function') {
console.error(
'Error: children prop is not a function. When using the childen prop, you have to use a render function. This function returns components passed in the components prop.',
);
}
const cardRefs = this.createCardRefs();
this.state = {
isScrolling: false,
scrollLeft: 0,
firstVisibleCardId: 0,
firstFullVisibleCardId: 0,
lastFullVisibleCardId: 0,
cardRefs,
cards: Slider.createCards(props, cardRefs),
};
this.containerRef = React.createRef();
this.scrollingEventId = null;
this.scrollTimeoutId = null;
this.touchHelper = new TouchHelper();
// Pass our scrollToNextCard func to prop func
const { scrollToPrevCard, scrollToNextCard } = props;
if (scrollToPrevCard) {
scrollToPrevCard(this.scrollToCard(DIRECTION.PREV));
} else {
this.scrollToCard(DIRECTION.PREV);
}
if (scrollToNextCard) {
scrollToNextCard(this.scrollToCard(DIRECTION.NEXT));
} else {
this.scrollToCard(DIRECTION.NEXT);
}
}
componentDidMount() {
this.setVisibleCardIds().then(() => {
// Set scroll amount to props or visible amount
this.setState((prevState, props) => {
if (typeof props.scrollAmount === 'number') {
return {
scrollAmount: props.scrollAmount,
};
}
return {
scrollAmount: prevState.lastFullVisibleCardId - prevState.firstVisibleCardId + 1,
};
});
});
// Disable default touchmove behaviour on iOS
this.containerRef.current.addEventListener('touchmove', this.onTouchMove, { passive: false });
}
componentDidUpdate(prevProps, prevState) {
const { components: prevComponents } = prevProps;
const { components } = this.props;
// Pass new isScrolling state to prop func
if (this.props.isScrolling && prevState.isScrolling !== this.state.isScrolling) {
this.props.isScrolling(this.state.isScrolling);
}
if (prevComponents.length !== components.length) {
const cardRefs = this.createCardRefs();
// we need to call this.createCardRefs() and this is not possible
// in a static method like getDerivedStateFromProps()
// eslint-disable-next-line react/no-did-update-set-state
this.setState({
cardRefs,
cards: Slider.createCards({ ...this.props, components }, cardRefs),
});
}
}
// eslint-disable-next-line react/sort-comp
setVisibleCardIds = () => new Promise((resolve) => {
if (this.touchHelper.isScrolling) return;
const { cardRefs } = this.state;
const { offset } = this.props;
const containerEl = this.containerRef.current;
const containerRect = containerEl.getBoundingClientRect();
const containerWidth = containerEl.clientWidth + containerRect.left;
const visibleCardIds = cardRefs.reduce((all, card, id) => {
const cardEl = card.current;
if (!cardEl) return all;
const cardRect = cardEl.getBoundingClientRect();
const cardX = cardRect.left + offset;
const cardX2 = cardX + cardRect.width;
// Check if card is in view
if (
(cardX < 0 && cardX2 > 0)
|| (cardX >= 0 && cardX2 <= containerWidth)
|| (cardX >= 0 && cardX < containerWidth && cardX2 >= containerWidth)
) {
return [...all, id];
}
return all;
}, []);
const cardRefRect = id => cardRefs[id].current.getBoundingClientRect();
const lastCardId = visibleCardIds[visibleCardIds.length - 1];
const firstCardId = visibleCardIds[0] || Math.min(0, lastCardId - 1);
const lastCardPos = cardRefRect(lastCardId);
const firstCardPos = cardRefRect(firstCardId);
// Cards that are at least 90% visible count as fully visible
const threshold = 0.1 * lastCardPos.width;
const lastFullVisibleCardId =
lastCardPos.left >= 0
&& lastCardPos.right <= containerWidth + threshold
? lastCardId
: Math.max(0, lastCardId - 1);
const firstFullVisibleCardId =
firstCardPos.left >= 0
&& firstCardPos.right <= containerWidth + threshold
? firstCardId
: Math.min(firstCardId + 1, cardRefs.length - 1);
this.setState({
firstVisibleCardId: firstCardId,
lastFullVisibleCardId, // eslint-disable-line
firstFullVisibleCardId, // eslint-disable-line
}, () => {
resolve();
});
})
// eslint-disable-next-line
onMousewheel = (e) => {
if (!this.containerRef) {
return;
}
const { scrollLeft } = this.state;
const { allowVerticalScrolling } = this.props;
const { scrollWidth, clientWidth } = this.containerRef.current;
const delta = allowVerticalScrolling ? e.deltaY + e.deltaX : e.deltaX;
const containerWidth = scrollWidth - clientWidth;
let x = scrollLeft;
// Set next scroll location
if (x + delta >= 0 && x + delta <= containerWidth) {
x = scrollLeft + delta;
}
if (x - delta < 0) {
x = 0;
}
requestAnimationFrame(() => {
this.setVisibleCardIds();
if (x !== scrollLeft) {
this.setState(
{
// isScrolling: true, // need fix: isScrolling to false if done scrolling or press prev/next button
scrollLeft: x,
},
() => {
this.containerRef.current.scrollLeft = this.state.scrollLeft;
},
);
}
});
};
onTouchMove = (e) => {
if (e.touches.length >= 2 && e.scale !== 1) {
e.preventDefault();
}
requestAnimationFrame(() => {
this.setVisibleCardIds();
this.setState({
scrollLeft: this.containerRef.current.scrollLeft,
});
if (!this.touchHelper.swiped) {
this.touchHelper.swiped = true;
this.forceUpdate();
}
this.touchHelper.onTouchMove(e);
});
};
onTouchEnd = () => {
const { x: startX, y: startY } = this.touchHelper.start;
const { x: endX, y: endY } = this.touchHelper.end;
const deltaX = startX - endX;
const deltaY = startY - endY;
if (Math.abs(deltaX) >= Math.abs(deltaY)) {
if (deltaX < 0) {
this.scrollToCard(DIRECTION.PREV)();
} else if (deltaX > 0) {
this.scrollToCard(DIRECTION.NEXT)();
}
}
this.touchHelper.isScrolling = false;
};
createCardRefs = () =>
Array.from({ length: this.props.components.length }).map(() => React.createRef());
stopScroll = () => {
this.setVisibleCardIds().then(() => {
this.setState({ isScrolling: false });
this.touchHelper.onTouchEnd();
});
};
scrollToX = (targetX, duration = 300) => {
const startX = this.state.scrollLeft;
const distance = Math.max(0, targetX) - startX;
const startTime = new Date().getTime();
const container = this.containerRef.current;
const containerWidth = container.getBoundingClientRect().width;
duration = duration || Math.min(Math.abs(distance), 300);
const loopScroll = () => {
this.scrollTimeoutId = setTimeout(() => {
// Scroll percentage
const p = Math.min(1, (new Date().getTime() - startTime) / duration);
// Current position
let x = Math.max(
0,
// eslint-disable-next-line no-mixed-operators
Math.floor(startX + distance * (p < 0.5 ? 2 * p * p : p * (4 - p * 2) - 1)),
);
const maxScrollX = container.scrollWidth - container.clientWidth;
if (x > maxScrollX) {
x = maxScrollX;
}
requestAnimationFrame(() => {
this.setState({ isScrolling: true, scrollLeft: x }, () => {
this.containerRef.current.scrollLeft = this.state.scrollLeft;
if (p < 1 && containerWidth + x < container.scrollWidth) {
loopScroll();
} else {
this.stopScroll();
}
});
});
}, 9);
};
loopScroll();
};
scrollToCard = direction => () => {
const {
scrollLeft,
firstVisibleCardId,
firstFullVisibleCardId,
cards,
} = this.state;
const { offset, id } = this.props;
const container = this.containerRef.current;
const containerRect = container.getBoundingClientRect();
if (direction === DIRECTION.PREV && firstVisibleCardId === 0 && scrollLeft === 0) {
return;
}
const isNext = direction === DIRECTION.NEXT;
const nextCardId = isNext
? Math.min(cards.length - 1, firstFullVisibleCardId + this.state.scrollAmount)
: Math.max(0, firstFullVisibleCardId - this.state.scrollAmount);
if (nextCardId == null || Number.isNaN(nextCardId)) {
console.error('Error: Invalid card id', nextCardId);
return this.setVisibleCardIds().then(() => this.scrollToCard(direction));
}
const card = document.getElementById(`card-${nextCardId}-${id}`);
const cardRect = card.getBoundingClientRect();
let targetX = (scrollLeft + cardRect.left + offset) - containerRect.left;
// Scroll to boundaries of first or last card
if (nextCardId === 0) {
targetX = 0;
}
if (nextCardId === cards.length - 1) {
targetX = container.scrollWidth - container.clientWidth;
}
// Scrolling resets the X position of the current card to 0, because they slide to the left.
// Scrolling position does not reset. This means we have to add
// our current scroll X to the X position of the next card to reach the next card
this.scrollToX(targetX);
};
render() {
const { cards, isScrolling } = this.state;
const { className } = this.props;
const children = this.props.children
? this.props.children({
components: cards,
swiped: this.touchHelper.swiped,
isScrolling,
})
: cards;
return (
<div
className={className}
onWheel={this.onMousewheel}
onTouchStart={this.touchHelper.onTouchStart}
onTouchEnd={this.onTouchEnd}
ref={this.containerRef}
>
{children}
</div>
);
}
}
export default Slider;