import React from 'react'; import { LayoutRectangle, NativeScrollEvent, NativeSyntheticEvent, Platform, ScrollView, ScrollViewProps, StyleProp, View, ViewStyle } from 'react-native'; export interface CarouselPropsType extends ScrollViewProps { accessibilityLabel?: string; pageStyle?: ViewStyle; children?: React.ReactNode; selectedIndex?: number; dots?: boolean; vertical?: boolean; autoplay?: boolean; autoplayInterval?: number; infinite?: boolean; } export interface CarouselProps extends CarouselPropsType { style?: StyleProp; dotStyle?: StyleProp; dotActiveStyle?: StyleProp; pagination?: (props: PaginationProps) => React.ReactNode; afterChange?: (index: number) => void; styles?: any; } interface NativeScrollPoint { x: number; y: number; } interface TargetedEvent { target: number; } export interface CarouselState { width: number; height: number; selectedIndex: number; afterSelectedIndex: number; isScrolling: boolean; offset: NativeScrollPoint; } export interface PaginationProps { height?: number; vertical?: boolean; current: number; count: number; styles: any; dotStyle?: StyleProp; dotActiveStyle?: StyleProp; } const defaultPagination = (props: PaginationProps) => { const { styles, current, vertical, count, dotStyle, dotActiveStyle, height } = props; const positionStyle = vertical ? 'paginationY' : 'paginationX'; const flexDirection = vertical ? 'column' : 'row'; const arr: any = []; for (let i = 0; i < count; i++) { arr.push( ); } return ( {arr} ); }; class Carousel extends React.PureComponent { static defaultProps: CarouselProps = { accessibilityLabel: 'Carousel', pageStyle: {}, infinite: false, dots: true, autoplay: false, autoplayInterval: 3000, selectedIndex: 0, vertical: false, pagination: defaultPagination, dotStyle: {}, dotActiveStyle: {} }; private count: number; private scrollview = React.createRef(); constructor(props: CarouselProps) { super(props); const { children, selectedIndex } = this.props; this.count = children ? React.Children.count(children) : 0; const index = (this.count > 1 && Math.min(selectedIndex as number, this.count - 1)) || 0; this.state = { width: 0, height: 0, isScrolling: false, selectedIndex: index, afterSelectedIndex: -1, offset: { x: 0, y: 0 } }; } componentDidMount() { this.autoplay(); } UNSAFE_componentWillReceiveProps(nextProps: any) { const { autoplay, children, infinite, vertical } = nextProps; const { width, height } = this.state; if (autoplay !== this.props.autoplay) { if (autoplay) { this.autoplay(); } else { this.autoplayTimer && clearTimeout(this.autoplayTimer); } } if ( children && React.Children.count(children) === this.count && infinite === this.props.infinite ) { return; } this.count = React.Children.count(children) || 1; const offset = vertical ? { x: 0, y: height * (infinite ? 1 : 0) } : { x: width * (infinite ? 1 : 0), y: 0 }; this.setState( { isScrolling: false, afterSelectedIndex: -1, // selectedIndex: nextProps.selectedIndex, offset: offset }, () => { this.scrollview?.current?.scrollTo(offset); this.autoplay(); } ); } private autoplayTimer: ReturnType; private scrollEndTimter: ReturnType; componentWillUnmount() { this.autoplayTimer && clearTimeout(this.autoplayTimer); this.scrollEndTimter && clearTimeout(this.scrollEndTimter); } onScrollBegin = (e: NativeSyntheticEvent) => { this.setState( { isScrolling: true }, () => { if (this.props.onScrollBeginDrag) { this.props.onScrollBeginDrag(e); } } ); }; onScrollEnd = (e: NativeSyntheticEvent) => { e.persist?.(); // android/web hack if (!e.nativeEvent.contentOffset) { // @ts-ignore const { position } = e.nativeEvent; e.nativeEvent.contentOffset = { x: this.props.vertical ? 0 : position * this.state.width, y: this.props.vertical ? position * this.state.height : 0 }; } this.autoplay(); clearTimeout(this.scrollEndTimter); this.scrollEndTimter = setTimeout(() => { this.updateIndex(e.nativeEvent.contentOffset); if (this.props.onMomentumScrollEnd) { this.props.onMomentumScrollEnd(e); } }, 50); // idle time }; onScrollEndDrag = (e: NativeSyntheticEvent) => { e.persist?.(); const { offset, selectedIndex } = this.state; const previousOffset = offset; const newOffset = e.nativeEvent.contentOffset; if ( (this.props.vertical ? previousOffset.y === newOffset.y : previousOffset.x === newOffset.x) && (selectedIndex === 0 || selectedIndex === this.count - 1) ) { this.setState({ isScrolling: false }); } if (this.props.onScrollEndDrag) { this.props.onScrollEndDrag(e); } }; onTouchStartForWeb = () => { this.setState({ isScrolling: true }); }; onTouchEndForWeb = () => { this.autoplay(); }; onScrollForWeb = (e: any) => { this.onScrollEnd(JSON.parse(JSON.stringify(e))); }; onLayout = ( e: NativeSyntheticEvent ) => { const { selectedIndex, infinite, vertical } = this.props; const scrollIndex = (this.count > 1 && Math.min(selectedIndex as number, this.count - 1)) || 0; const { width, height } = e.nativeEvent.layout; const offset = vertical ? { x: 0, y: height * (scrollIndex + (infinite && this.count > 1 ? 1 : 0)) } : { x: width * (scrollIndex + (infinite && this.count > 1 ? 1 : 0)), y: 0 }; this.setState( { width: width, height, offset }, () => { // web setTimeout(() => { this.scrollview?.current?.scrollTo({ ...offset, animated: true }); this.autoplay(); }, 100); } ); }; updateIndex = (currentOffset: NativeScrollPoint) => { const paramOffset = currentOffset; let { selectedIndex } = this.state; const { offset, width, height } = this.state; const diff = this.props.vertical ? paramOffset.y - offset.y : paramOffset.x - offset.x; if (!diff) { return; } selectedIndex += Math.round(diff / (this.props.vertical ? height : width)); if (this.props.infinite) { if (selectedIndex <= -1) { selectedIndex = this.count - 1; if (this.props.vertical) { paramOffset.y = height * this.count; } else { paramOffset.x = width * this.count; } } else if (selectedIndex >= this.count) { selectedIndex = 0; if (this.props.vertical) { paramOffset.y = height; } else { paramOffset.x = width; } } if (this.props.vertical) { if (paramOffset.y === height) { this.scrollToStart(); } else if (paramOffset.y === this.count * height) { this.scrollToEnd(); } } else { if (paramOffset.x === width) { this.scrollToStart(); } else if (paramOffset.x === this.count * width) { this.scrollToEnd(); } } } this.setState( { selectedIndex, offset: paramOffset }, () => { if ( this.props.afterChange && this.state.selectedIndex !== this.state.afterSelectedIndex ) { this.setState({ afterSelectedIndex: selectedIndex }, () => { this.props.afterChange?.(selectedIndex); }); } } ); }; scrollToStart = () => { this.scrollview?.current?.scrollTo({ x: this.props.vertical ? 0 : this.state.width, y: this.props.vertical ? this.state.height : 0, animated: false }); }; scrollToEnd = () => { this.scrollview?.current?.scrollTo({ x: this.props.vertical ? 0 : this.state.width * this.count, y: this.props.vertical ? this.state.height * this.count : 0, animated: false }); }; scrollNextPage = () => { const { selectedIndex, isScrolling, width, height } = this.state; if (isScrolling || this.count < 2) { return; } const diff = selectedIndex + 1 + (this.props.infinite ? 1 : 0); this.scrollview?.current?.scrollTo( this.props.vertical ? { x: 0, y: diff * height } : { x: diff * width, y: 0 } ); this.setState( { isScrolling: true }, () => { if (Platform.OS !== 'ios') { this.onScrollEnd({ nativeEvent: { // @ts-ignore position: diff } }); } } ); }; goPrev = () => { const { selectedIndex, isScrolling, width, height } = this.state; if (isScrolling || this.count < 2) { return; } const diff = (selectedIndex - 1 < 0 ? this.count - 1 : selectedIndex - 1) + (this.props.infinite ? 1 : 0); this.scrollview?.current?.scrollTo( this.props.vertical ? { x: 0, y: diff * height } : { x: diff * width, y: 0 } ); this.setState( { isScrolling: true }, () => { if (Platform.OS !== 'ios') { this.onScrollEnd({ nativeEvent: { // @ts-ignore position: diff } }); } } ); }; /** * go to index * @param index * @param animated */ public goTo(index: number, animated?: boolean) { const { width, height } = this.state; const count = this.props.infinite ? this.count - 1 : this.count; if (typeof index !== 'number' || index < 0 || index > count) { return console.warn( false, 'Carousel', `function goTo(index): ${'`index`'} must be between 0 - ${count} numbers` ); } this.scrollview?.current?.scrollTo({ x: this.props.vertical ? 0 : (index + (this.props.infinite ? 1 : 0)) * width, y: this.props.vertical ? (index + (this.props.infinite ? 1 : 0)) * height : 0, animated }); } render() { const { children, dots, infinite, accessibilityLabel, pageStyle } = this.props; const { width, height, selectedIndex } = this.state; if (!children) { return null; } let pages; const pageWidth = [pageStyle, { width, height }]; if (this.count > 1) { const childrenArray = React.Children.toArray(children); if (infinite) { childrenArray.unshift(childrenArray[this.count - 1]); childrenArray.push(childrenArray[1]); } pages = childrenArray.map((child, index) => ( {child} )); } else { pages = {children}; } return ( {this.renderScroll(pages)} {dots && this.renderDots(selectedIndex)} ); } private autoplay = () => { this.setState({ isScrolling: false }, () => { const { children, autoplay, autoplayInterval, infinite } = this.props; const { selectedIndex } = this.state; if (!Array.isArray(children) || !autoplay) { return; } clearTimeout(this.autoplayTimer); this.autoplayTimer = setTimeout(() => { if (!infinite && selectedIndex + 1 === this.count - 1) { return; } this.scrollNextPage(); }, autoplayInterval); }); }; private renderScroll = (pages: React.ReactNode) => { return ( {pages} ); }; private renderDots = (index: number) => { const { vertical, pagination, dotStyle, dotActiveStyle, styles } = this.props; const { height } = this.state; if (!pagination) { return null; } return pagination({ height, styles, vertical, current: index, count: this.count, dotStyle, dotActiveStyle }); }; } export default Carousel;