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 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 } 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 { vertical?: boolean current: number count: number styles: any dotStyle?: StyleProp dotActiveStyle?: StyleProp } const defaultPagination = (props: PaginationProps) => { const { styles, current, vertical, count, dotStyle, dotActiveStyle } = 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, 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: CarouselProps) { 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: 0, 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, height, offset, }, () => { // web this.scrollview?.current?.scrollTo({ ...offset, animated: false }) this.autoplay() }, ) } 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, }, }) } }, ) } /** * 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; } 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, 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)} ) } 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} ) } } export default Carousel