UNPKG

react-native-ui-lib

Version:

[![SWUbanner](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct.svg)](https://stand-with-ukraine.pp.ua)

547 lines (543 loc) • 17.5 kB
import _times from "lodash/times"; import _isUndefined from "lodash/isUndefined"; import React, { Component } from 'react'; import { Animated, ScrollView, StyleSheet } from 'react-native'; import { Colors } from "../../style"; import { asBaseComponent, Constants } from "../../commons/new"; import View from "../view"; import Text from "../text"; import PageControl from "../pageControl"; import * as presenter from "./CarouselPresenter"; import { CarouselProps, PageControlPosition } from "./types"; export { CarouselProps, PageControlPosition }; /** * @description: Carousel for scrolling pages horizontally * @gif: https://user-images.githubusercontent.com/1780255/107120258-40b5bc80-6895-11eb-9596-8065d3a940ff.gif, https://user-images.githubusercontent.com/1780255/107120257-3eebf900-6895-11eb-9800-402e9e0dc692.gif * @example: https://github.com/wix/react-native-ui-lib/blob/master/demo/src/screens/componentScreens/CarouselScreen.tsx * @extends: ScrollView * @extendsLink: https://reactnative.dev/docs/scrollview * @notes: This is a screen width Component */ class Carousel extends Component { static displayName = 'Carousel'; static defaultProps = { initialPage: 0, pagingEnabled: true, autoplay: false, autoplayInterval: 4000, horizontal: true }; static pageControlPositions = PageControlPosition; carousel = React.createRef(); constructor(props) { super(props); const defaultPageWidth = props.loop || !props.pageWidth ? Constants.screenWidth : props.pageWidth; const pageHeight = props.pageHeight ?? Constants.screenHeight; this.isAutoScrolled = false; this.state = { containerWidth: undefined, // @ts-ignore (defaultProps) currentPage: this.shouldUsePageWidth() ? this.getCalcIndex(props.initialPage) : props.initialPage, currentStandingPage: props.initialPage || 0, pageWidth: defaultPageWidth, pageHeight, initialOffset: presenter.calcOffset(props, { // @ts-ignore (defaultProps) currentPage: props.initialPage, pageWidth: defaultPageWidth, pageHeight }), prevProps: props }; } static getDerivedStateFromProps(nextProps, prevState) { const { currentPage, prevProps } = prevState; const { pageWidth, pageHeight } = prevProps; const { pageWidth: nextPageWidth, pageHeight: nextPageHeight } = nextProps; if (!_isUndefined(nextPageWidth) && pageWidth !== nextPageWidth || !_isUndefined(nextPageHeight) && pageHeight !== nextPageHeight) { const pageWidth = nextPageWidth; const pageHeight = nextPageHeight; return { pageWidth, initialOffset: presenter.calcOffset(prevProps, { currentPage, pageWidth, pageHeight }), prevProps: nextProps }; } // for presenter.calcOffset() props parameter if (prevProps.containerMarginHorizontal !== nextProps.containerMarginHorizontal || prevProps.loop !== nextProps.loop) { return { prevProps: nextProps }; } return null; } componentDidMount() { this.dimensionsChangeListener = Constants.addDimensionsEventListener(this.onOrientationChanged); if (this.props.autoplay) { this.startAutoPlay(); } } componentWillUnmount() { Constants.removeDimensionsEventListener(this.dimensionsChangeListener || this.onOrientationChanged); if (this.autoplayTimer) { clearInterval(this.autoplayTimer); } } componentDidUpdate(prevProps) { const { autoplay } = this.props; if (autoplay && !prevProps.autoplay) { this.startAutoPlay(); } else if (!autoplay && prevProps.autoplay) { this.stopAutoPlay(); } } onOrientationChanged = () => { const { pageWidth, loop } = this.props; if (!pageWidth || loop) { this.orientationChange = true; // HACK: setting to containerWidth for Android's call when view disappear this.setState({ pageWidth: this.state.containerWidth || Constants.screenWidth }); } }; getItemSpacings(props) { const { itemSpacings = 16 } = props; return itemSpacings; } getContainerMarginHorizontal = () => { const { containerMarginHorizontal = 0 } = this.props; return containerMarginHorizontal; }; // TODO: RN 61.5 - try to remove this from the children and move to the ScrollView's contentContainerStyle // style={{overflow: 'visible'}} does not work in ScrollView on Android, maybe it will be fixed in the future getContainerPaddingVertical = () => { const { containerPaddingVertical = 0 } = this.props; return containerPaddingVertical; }; updateOffset = (animated = false) => { const { x, y } = presenter.calcOffset(this.props, this.state); if (this.carousel?.current) { this.carousel.current.scrollTo({ x, y, animated }); if (Constants.isAndroid) { // this is done to handle onMomentumScrollEnd not being called in Android: // https://github.com/facebook/react-native/issues/11693 // https://github.com/facebook/react-native/issues/19246 this.onMomentumScrollEnd(); } } }; startAutoPlay() { this.autoplayTimer = setInterval(() => { this.isAutoScrolled = true; this.goToNextPage(); }, this.props.autoplayInterval); } stopAutoPlay() { if (this.autoplayTimer) { clearInterval(this.autoplayTimer); } } resetAutoPlay() { this.stopAutoPlay(); this.startAutoPlay(); } goToPage(pageIndex, animated = true) { this.setState({ currentPage: pageIndex }, () => this.updateOffset(animated)); } goToNextPage() { const { currentPage } = this.state; const pagesCount = presenter.getChildrenLength(this.props); const { loop } = this.props; let nextPageIndex; if (loop) { if (currentPage === pagesCount + 1) { this.goToPage(0, false); return; } nextPageIndex = currentPage + 1; } else { nextPageIndex = Math.min(pagesCount - 1, currentPage + 1); } this.goToPage(nextPageIndex, true); // // in case of a loop, after we advanced right to the cloned first page, // // we return silently to the real first page // if (loop && currentPage === pagesCount) { // this.goToPage(0, false); // } } getCalcIndex(index) { // to handle scrollView index issue in Android's RTL layout if (Constants.isRTL && Constants.isAndroid) { const length = presenter.getChildrenLength(this.props) - 1; return length - index; } return index; } // TODO: currently this returns pagesCount offsets, not starting from 0; look into changing this into (pagesCount - 1) or to have the 1st item as 0 getSnapToOffsets = () => { const { containerWidth, pageWidth } = this.state; if (this.shouldEnablePagination()) { return undefined; } if (containerWidth) { const spacings = pageWidth === containerWidth ? 0 : this.getItemSpacings(this.props); const initialBreak = pageWidth - (containerWidth - pageWidth - spacings) / 2; const snapToOffsets = _times(presenter.getChildrenLength(this.props), index => initialBreak + index * pageWidth + this.getContainerMarginHorizontal()); return snapToOffsets; } }; getInitialContentOffset = snapToOffsets => { const { horizontal, initialPage } = this.props; const { initialOffset } = this.state; let contentOffset = initialOffset; if (snapToOffsets && initialPage !== undefined) { const offset = initialPage === 0 ? 0 : snapToOffsets[initialPage - 1]; contentOffset = { x: horizontal ? offset : 0, y: horizontal ? 0 : offset }; } return contentOffset; }; shouldUsePageWidth() { const { loop, pageWidth } = this.props; return !loop && pageWidth; } shouldEnablePagination() { const { pagingEnabled, horizontal } = this.props; return horizontal ? pagingEnabled && !this.shouldUsePageWidth() : true; } shouldAllowAccessibilityLayout() { const { allowAccessibleLayout } = this.props; return allowAccessibleLayout && Constants.accessibility.isScreenReaderEnabled; } onContentSizeChange = () => { // this is to handle initial scroll position (content offset) if (Constants.isAndroid) { this.updateOffset(); } }; onContainerLayout = ({ nativeEvent: { layout: { width: containerWidth, height: containerHeight } } }) => { const { pageWidth = containerWidth, pageHeight = containerHeight, horizontal } = this.props; const initialOffset = presenter.calcOffset(this.props, { currentPage: this.state.currentPage, pageWidth, pageHeight }); // NOTE: This is to avoid resetting containerWidth to 0 - an issue that happens // on Android in some case when onLayout is re-triggered if (horizontal && containerWidth || !horizontal && containerHeight) { this.setState({ containerWidth, pageWidth, pageHeight, initialOffset }); } }; onMomentumScrollEnd = () => { // finished full page scroll const { currentStandingPage, currentPage } = this.state; const pagesCount = presenter.getChildrenLength(this.props); if (currentPage < pagesCount) { this.setState({ currentStandingPage: currentPage }); if (currentStandingPage !== currentPage) { this.props.onChangePage?.(currentPage, currentStandingPage, { isAutoScrolled: this.isAutoScrolled }); this.isAutoScrolled = false; } } }; onScroll = event => { if (!this.skippedInitialScroll) { this.skippedInitialScroll = true; return; } const { loop, autoplay, horizontal } = this.props; const { pageWidth, pageHeight } = this.state; const offsetX = event.nativeEvent.contentOffset.x; const offsetY = event.nativeEvent.contentOffset.y; const offset = horizontal ? offsetX : offsetY; const pageSize = horizontal ? pageWidth : pageHeight; if (offset >= 0) { if (!this.orientationChange) { // Avoid new calculation on orientation change const newPage = presenter.calcPageIndex(offset, this.props, pageSize); this.setState({ currentPage: newPage }); } this.orientationChange = false; } if (loop && presenter.isOutOfBounds(offsetX, this.props, pageWidth)) { this.updateOffset(); } if (autoplay) { // reset the timer to avoid auto scroll immediately after manual one this.resetAutoPlay(); } this.props.onScroll?.(event); }; onScrollEvent = Animated.event([{ nativeEvent: { contentOffset: // @ts-ignore { y: this.props?.animatedScrollOffset?.y, x: this.props?.animatedScrollOffset?.x } } }], { useNativeDriver: true, listener: this.onScroll }); renderChild = (child, key) => { if (child) { const { pageWidth, pageHeight } = this.state; const { horizontal } = this.props; const paddingLeft = horizontal ? this.shouldUsePageWidth() ? this.getItemSpacings(this.props) : undefined : 0; const index = Number(key); const length = presenter.getChildrenLength(this.props); const containerMarginHorizontal = this.getContainerMarginHorizontal(); const marginLeft = index === 0 ? containerMarginHorizontal : 0; const marginRight = index === length - 1 ? containerMarginHorizontal : 0; const paddingVertical = this.getContainerPaddingVertical(); return <View style={[{ width: pageWidth, height: !horizontal ? pageHeight : undefined, paddingLeft, marginLeft, marginRight, paddingVertical }, Constants.isRTL && Constants.isAndroid && styles.invertedView]} key={key} collapsable={false}> {this.shouldAllowAccessibilityLayout() && !Number.isNaN(index) && <View style={styles.hiddenText}> <Text>{`page ${index + 1} out of ${length}`}</Text> </View>} {child} </View>; } }; renderChildren() { const { children: propsChildren, loop } = this.props; const length = presenter.getChildrenLength(this.props); const children = Constants.isRTL && Constants.isAndroid ? React.Children.toArray(propsChildren).reverse() : propsChildren; const childrenArray = React.Children.map(children, (child, index) => { return this.renderChild(child, `${index}`); }); if (loop && childrenArray) { // @ts-ignore childrenArray.unshift(this.renderChild(children[length - 1], `${length - 1}-clone`)); // @ts-ignore childrenArray.push(this.renderChild(children[0], `${0}-clone`)); } return childrenArray; } renderPageControl() { const { pageControlPosition, pageControlProps = {} } = this.props; const { currentStandingPage } = this.state; if (pageControlPosition) { const { size = 6, spacing = 8, color = Colors.$backgroundNeutralHeavy, inactiveColor = Colors.$backgroundDisabled, ...others } = pageControlProps; const pagesCount = presenter.getChildrenLength(this.props); const containerStyle = pageControlPosition === PageControlPosition.UNDER ? { marginVertical: 16 - this.getContainerPaddingVertical() } : styles.pageControlContainerStyle; return <PageControl size={size} spacing={spacing} containerStyle={[containerStyle, Constants.isRTL && Constants.isIOS && styles.flip]} inactiveColor={inactiveColor} color={color} {...others} numOfPages={pagesCount} currentPage={currentStandingPage} />; } } renderCounter() { const { pageWidth, showCounter, counterTextStyle } = this.props; const { currentStandingPage } = this.state; const pagesCount = presenter.getChildrenLength(this.props); if (showCounter && !pageWidth) { return <View center style={styles.counter}> <Text grey80 text90 style={[{ fontWeight: 'bold' }, counterTextStyle]}> {currentStandingPage + 1}/{pagesCount} </Text> </View>; } } renderAccessibleLayout() { const { containerStyle, children, testID } = this.props; return <View style={containerStyle} onLayout={this.onContainerLayout}> <ScrollView testID={testID} ref={this.carousel} showsVerticalScrollIndicator={false} pagingEnabled onContentSizeChange={this.onContentSizeChange} onScroll={this.onScroll} onMomentumScrollEnd={this.onMomentumScrollEnd}> {React.Children.map(children, (child, index) => { return this.renderChild(child, `${index}`); })} </ScrollView> </View>; } renderCarousel() { const { containerStyle, animated, horizontal, animatedScrollOffset, style, ...others } = this.props; const scrollContainerStyle = this.shouldUsePageWidth() ? { paddingRight: this.getItemSpacings(this.props) } : undefined; const snapToOffsets = this.getSnapToOffsets(); const marginBottom = Math.max(0, this.getContainerPaddingVertical() - 16); const ScrollContainer = animatedScrollOffset ? Animated.ScrollView : ScrollView; const contentOffset = this.getInitialContentOffset(snapToOffsets); const _style = Constants.isRTL && Constants.isAndroid ? [styles.invertedView, style] : style; return <View animated={animated} style={[{ marginBottom }, containerStyle]} onLayout={this.onContainerLayout}> <ScrollContainer showsHorizontalScrollIndicator={false} showsVerticalScrollIndicator={false} decelerationRate="fast" scrollEventThrottle={Constants.isAndroid ? 16 : 200} // Android needs 16ms throttle to reliably catch loop boundary during fast swipes (Ticket 4885) {...others} ref={this.carousel} onScroll={animatedScrollOffset ? this.onScrollEvent : this.onScroll} contentContainerStyle={scrollContainerStyle} horizontal={horizontal} pagingEnabled={this.shouldEnablePagination()} snapToOffsets={snapToOffsets} contentOffset={contentOffset} // onContentSizeChange={this.onContentSizeChange} onMomentumScrollEnd={this.onMomentumScrollEnd} style={_style}> {this.renderChildren()} </ScrollContainer> {this.renderPageControl()} {this.renderCounter()} </View>; } render() { return this.shouldAllowAccessibilityLayout() ? this.renderAccessibleLayout() : this.renderCarousel(); } } export { Carousel }; // For tests export default asBaseComponent(Carousel); const styles = StyleSheet.create({ counter: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 20, backgroundColor: Colors.rgba(Colors.black, 0.6), position: 'absolute', top: 12, right: 12 }, flip: { transform: [{ scaleX: -1 }] }, pageControlContainerStyle: { position: 'absolute', bottom: 16, alignSelf: 'center' }, hiddenText: { position: 'absolute', width: 1 }, invertedView: { transform: [{ scaleX: -1 }] } });