pinar
Version:
Customizable, lightweight React Native carousel component with accessibility support
452 lines (451 loc) • 20.6 kB
JavaScript
import React from "react";
import { Dimensions, Platform, ScrollView, StyleSheet, Text, TouchableOpacity, View, } from "react-native";
import { defaultStyles } from "./styles";
const defaultScrollViewProps = {
horizontal: true,
pagingEnabled: true,
bounces: false,
showsHorizontalScrollIndicator: false,
showsVerticalScrollIndicator: false,
scrollsToTop: false,
removeClippedSubviews: true,
automaticallyAdjustContentInsets: false,
scrollEventThrottle: 16,
scrollEnabled: true,
};
const defaultCarouselProps = {
showsControls: true,
showsDots: true,
autoplay: false,
autoplayInterval: 3000,
accessibility: true,
accessibilityLabelPrev: "Previous",
accessibilityLabelNext: "Next",
index: 0,
mergeStyles: false,
};
const styles = StyleSheet.create(defaultStyles);
class Pinar extends React.PureComponent {
constructor(props) {
super(props);
this.autoplayTimer = 0;
this.autoplay = () => {
const { isAutoplayEnd, isScrolling } = this.internals;
if (isScrolling || isAutoplayEnd) {
return;
}
const { autoplayInterval } = this.props;
clearTimeout(this.autoplayTimer);
this.autoplayTimer = setTimeout(() => {
const { loop } = this.props;
const { total, activePageIndex } = this.state;
if (!loop && activePageIndex === total - 1) {
this.internals.isAutoplayEnd = true;
}
else {
this.scrollToNext();
}
}, autoplayInterval);
};
this.getCarouselDimensions = () => {
const { height, width } = this.props;
const dimensions = Dimensions.get("window");
return {
height: height !== undefined ? height : dimensions.height,
width: width !== undefined ? width : dimensions.width,
};
};
this.onScrollBeginDrag = (_) => {
this.internals.isScrolling = true;
};
this.onScrollEndDrag = (e) => {
const { contentOffset } = e.nativeEvent;
const { horizontal } = this.props;
const { activePageIndex, total } = this.state;
const { offset } = this.internals;
const previousOffset = horizontal ? offset.x : offset.y;
const newOffset = horizontal ? contentOffset.x : contentOffset.y;
const isFirstPage = activePageIndex === 0;
const isLastPage = activePageIndex === total - 1;
if (previousOffset === newOffset && (isFirstPage || isLastPage)) {
this.internals.isScrolling = false;
}
};
this.onScroll = (e) => {
const { onScroll } = this.props;
if (typeof onScroll === "function") {
onScroll(e);
}
if (Platform.OS === "android") {
const { horizontal } = this.props;
const { x, y } = e.nativeEvent.contentOffset;
const offset = horizontal ? Math.floor(x) : Math.floor(y);
if (offset === this.internals.onScrollEndCallbackTargetOffset) {
this.onMomentumScrollEnd(e);
}
}
if (Platform.OS === "ios" && !this.internals.isAnimatedScroll) {
this.internals.isAnimatedScroll = true;
this.onMomentumScrollEnd(e);
}
};
this.onMomentumScrollEnd = (e) => {
const { onMomentumScrollEnd } = this.props;
if (typeof onMomentumScrollEnd === "function") {
onMomentumScrollEnd(e);
}
this.internals.isScrolling = false;
const { activePageIndex, total, height, width } = this.state;
const { horizontal, onIndexChanged, loop } = this.props;
const offset = e.nativeEvent.contentOffset;
const dir = horizontal ? "x" : "y";
const step = dir === "x" ? width : height;
const diff = offset[dir] - this.internals.offset[dir];
if (!diff)
return;
const nextActivePageIndex = Math.floor(activePageIndex + Math.round(diff / step));
if (nextActivePageIndex === activePageIndex) {
return;
}
const isIndexSmallerThanFirstPageIndex = nextActivePageIndex <= -1;
const isIndexBiggerThanLastPageIndex = nextActivePageIndex >= total;
const needsToUpdateOffset = isIndexSmallerThanFirstPageIndex || isIndexBiggerThanLastPageIndex;
const newState = { activePageIndex: nextActivePageIndex };
if (loop) {
if (isIndexSmallerThanFirstPageIndex) {
newState.activePageIndex = total - 1;
offset[dir] = step * total;
}
else if (isIndexBiggerThanLastPageIndex) {
newState.activePageIndex = 0;
offset[dir] = step;
}
}
this.internals.offset = offset;
if (typeof onIndexChanged === "function") {
onIndexChanged({ index: newState.activePageIndex, total });
}
if (needsToUpdateOffset) {
const hasOffsetChanged = offset[dir] !== this.internals.offset[dir];
if (!hasOffsetChanged) {
const newOffset = { x: 0, y: 0 };
newOffset[dir] = offset[dir] + 1;
this.setState(Object.assign(Object.assign({}, newState), { offset: newOffset }), () => {
this.scrollTo({
x: newOffset.x,
y: newOffset.y,
animated: false,
});
this.setState({ offset }, () => {
this.scrollTo({
x: offset.x,
y: offset.y,
animated: false,
});
});
});
}
else {
this.setState(Object.assign(Object.assign({}, newState), { offset }), () => {
this.scrollTo({
x: offset.x,
y: offset.y,
animated: false,
});
});
}
}
else {
this.setState(newState);
}
const { autoplay } = this.props;
if (autoplay || this.internals.autoplay) {
this.autoplay();
}
};
this.isActivePageIndex = (index) => {
const { activePageIndex, total } = this.state;
const min = 0;
const max = total;
const isCurrentIndex = index === activePageIndex;
const isSmallerThanMin = index === min && activePageIndex < min;
const isBiggerThanMax = index === max && activePageIndex > max;
return isCurrentIndex || isSmallerThanMin || isBiggerThanMax;
};
this.scrollTo = ({ x, y, animated, }) => {
if (this.scrollView === null) {
return;
}
this.scrollView.scrollTo({ x, y, animated });
};
this.scrollToIndex = ({ index, animated = true, }) => {
const { total } = this.state;
const { isScrolling } = this.internals;
if (this.scrollView === null || isScrolling || total < 2) {
return;
}
const { width, height } = this.state;
const { horizontal, loop } = this.props;
const diff = (loop ? 1 : 0) + index;
const x = horizontal ? diff * width : 0;
const y = horizontal ? 0 : diff * height;
this.scrollTo({ animated, x, y });
if (Platform.OS === "android") {
this.internals.onScrollEndCallbackTargetOffset = horizontal
? Math.floor(x)
: Math.floor(y);
}
this.internals.isScrolling = true;
this.internals.isAutoplayEnd = false;
this.internals.isAnimatedScroll = animated;
};
this.scrollBy = ({ index, animated = true }) => {
const { total } = this.state;
const { isScrolling } = this.internals;
if (this.scrollView === null || isScrolling || total < 2) {
return;
}
const { activePageIndex, width, height } = this.state;
const { horizontal, loop } = this.props;
const diff = (loop ? 1 : 0) + index + activePageIndex;
const min = 0;
if (!loop && (diff > total - 1 || diff < min)) {
return;
}
const x = horizontal ? diff * width : min;
const y = horizontal ? min : diff * height;
this.scrollTo({ animated, x, y });
if (Platform.OS === "android") {
this.internals.onScrollEndCallbackTargetOffset = horizontal
? Math.floor(x)
: Math.floor(y);
}
this.internals.isScrolling = true;
this.internals.isAutoplayEnd = false;
this.internals.isAnimatedScroll = animated;
};
this.scrollToPrev = () => {
this.scrollBy({ index: -1 });
};
this.scrollToNext = () => {
this.scrollBy({ index: 1 });
};
this.startAutoplay = () => {
this.internals.autoplay = true;
this.autoplay();
};
this.stopAutoplay = () => {
this.internals.autoplay = false;
clearTimeout(this.autoplayTimer);
};
this.onLayout = (e) => {
const { onLayout } = this.props;
if (typeof onLayout === "function") {
onLayout(e);
}
const { activePageIndex, total } = this.state;
const { height: propsHeight, width: propsWidth } = this.props;
const { height: layoutHeight, width: layoutWidth } = e.nativeEvent.layout;
const width = propsWidth !== undefined ? propsWidth : layoutWidth;
const height = propsHeight !== undefined ? propsHeight : layoutHeight;
const initialOffset = { x: 0, y: 0 };
const offset = initialOffset;
this.internals.offset = initialOffset;
if (total > 1) {
const { horizontal, loop } = this.props;
const dir = horizontal ? "x" : "y";
const index = loop ? activePageIndex + 1 : activePageIndex;
offset[dir] = dir === "x" ? width * index : height * index;
}
this.setState({ height, width, offset });
this.scrollTo({ x: offset.x, y: offset.y, animated: false });
};
this.renderNext = () => {
const { renderNext, loop } = this.props;
const { activePageIndex, total } = this.state;
const isShown = loop || activePageIndex < total - 1;
if (isShown) {
if (typeof renderNext === "function") {
return renderNext({
scrollToNext: this.scrollToNext,
});
}
const { accessibility, accessibilityLabelNext, controlsButtonStyle, controlsTextStyle, mergeStyles, } = this.props;
return (<TouchableOpacity accessibilityLabel={accessibilityLabelNext} accessibilityRole="button" accessible={accessibility} onPress={this.scrollToNext} style={controlsButtonStyle} testID="PinarNextButton">
<Text accessibilityLabel={accessibilityLabelNext} accessible={accessibility} style={mergeStyles
? [styles.buttonText, controlsTextStyle]
: controlsTextStyle || styles.buttonText}>
›
</Text>
</TouchableOpacity>);
}
return <View />;
};
this.renderPrev = () => {
const { renderPrev, loop } = this.props;
const { activePageIndex } = this.state;
const isShown = loop || activePageIndex > 0;
if (isShown) {
if (typeof renderPrev === "function") {
return renderPrev({
scrollToPrev: this.scrollToPrev,
});
}
const { accessibility, accessibilityLabelPrev, controlsButtonStyle, controlsTextStyle, mergeStyles, } = this.props;
return (<TouchableOpacity accessibilityLabel={accessibilityLabelPrev} accessibilityRole="button" accessible={accessibility} onPress={this.scrollToPrev} style={controlsButtonStyle} testID="PinarPrevButton">
<Text accessibilityLabel={accessibilityLabelPrev} accessible={accessibility} style={mergeStyles
? [styles.buttonText, controlsTextStyle]
: controlsTextStyle || styles.buttonText}>
‹
</Text>
</TouchableOpacity>);
}
return <View />;
};
this.refScrollView = (view) => {
if (view === null) {
return;
}
this.scrollView = view;
};
this.renderControls = () => {
const { renderControls } = this.props;
if (typeof renderControls === "function") {
return renderControls({
scrollToPrev: this.scrollToPrev,
scrollToNext: this.scrollToNext,
});
}
const { height, width } = this.state;
const { controlsContainerStyle, mergeStyles } = this.props;
const defaultControlsContainerStyle = [
styles.controlsContainer,
{ height, width },
];
return (<View pointerEvents="box-none" style={mergeStyles
? [defaultControlsContainerStyle, controlsContainerStyle]
: controlsContainerStyle || defaultControlsContainerStyle}>
{this.renderPrev()}
{this.renderNext()}
</View>);
};
this.renderDots = () => {
const { renderDots } = this.props;
if (typeof renderDots === "function") {
const { activePageIndex, total } = this.state;
return renderDots({
index: activePageIndex,
total,
context: this,
});
}
const { children, dotsContainerStyle, horizontal, renderActiveDot, renderDot, mergeStyles, } = this.props;
const defaultDotsContainerStyle = horizontal
? styles.dotsContainerHorizontal
: styles.dotsContainerVertical;
return (<View style={mergeStyles
? [defaultDotsContainerStyle, dotsContainerStyle]
: dotsContainerStyle || defaultDotsContainerStyle}>
{React.Children.map(children, (_, i) => {
const isActive = this.isActivePageIndex(i);
if (isActive && typeof renderActiveDot === "function") {
return renderActiveDot({ index: i });
}
if (typeof renderDot === "function") {
return renderDot({ index: i });
}
const { dotStyle, activeDotStyle } = this.props;
const style = isActive
? activeDotStyle || styles.dotActive
: dotStyle || styles.dot;
const mergeStyle = isActive
? [styles.dotActive, activeDotStyle]
: [styles.dot, dotStyle];
return <View key={i} style={mergeStyles ? mergeStyle : style}/>;
})}
</View>);
};
this.renderChildren = (children) => {
const { height, width, total } = this.state;
const { accessibility, loop } = this.props;
const needsToLoop = loop && total > 1;
const childrenArray = React.Children.toArray(children);
const keys = Object.keys(childrenArray);
if (needsToLoop) {
const firstPageIndex = 0;
const lastPageIndex = total - 1;
keys.unshift(String(lastPageIndex));
keys.push(String(firstPageIndex));
}
return keys.map((key, i) => {
return (<View accessible={accessibility} key={`${i}${key}`} style={{ height, width }}>
{childrenArray[Number(key)]}
</View>);
});
};
const { height, width } = this.getCarouselDimensions();
const total = React.Children.toArray(props.children).length;
const initialIndex = props.index || 0;
const lastIndex = total - 1;
const activePageIndex = total > 1 ? Math.min(initialIndex, lastIndex) : 0;
const offset = { x: 0, y: 0 };
this.internals = {
autoplay: false,
isAutoplayEnd: false,
isScrolling: false,
isAnimatedScroll: true,
offset,
onScrollEndCallbackTargetOffset: 0,
};
this.state = { activePageIndex, height, width, total, offset };
this.scrollView = null;
}
componentDidMount() {
const { activePageIndex } = this.state;
const { index, autoplay } = this.props;
if (index && index !== activePageIndex) {
this.scrollBy({ index, animated: false });
this.setState({ activePageIndex: index });
}
if (autoplay) {
this.autoplay();
}
}
componentWillUnmount() {
this.stopAutoplay();
}
componentDidUpdate(prevProps, prevState) {
const { height, width, index, children } = this.props;
const needsToUpdateWidth = prevProps.width !== width;
const needsToUpdateHeight = prevProps.height !== height;
const total = React.Children.toArray(children).length;
const needsToUpdateTotal = prevState.total !== total;
const needsToUpdateIndex = prevProps.index !== index;
if (needsToUpdateHeight ||
needsToUpdateWidth ||
needsToUpdateTotal ||
needsToUpdateIndex) {
this.setState(Object.assign(Object.assign({}, this.getCarouselDimensions()), { total, activePageIndex: index }));
}
}
render() {
const { bounces, children, horizontal, pagingEnabled, showsControls, showsHorizontalScrollIndicator, showsDots, showsVerticalScrollIndicator, scrollsToTop, removeClippedSubviews, automaticallyAdjustContentInsets, scrollEventThrottle, scrollEnabled, width, height, style, containerStyle, contentContainerStyle, } = this.props;
const hasHeightAndWidthProps = width !== undefined && height !== undefined;
return (<View onLayout={this.onLayout} style={[
styles.wrapper,
{ maxHeight: height, maxWidth: width },
!hasHeightAndWidthProps && { flex: 1 },
style,
]}>
<View style={{ height, width }}>
<ScrollView automaticallyAdjustContentInsets={automaticallyAdjustContentInsets} bounces={bounces} contentContainerStyle={contentContainerStyle} horizontal={horizontal} onMomentumScrollEnd={this.onMomentumScrollEnd} onScroll={this.onScroll} onScrollBeginDrag={this.onScrollBeginDrag} onScrollEndDrag={this.onScrollEndDrag} pagingEnabled={pagingEnabled} ref={this.refScrollView} removeClippedSubviews={removeClippedSubviews} scrollEnabled={scrollEnabled} scrollEventThrottle={scrollEventThrottle} scrollsToTop={scrollsToTop} showsHorizontalScrollIndicator={showsHorizontalScrollIndicator} showsVerticalScrollIndicator={showsVerticalScrollIndicator} style={containerStyle}>
{this.renderChildren(children)}
</ScrollView>
{showsDots && this.renderDots()}
{showsControls && this.renderControls()}
</View>
</View>);
}
}
Pinar.defaultProps = Object.assign(Object.assign({}, defaultScrollViewProps), defaultCarouselProps);
export { Pinar };