@newtonry/react-native-looped-carousel
Version:
Looped carousel for React Native
437 lines (404 loc) • 12.7 kB
JavaScript
import React, { Component } from 'react';
import {
Platform,
StyleSheet,
Text,
ScrollView,
TouchableOpacity,
View,
TouchableWithoutFeedback,
} from 'react-native';
import PropTypes from 'prop-types';
import isEqual from 'lodash.isequal';
const PAGE_CHANGE_DELAY = 4000;
/**
* Animates pages in cycle
* (loop possible if children count > 1)
*/
export default class Carousel extends Component {
static propTypes = {
children: PropTypes.node.isRequired,
autoplay: PropTypes.bool,
delay: PropTypes.number,
currentPage: PropTypes.number,
style: View.propTypes.style,
pageStyle: View.propTypes.style,
contentContainerStyle: View.propTypes.style,
pageInfo: PropTypes.bool,
pageInfoBackgroundColor: PropTypes.string,
pageInfoTextStyle: Text.propTypes.style,
pageInfoBottomContainerStyle: View.propTypes.style,
pageInfoTextSeparator: PropTypes.string,
bullets: PropTypes.bool,
bulletsContainerStyle: Text.propTypes.style,
bulletStyle: Text.propTypes.style,
arrows: PropTypes.bool,
arrowsContainerStyle: Text.propTypes.style,
arrowStyle: Text.propTypes.style,
leftArrowText: PropTypes.oneOfType([
PropTypes.string,
PropTypes.element,
]),
rightArrowText: PropTypes.oneOfType([
PropTypes.string,
PropTypes.element,
]),
chosenBulletStyle: Text.propTypes.style,
onAnimateNextPage: PropTypes.func,
swipe: PropTypes.bool,
};
static defaultProps = {
delay: PAGE_CHANGE_DELAY,
autoplay: true,
pageInfo: false,
bullets: false,
arrows: false,
pageInfoBackgroundColor: 'rgba(0, 0, 0, 0.25)',
pageInfoTextSeparator: ' / ',
currentPage: 0,
style: undefined,
pageStyle: undefined,
contentContainerStyle: undefined,
pageInfoTextStyle: undefined,
pageInfoBottomContainerStyle: undefined,
bulletsContainerStyle: undefined,
chosenBulletStyle: undefined,
bulletStyle: undefined,
arrowsContainerStyle: undefined,
arrowStyle: undefined,
leftArrowText: '',
rightArrowText: '',
onAnimateNextPage: undefined,
swipe: true,
};
constructor(props) {
super(props);
const size = { width: 0, height: 0 };
if (props.children) {
const childrenLength = React.Children.count(props.children) || 1;
this.state = {
currentPage: props.currentPage,
size,
childrenLength,
contents: null,
};
} else {
this.state = { size };
}
}
componentDidMount() {
if (this.state.childrenLength) {
this._setUpTimer();
}
// Set up pages but not content. Content will be set up via onLayout event.
this._setUpPages().then(() => this.setState({ contents: this.pages }));
}
componentWillUnmount() {
this._clearTimer();
}
componentWillReceiveProps(nextProps) {
if (!isEqual(this.props.children, nextProps.children)) {
let childrenLength = 0;
this.setState({ currentPage: 0 });
if (nextProps.children) {
const length = React.Children.count(nextProps.children);
childrenLength = length || 1;
}
this.setState({ childrenLength }, () => {
this._setUpPages().then(() => this.setState({ contents: this.pages }));
});
this._setUpTimer();
}
}
_setUpPages() {
return new Promise(resolve => {
const { size } = this.state;
const children = React.Children.toArray(this.props.children);
const pages = [];
if (children && children.length > 1) {
// add all pages
for (let i = 0; i < children.length; i += 1) {
pages.push(children[i]);
}
// We want to make infinite pages structure like this: 1-2-3-1-2
// so we add first and second page again to the end
pages.push(children[0]);
pages.push(children[1]);
} else if (children) {
pages.push(children[0]);
} else {
pages.push(<View><Text>
You are supposed to add children inside Carousel
</Text></View>);
}
this.pages = pages.map((page, i) => (
<TouchableWithoutFeedback style={[{ ...size }, this.props.pageStyle]} key={`page${i}`}>
{page}
</TouchableWithoutFeedback>
));
resolve();
});
}
getCurrentPage() {
return this.state.currentPage;
}
_onScrollBegin = () => {
this._clearTimer();
}
_setCurrentPage = (currentPage) => {
this.setState(() => ({ currentPage }));
this.setState(() => { this.props.onAnimateNextPage ? this.props.onAnimateNextPage(currentPage) : false; });
}
_onScrollEnd = (event) => {
const offset = { ...event.nativeEvent.contentOffset };
const page = this._calculateCurrentPage(offset.x);
this._placeCritical(page);
this._setCurrentPage(page);
this._setUpTimer();
}
_onLayout = (event) => {
const { height, width } = event.nativeEvent.layout;
this.setState({ size: { width, height } });
// remove setTimeout wrapper when https://github.com/facebook/react-native/issues/6849 is resolved.
setTimeout(() => this._placeCritical(this.state.currentPage), 0);
}
_clearTimer = () => {
clearTimeout(this.timer);
}
_setUpTimer = () => {
// only for cycling
if (this.props.autoplay && React.Children.count(this.props.children) > 1) {
this._clearTimer();
this.timer = setTimeout(this._animateNextPage, this.props.delay);
}
}
_scrollTo = ({ offset, animated, nofix }) => {
if (this.scrollView) {
this.scrollView.scrollTo({ y: 0, x: offset, animated });
// Fix bug #50
if (!nofix && Platform.OS === 'android' && !animated) {
this.scrollView.scrollTo({ y: 0, x: offset, animated: true });
}
}
}
_animateNextPage = () => {
const { currentPage } = this.state;
this.animateToPage(this._normalizePageNumber(currentPage + 1));
}
animateToPage = (page) => {
let currentPage = page;
this._clearTimer();
const { width } = this.state.size;
const { childrenLength } = this.state;
if (currentPage >= childrenLength) {
currentPage = 0;
}
if (currentPage === 0) {
// animate properly based on direction
const scrollMultiplier = this.state.currentPage === 1 ? 1 : -1;
this._scrollTo({
offset: (childrenLength + (1 * scrollMultiplier)) * width,
animated: false,
nofix: true,
});
this._scrollTo({ offset: childrenLength * width, animated: true });
} else if (currentPage === 1) {
const scrollMultiplier = this.state.currentPage === 0 ? 0 : 2;
this._scrollTo({ offset: width * scrollMultiplier, animated: false, nofix: true });
this._scrollTo({ offset: width, animated: true });
} else {
this._scrollTo({ offset: currentPage * width, animated: true });
}
this._setCurrentPage(currentPage);
this._setUpTimer();
}
_placeCritical = (page) => {
const { childrenLength } = this.state;
const { width } = this.state.size;
if (childrenLength === 1) {
this._scrollTo({ offset: 0, animated: false });
} else if (page === 0) {
this._scrollTo({ offset: childrenLength * width, animated: false });
} else if (page === 1) {
this._scrollTo({ offset: width, animated: false });
} else {
this._scrollTo({ offset: page * width, animated: false });
}
}
_normalizePageNumber = (page) => {
const { childrenLength } = this.state;
if (page === childrenLength) {
return 0;
} else if (page >= childrenLength) {
return 1;
}
return page;
}
_calculateCurrentPage = (offset) => {
const { width } = this.state.size;
const page = Math.floor(offset / width);
return this._normalizePageNumber(page);
}
_renderPageInfo = (pageLength) =>
(<View style={[styles.pageInfoBottomContainer, this.props.pageInfoBottomContainerStyle]} pointerEvents="none">
<View style={styles.pageInfoContainer}>
<View
style={[styles.pageInfoPill, { backgroundColor: this.props.pageInfoBackgroundColor }]}
>
<Text
style={[styles.pageInfoText, this.props.pageInfoTextStyle]}
>
{`${this.state.currentPage + 1}${this.props.pageInfoTextSeparator}${pageLength}`}
</Text>
</View>
</View>
</View>)
_renderBullets = (pageLength) => {
const bullets = [];
for (let i = 0; i < pageLength; i += 1) {
bullets.push(
<TouchableWithoutFeedback onPress={() => this.animateToPage(i)} key={`bullet${i}`}>
<View
style={i === this.state.currentPage ?
[styles.chosenBullet, this.props.chosenBulletStyle] :
[styles.bullet, this.props.bulletStyle]}
/>
</TouchableWithoutFeedback>);
}
return (
<View style={styles.bullets} pointerEvents="box-none">
<View style={[styles.bulletsContainer, this.props.bulletsContainerStyle]} pointerEvents="box-none">
{bullets}
</View>
</View>
);
}
_renderArrows = () => {
let { currentPage } = this.state;
const { childrenLength } = this.state;
if (currentPage < 1) {
currentPage = childrenLength;
}
return (
<View style={styles.arrows} pointerEvents="box-none">
<View style={[styles.arrowsContainer, this.props.arrowsContainerStyle]} pointerEvents="box-none">
<TouchableOpacity onPress={() => this.animateToPage(this._normalizePageNumber(currentPage - 1))} style={this.props.arrowStyle}><Text>{this.props.leftArrowText ? this.props.leftArrowText : 'Left'}</Text></TouchableOpacity>
<TouchableOpacity onPress={() => this.animateToPage(this._normalizePageNumber(currentPage + 1))} style={this.props.arrowStyle}><Text>{this.props.rightArrowText ? this.props.rightArrowText : 'Right'}</Text></TouchableOpacity>
</View>
</View>
);
}
render() {
const { contents } = this.state;
const containerProps = {
onLayout: this._onLayout,
style: [this.props.style],
};
const { size } = this.state;
const childrenLength = React.Children.count(this.props.children);
return (
<View {...containerProps}>
<ScrollView
ref={(c) => { this.scrollView = c; }}
onScrollBeginDrag={this._onScrollBegin}
onMomentumScrollEnd={this._onScrollEnd}
alwaysBounceHorizontal={false}
alwaysBounceVertical={false}
contentInset={{ top: 0 }}
automaticallyAdjustContentInsets={false}
showsHorizontalScrollIndicator={false}
horizontal
pagingEnabled
bounces={false}
scrollEnabled={this.props.swipe}
contentContainerStyle={[
styles.horizontalScroll,
this.props.contentContainerStyle,
{
width: size.width * (childrenLength + (childrenLength > 1 ? 2 : 0)),
height: size.height,
},
]}
>
{contents}
</ScrollView>
{this.props.arrows && this._renderArrows(this.state.childrenLength)}
{this.props.bullets && this._renderBullets(this.state.childrenLength)}
{this.props.pageInfo && this._renderPageInfo(this.state.childrenLength)}
</View>
);
}
}
const styles = StyleSheet.create({
horizontalScroll: {
position: 'absolute',
},
pageInfoBottomContainer: {
position: 'absolute',
bottom: 20,
left: 0,
right: 0,
backgroundColor: 'transparent',
},
pageInfoContainer: {
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'transparent',
},
pageInfoPill: {
width: 80,
height: 20,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
},
pageInfoText: {
textAlign: 'center',
},
bullets: {
position: 'absolute',
left: 0,
right: 0,
bottom: 10,
height: 30,
backgroundColor: 'transparent',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
},
arrows: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
top: 0,
backgroundColor: 'transparent',
},
arrowsContainer: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
bulletsContainer: {
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
},
chosenBullet: {
margin: 10,
width: 10,
height: 10,
borderRadius: 20,
backgroundColor: 'white',
},
bullet: {
margin: 10,
width: 10,
height: 10,
borderRadius: 20,
backgroundColor: 'transparent',
borderColor: 'white',
borderWidth: 1,
},
});