UNPKG

react-smooth-slider

Version:
407 lines (336 loc) 11.7 kB
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;