UNPKG

pinar

Version:

Customizable, lightweight React Native carousel component with accessibility support

452 lines (451 loc) 20.6 kB
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 };