import * as React from "react"; import { Animated, Easing, FlatList, I18nManager, Platform, ScrollView, StyleProp, View, ViewPropTypes, ViewStyle, } from "react-native"; import { defaultScrollInterpolator, stackScrollInterpolator, tinderScrollInterpolator, defaultAnimatedStyles, shiftAnimatedStyles, stackAnimatedStyles, tinderAnimatedStyles, } from "../utils/animations"; const IS_IOS = Platform.OS === "ios"; // Native driver for scroll events // See: https://facebook.github.io/react-native/blog/2017/02/14/using-native-driver-for-animated.html const AnimatedFlatList = FlatList ? Animated.createAnimatedComponent(FlatList) : null; const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView); // React Native automatically handles RTL layouts; unfortunately, it's buggy with horizontal ScrollView // See https://github.com/facebook/react-native/issues/11960 // NOTE: the following variable is not declared in the constructor // otherwise it is undefined at init, which messes with custom indexes const IS_RTL = I18nManager.isRTL; type Props = { data: any[], renderItem: (data, props) => React.ReactNode, itemWidth?: number, // required for horizontal carousel itemHeight?: number, // required for vertical carousel sliderWidth?: number, // required for horizontal carousel sliderHeight?: number, // required for vertical carousel activeAnimationType?: string, activeAnimationOptions?: Record, activeSlideAlignment?: "center" | "end" | "start", activeSlideOffset?: number, apparitionDelay?: number, autoplay?: boolean, autoplayDelay?: number, autoplayInterval?: number, callbackOffsetMargin?: number, containerCustomStyle?: StyleProp, contentContainerCustomStyle?: StyleProp, enableMomentum?: boolean, enableSnap?: boolean, firstItem?: number, hasParallaxImages?: boolean, inactiveSlideOpacity?: number, inactiveSlideScale?: number, inactiveSlideShift?: number, layout?: "default" | "stack" | "tinder", layoutCardOffset?: number, lockScrollTimeoutDuration?: number, lockScrollWhileSnapping?: boolean, loop?: boolean, loopClonesPerSide?: number, scrollEnabled?: boolean, scrollInterpolator?: (index, props) => void, slideInterpolatedStyle?: (index, animatedValue, props) => void, slideStyle?: StyleProp, swipeThreshold?: number, useScrollView?: boolean | (() => void), vertical?: boolean, onBeforeSnapToItem?: (index) => void, onSnapToItem?: (index?) => void, onScroll?: (evt) => void, keyExtractor?: string | (() => void), onTouchStart?: (evt?) => void, onTouchEnd?: (evt?) => void, onScrollBeginDrag?: (evt) => void, onScrollEndDrag?: (evt) => void, style?: Record, customAnimationType?: string, customAnimationOptions?: Record, onScrollViewScroll?: (evt) => void, onStartShouldSetResponderCapture?: (evt) => void, onMomentumScrollEnd?: (evt?) => void, onLayout?: (evt?) => void, }; type State = { hideCarousel: boolean, interpolators: any[], } export default class Carousel extends React.PureComponent { static defaultProps = { activeAnimationType: "timing", activeAnimationOptions: null, activeSlideAlignment: "center", activeSlideOffset: 20, apparitionDelay: 0, autoplay: false, autoplayDelay: 1000, autoplayInterval: 3000, callbackOffsetMargin: 5, containerCustomStyle: {}, contentContainerCustomStyle: {}, enableMomentum: false, enableSnap: true, firstItem: 0, hasParallaxImages: false, inactiveSlideOpacity: 0.7, inactiveSlideScale: 0.9, inactiveSlideShift: 0, layout: "default", lockScrollTimeoutDuration: 1000, lockScrollWhileSnapping: false, loop: false, loopClonesPerSide: 3, scrollEnabled: true, slideStyle: {}, swipeThreshold: 20, useScrollView: !AnimatedFlatList, vertical: false, }; state: State = { hideCarousel: true, interpolators: [], }; _activeItem: number; _previousActiveItem: number; _previousFirstItem: number; _previousItemsLength: number; _mounted: boolean; _positions: any[]; _currentContentOffset: number; // store ScrollView's scroll position _canFireBeforeCallback: boolean; _canFireCallback: boolean; _scrollOffsetRef = null; _onScrollTriggered = true; // used when momentum is enabled to prevent an issue with edges items _lastScrollDate: number; // used to work around a FlatList bug _scrollEnabled: boolean; // This bool aims at fixing an iOS bug due to scrollTo that triggers onMomentumScrollEnd. // onMomentumScrollEnd fires this._snapScroll, thus creating an infinite loop. _ignoreNextMomentum: boolean; _carouselRef; _scrollPos: Animated.Value; _onScrollHandler; _apparitionTimeout; _hackSlideAnimationTimeout; _enableAutoplayTimeout; _autoplayTimeout; _snapNoMomentumTimeout; _edgeItemTimeout; _lockScrollTimeout; _autoplayInterval; _itemToSnapTo: number; _autoplay: boolean; _autoplaying: boolean; _scrollStartOffset: number; _scrollStartActive: number; _scrollEndOffset: number; _scrollEndActive: number; _onLayoutInitDone: boolean; constructor(props) { super(props); // The following values are not stored in the state because 'setState()' is asynchronous // and this results in an absolutely crappy behavior on Android while swiping (see #156) const initialActiveItem = this._getFirstItem(props.firstItem); this._activeItem = initialActiveItem; this._previousActiveItem = initialActiveItem; this._previousFirstItem = initialActiveItem; this._previousItemsLength = initialActiveItem; this._mounted = false; this._positions = []; this._currentContentOffset = 0; // store ScrollView's scroll position this._canFireBeforeCallback = false; this._canFireCallback = false; this._scrollOffsetRef = null; this._onScrollTriggered = true; // used when momentum is enabled to prevent an issue with edges items this._lastScrollDate = 0; // used to work around a FlatList bug this._scrollEnabled = props.scrollEnabled !== false; // This bool aims at fixing an iOS bug due to scrollTo that triggers onMomentumScrollEnd. // onMomentumScrollEnd fires this._snapScroll, thus creating an infinite loop. this._ignoreNextMomentum = false; this._initPositionsAndInterpolators = this._initPositionsAndInterpolators.bind( this ); this._renderItem = this._renderItem.bind(this); this._onSnap = this._onSnap.bind(this); this._onLayout = this._onLayout.bind(this); this._onScroll = this._onScroll.bind(this); this._onScrollBeginDrag = props.enableSnap ? this._onScrollBeginDrag.bind(this) : undefined; this._onScrollEnd = props.enableSnap || props.autoplay ? this._onScrollEnd.bind(this) : undefined; this._onScrollEndDrag = !props.enableMomentum ? this._onScrollEndDrag.bind(this) : undefined; this._onMomentumScrollEnd = props.enableMomentum ? this._onMomentumScrollEnd.bind(this) : undefined; this._onTouchStart = this._onTouchStart.bind(this); this._onTouchEnd = this._onTouchEnd.bind(this); this._onTouchRelease = this._onTouchRelease.bind(this); this._getKeyExtractor = this._getKeyExtractor.bind(this); this._setScrollHandler(props); // Warnings if (!ViewPropTypes) { console.warn( "react-native-snap-carousel: It is recommended to use at least version 0.44 of React Native with the plugin" ); } if (!props.vertical && (!props.sliderWidth || !props.itemWidth)) { console.error( "react-native-snap-carousel: You need to specify both `sliderWidth` and `itemWidth` for horizontal carousels" ); } if (props.vertical && (!props.sliderHeight || !props.itemHeight)) { console.error( "react-native-snap-carousel: You need to specify both `sliderHeight` and `itemHeight` for vertical carousels" ); } if (props.apparitionDelay && !IS_IOS && !props.useScrollView) { console.warn( "react-native-snap-carousel: Using `apparitionDelay` on Android is not recommended since it can lead to rendering issues" ); } if (props.customAnimationType || props.customAnimationOptions) { console.warn( "react-native-snap-carousel: Props `customAnimationType` and `customAnimationOptions` have been renamed to `activeAnimationType` and `activeAnimationOptions`" ); } if (props.onScrollViewScroll) { console.error( "react-native-snap-carousel: Prop `onScrollViewScroll` has been removed. Use `onScroll` instead" ); } } componentDidMount() { const { apparitionDelay, autoplay, firstItem } = this.props; const _firstItem = this._getFirstItem(firstItem); const apparitionCallback = () => { this.setState({ hideCarousel: false }); if (autoplay) { this.startAutoplay(); } }; this._mounted = true; this._initPositionsAndInterpolators(); // Without 'requestAnimationFrame' or a `0` timeout, images will randomly not be rendered on Android... requestAnimationFrame(() => { if (!this._mounted) { return; } this._snapToItem(_firstItem, false, false, true, false); this._hackActiveSlideAnimation(_firstItem, "start", true); if (apparitionDelay) { this._apparitionTimeout = setTimeout(() => { apparitionCallback(); }, apparitionDelay); } else { apparitionCallback(); } }); } componentDidUpdate(prevProps) { const { interpolators } = this.state; const { firstItem, itemHeight, itemWidth, scrollEnabled, sliderHeight, sliderWidth, } = this.props; const itemsLength = this._getCustomDataLength(this.props); if (!itemsLength) { return; } const nextFirstItem = this._getFirstItem(firstItem, this.props); let nextActiveItem = this._activeItem || this._activeItem === 0 ? this._activeItem : nextFirstItem; const hasNewSliderWidth = sliderWidth && sliderWidth !== prevProps.sliderWidth; const hasNewSliderHeight = sliderHeight && sliderHeight !== prevProps.sliderHeight; const hasNewItemWidth = itemWidth && itemWidth !== prevProps.itemWidth; const hasNewItemHeight = itemHeight && itemHeight !== prevProps.itemHeight; const hasNewScrollEnabled = scrollEnabled !== prevProps.scrollEnabled; // Prevent issues with dynamically removed items if (nextActiveItem > itemsLength - 1) { nextActiveItem = itemsLength - 1; } // Handle changing scrollEnabled independent of user -> carousel interaction if (hasNewScrollEnabled) { this._setScrollEnabled(scrollEnabled); } if ( interpolators.length !== itemsLength || hasNewSliderWidth || hasNewSliderHeight || hasNewItemWidth || hasNewItemHeight ) { this._activeItem = nextActiveItem; this._previousItemsLength = itemsLength; this._initPositionsAndInterpolators(this.props); // Handle scroll issue when dynamically removing items (see #133) // This also fixes first item's active state on Android // Because 'initialScrollIndex' apparently doesn't trigger scroll if (this._previousItemsLength > itemsLength) { this._hackActiveSlideAnimation(nextActiveItem, null, true); } if ( hasNewSliderWidth || hasNewSliderHeight || hasNewItemWidth || hasNewItemHeight ) { this._snapToItem(nextActiveItem, false, false, false, false); } } else if ( nextFirstItem !== this._previousFirstItem && nextFirstItem !== this._activeItem ) { this._activeItem = nextFirstItem; this._previousFirstItem = nextFirstItem; this._snapToItem(nextFirstItem, false, true, false, false); } if (this.props.onScroll !== prevProps.onScroll) { this._setScrollHandler(this.props); } } componentWillUnmount() { this._mounted = false; this.stopAutoplay(); clearTimeout(this._apparitionTimeout); clearTimeout(this._hackSlideAnimationTimeout); clearTimeout(this._enableAutoplayTimeout); clearTimeout(this._autoplayTimeout); clearTimeout(this._snapNoMomentumTimeout); clearTimeout(this._edgeItemTimeout); clearTimeout(this._lockScrollTimeout); } get realIndex() { return this._activeItem; } get currentIndex() { return this._getDataIndex(this._activeItem); } get currentScrollPosition() { return this._currentContentOffset; } _setScrollHandler(props) { // Native driver for scroll events const scrollEventConfig = { listener: this._onScroll, useNativeDriver: true, }; this._scrollPos = new Animated.Value(0); const argMapping = props.vertical ? [{ nativeEvent: { contentOffset: { y: this._scrollPos } } }] : [{ nativeEvent: { contentOffset: { x: this._scrollPos } } }]; if (props.onScroll && Array.isArray(props.onScroll._argMapping)) { // Because of a react-native issue https://github.com/facebook/react-native/issues/13294 argMapping.pop(); const [argMap] = props.onScroll._argMapping; if (argMap && argMap.nativeEvent && argMap.nativeEvent.contentOffset) { // Shares the same animated value passed in props this._scrollPos = argMap.nativeEvent.contentOffset.x || argMap.nativeEvent.contentOffset.y || this._scrollPos; } argMapping.push(...props.onScroll._argMapping); } this._onScrollHandler = Animated.event(argMapping, scrollEventConfig); } _needsScrollView() { const { useScrollView } = this.props; return ( useScrollView || !AnimatedFlatList || this._shouldUseStackLayout() || this._shouldUseTinderLayout() ); } _needsRTLAdaptations() { const { vertical } = this.props; return IS_RTL && !IS_IOS && !vertical; } _canLockScroll() { const { scrollEnabled, enableMomentum, lockScrollWhileSnapping, } = this.props; return scrollEnabled && !enableMomentum && lockScrollWhileSnapping; } _enableLoop() { const { data, enableSnap, loop } = this.props; return enableSnap && loop && data && data.length && data.length > 1; } _shouldAnimateSlides(props = this.props) { const { inactiveSlideOpacity, inactiveSlideScale, scrollInterpolator, slideInterpolatedStyle, } = props; return ( inactiveSlideOpacity < 1 || inactiveSlideScale < 1 || !!scrollInterpolator || !!slideInterpolatedStyle || this._shouldUseShiftLayout() || this._shouldUseStackLayout() || this._shouldUseTinderLayout() ); } _shouldUseCustomAnimation() { const { activeAnimationOptions } = this.props; return ( !!activeAnimationOptions && !this._shouldUseStackLayout() && !this._shouldUseTinderLayout() ); } _shouldUseShiftLayout() { const { inactiveSlideShift, layout } = this.props; return layout === "default" && inactiveSlideShift !== 0; } _shouldUseStackLayout() { return this.props.layout === "stack"; } _shouldUseTinderLayout() { return this.props.layout === "tinder"; } _getCustomData(props = this.props) { const { data, loopClonesPerSide } = props; const dataLength = data && data.length; if (!dataLength) { return []; } if (!this._enableLoop()) { return data; } let previousItems = []; let nextItems = []; if (loopClonesPerSide > dataLength) { const dataMultiplier = Math.floor(loopClonesPerSide / dataLength); const remainder = loopClonesPerSide % dataLength; for (let i = 0; i < dataMultiplier; i++) { previousItems.push(...data); nextItems.push(...data); } previousItems.unshift(...data.slice(-remainder)); nextItems.push(...data.slice(0, remainder)); } else { previousItems = data.slice(-loopClonesPerSide); nextItems = data.slice(0, loopClonesPerSide); } return previousItems.concat(data, nextItems); } _getCustomDataLength(props = this.props) { const { data, loopClonesPerSide } = props; const dataLength = data && data.length; if (!dataLength) { return 0; } return this._enableLoop() ? dataLength + 2 * loopClonesPerSide : dataLength; } _getCustomIndex(index, props = this.props) { const itemsLength = this._getCustomDataLength(props); if (!itemsLength || (!index && index !== 0)) { return 0; } return this._needsRTLAdaptations() ? itemsLength - index - 1 : index; } _getDataIndex(index) { const { data, loopClonesPerSide } = this.props; const dataLength = data && data.length; if (!this._enableLoop() || !dataLength) { return index; } if (index >= dataLength + loopClonesPerSide) { return loopClonesPerSide > dataLength ? (index - loopClonesPerSide) % dataLength : index - dataLength - loopClonesPerSide; } else if (index < loopClonesPerSide) { // TODO: is there a simpler way of determining the interpolated index? if (loopClonesPerSide > dataLength) { const baseDataIndexes = []; const dataIndexes = []; const dataMultiplier = Math.floor(loopClonesPerSide / dataLength); const remainder = loopClonesPerSide % dataLength; for (let i = 0; i < dataLength; i++) { baseDataIndexes.push(i); } for (let j = 0; j < dataMultiplier; j++) { dataIndexes.push(...baseDataIndexes); } dataIndexes.unshift(...baseDataIndexes.slice(-remainder)); return dataIndexes[index]; } else { return index + dataLength - loopClonesPerSide; } } else { return index - loopClonesPerSide; } } // Used with `snapToItem()` and 'PaginationDot' _getPositionIndex(index) { const { loop, loopClonesPerSide } = this.props; return loop ? index + loopClonesPerSide : index; } _getFirstItem(index, props = this.props) { const { loopClonesPerSide } = props; const itemsLength = this._getCustomDataLength(props); if (!itemsLength || index > itemsLength - 1 || index < 0) { return 0; } return this._enableLoop() ? index + loopClonesPerSide : index; } _getWrappedRef() { if ( this._carouselRef && ((this._needsScrollView() && this._carouselRef.scrollTo) || (!this._needsScrollView() && this._carouselRef.scrollToOffset)) ) { return this._carouselRef; } // https://github.com/facebook/react-native/issues/10635 // https://stackoverflow.com/a/48786374/8412141 return ( this._carouselRef && this._carouselRef.getNode && this._carouselRef.getNode() ); } _getScrollEnabled() { return this._scrollEnabled; } _setScrollEnabled(scrollEnabled = true) { const wrappedRef = this._getWrappedRef(); if (!wrappedRef || !wrappedRef.setNativeProps) { return; } // 'setNativeProps()' is used instead of 'setState()' because the latter // really takes a toll on Android behavior when momentum is disabled wrappedRef.setNativeProps({ scrollEnabled }); this._scrollEnabled = scrollEnabled; } _getKeyExtractor(item, index) { return this._needsScrollView() ? `scrollview-item-${index}` : `flatlist-item-${index}`; } _getScrollOffset(event) { const { vertical } = this.props; return ( (event && event.nativeEvent && event.nativeEvent.contentOffset && event.nativeEvent.contentOffset[vertical ? "y" : "x"]) || 0 ); } _getContainerInnerMargin(opposite = false) { const { sliderWidth, sliderHeight, itemWidth, itemHeight, vertical, activeSlideAlignment, } = this.props; if ( (activeSlideAlignment === "start" && !opposite) || (activeSlideAlignment === "end" && opposite) ) { return 0; } else if ( (activeSlideAlignment === "end" && !opposite) || (activeSlideAlignment === "start" && opposite) ) { return vertical ? sliderHeight - itemHeight : sliderWidth - itemWidth; } else { return vertical ? (sliderHeight - itemHeight) / 2 : (sliderWidth - itemWidth) / 2; } } _getViewportOffset() { const { sliderWidth, sliderHeight, itemWidth, itemHeight, vertical, activeSlideAlignment, } = this.props; if (activeSlideAlignment === "start") { return vertical ? itemHeight / 2 : itemWidth / 2; } else if (activeSlideAlignment === "end") { return vertical ? sliderHeight - itemHeight / 2 : sliderWidth - itemWidth / 2; } else { return vertical ? sliderHeight / 2 : sliderWidth / 2; } } _getCenter(offset) { return offset + this._getViewportOffset() - this._getContainerInnerMargin(); } _getActiveItem(offset) { const { activeSlideOffset, swipeThreshold } = this.props; const center = this._getCenter(offset); const centerOffset = activeSlideOffset || swipeThreshold; for (let i = 0; i < this._positions.length; i++) { const { start, end } = this._positions[i]; if (center + centerOffset >= start && center - centerOffset <= end) { return i; } } const lastIndex = this._positions.length - 1; if ( this._positions[lastIndex] && center - centerOffset > this._positions[lastIndex].end ) { return lastIndex; } return 0; } _initPositionsAndInterpolators(props = this.props) { const { data, itemWidth, itemHeight, scrollInterpolator, vertical } = props; const sizeRef = vertical ? itemHeight : itemWidth; if (!data || !data.length) { return; } const interpolators = []; this._positions = []; this._getCustomData(props).forEach((itemData, index) => { const _index = this._getCustomIndex(index, props); let animatedValue; this._positions[index] = { start: index * sizeRef, end: index * sizeRef + sizeRef, }; if (!this._shouldAnimateSlides(props)) { animatedValue = new Animated.Value(1); } else if (this._shouldUseCustomAnimation()) { animatedValue = new Animated.Value(_index === this._activeItem ? 1 : 0); } else { let interpolator; if (scrollInterpolator) { interpolator = scrollInterpolator(_index, props); } else if (this._shouldUseStackLayout()) { interpolator = stackScrollInterpolator(_index, props); } else if (this._shouldUseTinderLayout()) { interpolator = tinderScrollInterpolator(_index, props); } if ( !interpolator || !interpolator.inputRange || !interpolator.outputRange ) { interpolator = defaultScrollInterpolator(_index, props); } animatedValue = this._scrollPos.interpolate({ ...interpolator, extrapolate: "clamp", }); } interpolators.push(animatedValue); }); this.setState({ interpolators }); } _getSlideAnimation(index, toValue) { const { interpolators } = this.state; const { activeAnimationType, activeAnimationOptions } = this.props; const animatedValue = interpolators && interpolators[index]; if (!animatedValue && animatedValue !== 0) { return null; } const animationCommonOptions = { isInteraction: false, useNativeDriver: true, ...activeAnimationOptions, toValue: toValue, }; return Animated.parallel([ Animated["timing"](animatedValue, { ...animationCommonOptions, easing: Easing.linear, }), Animated[activeAnimationType](animatedValue, { ...animationCommonOptions, }), ]); } _playCustomSlideAnimation(current, next) { const { interpolators } = this.state; const itemsLength = this._getCustomDataLength(); const _currentIndex = this._getCustomIndex(current); const _currentDataIndex = this._getDataIndex(_currentIndex); const _nextIndex = this._getCustomIndex(next); const _nextDataIndex = this._getDataIndex(_nextIndex); const animations = []; // Keep animations in sync when looping if (this._enableLoop()) { for (let i = 0; i < itemsLength; i++) { if (this._getDataIndex(i) === _currentDataIndex && interpolators[i]) { animations.push(this._getSlideAnimation(i, 0)); } else if ( this._getDataIndex(i) === _nextDataIndex && interpolators[i] ) { animations.push(this._getSlideAnimation(i, 1)); } } } else { if (interpolators[current]) { animations.push(this._getSlideAnimation(current, 0)); } if (interpolators[next]) { animations.push(this._getSlideAnimation(next, 1)); } } Animated.parallel(animations, { stopTogether: false }).start(); } _hackActiveSlideAnimation(index, goTo, force = false) { const { data } = this.props; if ( !this._mounted || !this._carouselRef || !this._positions[index] || (!force && this._enableLoop()) ) { return; } const offset = this._positions[index] && this._positions[index].start; if (!offset && offset !== 0) { return; } const itemsLength = data && data.length; const direction = goTo || itemsLength === 1 ? "start" : "end"; this._scrollTo(offset + (direction === "start" ? -1 : 1), false); clearTimeout(this._hackSlideAnimationTimeout); this._hackSlideAnimationTimeout = setTimeout(() => { this._scrollTo(offset, false); }, 50); // works randomly when set to '0' } _lockScroll() { const { lockScrollTimeoutDuration } = this.props; clearTimeout(this._lockScrollTimeout); this._lockScrollTimeout = setTimeout(() => { this._releaseScroll(); }, lockScrollTimeoutDuration); this._setScrollEnabled(false); } _releaseScroll() { clearTimeout(this._lockScrollTimeout); this._setScrollEnabled(true); } _repositionScroll(index) { const { data, loopClonesPerSide } = this.props; const dataLength = data && data.length; if ( !this._enableLoop() || !dataLength || (index >= loopClonesPerSide && index < dataLength + loopClonesPerSide) ) { return; } let repositionTo = index; if (index >= dataLength + loopClonesPerSide) { repositionTo = index - dataLength; } else if (index < loopClonesPerSide) { repositionTo = index + dataLength; } this._snapToItem(repositionTo, false, false, false, false); } _scrollTo(offset, animated = true) { const { vertical } = this.props; const wrappedRef = this._getWrappedRef(); if (!this._mounted || !wrappedRef) { return; } const specificOptions = this._needsScrollView() ? { x: vertical ? 0 : offset, y: vertical ? offset : 0, } : { offset, }; const options = { ...specificOptions, animated, }; if (this._needsScrollView()) { wrappedRef.scrollTo(options); } else { wrappedRef.scrollToOffset(options); } } _onScroll(event?) { const { callbackOffsetMargin, enableMomentum, onScroll } = this.props; const scrollOffset = event ? this._getScrollOffset(event) : this._currentContentOffset; const nextActiveItem = this._getActiveItem(scrollOffset); const itemReached = nextActiveItem === this._itemToSnapTo; const scrollConditions = scrollOffset >= this._scrollOffsetRef - callbackOffsetMargin && scrollOffset <= this._scrollOffsetRef + callbackOffsetMargin; this._currentContentOffset = scrollOffset; this._onScrollTriggered = true; this._lastScrollDate = Date.now(); if ( this._activeItem !== nextActiveItem && this._shouldUseCustomAnimation() ) { this._playCustomSlideAnimation(this._activeItem, nextActiveItem); } if (enableMomentum) { clearTimeout(this._snapNoMomentumTimeout); if (this._activeItem !== nextActiveItem) { this._activeItem = nextActiveItem; } if (itemReached) { if (this._canFireBeforeCallback) { this._onBeforeSnap(this._getDataIndex(nextActiveItem)); } if (scrollConditions && this._canFireCallback) { this._onSnap(this._getDataIndex(nextActiveItem)); } } } else if (this._activeItem !== nextActiveItem && itemReached) { if (this._canFireBeforeCallback) { this._onBeforeSnap(this._getDataIndex(nextActiveItem)); } if (scrollConditions) { this._activeItem = nextActiveItem; if (this._canLockScroll()) { this._releaseScroll(); } if (this._canFireCallback) { this._onSnap(this._getDataIndex(nextActiveItem)); } } } if ( nextActiveItem === this._itemToSnapTo && scrollOffset === this._scrollOffsetRef ) { this._repositionScroll(nextActiveItem); } if (typeof onScroll === "function" && event) { onScroll(event); } } _onStartShouldSetResponderCapture(event) { const { onStartShouldSetResponderCapture } = this.props; if (onStartShouldSetResponderCapture) { onStartShouldSetResponderCapture(event); } return this._getScrollEnabled(); } _onTouchStart() { const { onTouchStart } = this.props; // `onTouchStart` is fired even when `scrollEnabled` is set to `false` if (this._getScrollEnabled() !== false && this._autoplaying) { this.pauseAutoPlay(); } if (onTouchStart) { onTouchStart(); } } _onTouchEnd() { const { onTouchEnd } = this.props; if ( this._getScrollEnabled() !== false && this._autoplay && !this._autoplaying ) { // This event is buggy on Android, so a fallback is provided in _onScrollEnd() this.startAutoplay(); } if (onTouchEnd) { onTouchEnd(); } } // Used when `enableSnap` is ENABLED _onScrollBeginDrag(event) { const { onScrollBeginDrag } = this.props; if (!this._getScrollEnabled()) { return; } this._scrollStartOffset = this._getScrollOffset(event); this._scrollStartActive = this._getActiveItem(this._scrollStartOffset); this._ignoreNextMomentum = false; // this._canFireCallback = false; if (onScrollBeginDrag) { onScrollBeginDrag(event); } } // Used when `enableMomentum` is DISABLED _onScrollEndDrag(event) { const { onScrollEndDrag } = this.props; if (this._carouselRef) { this._onScrollEnd && this._onScrollEnd(); } if (onScrollEndDrag) { onScrollEndDrag(event); } } // Used when `enableMomentum` is ENABLED _onMomentumScrollEnd(event) { const { onMomentumScrollEnd } = this.props; if (this._carouselRef) { this._onScrollEnd && this._onScrollEnd(); } if (onMomentumScrollEnd) { onMomentumScrollEnd(event); } } _onScrollEnd(event?) { const { autoplayDelay, enableSnap } = this.props; if (this._ignoreNextMomentum) { // iOS fix this._ignoreNextMomentum = false; return; } if (this._currentContentOffset === this._scrollEndOffset) { return; } this._scrollEndOffset = this._currentContentOffset; this._scrollEndActive = this._getActiveItem(this._scrollEndOffset); if (enableSnap) { this._snapScroll(this._scrollEndOffset - this._scrollStartOffset); } // The touchEnd event is buggy on Android, so this will serve as a fallback whenever needed // https://github.com/facebook/react-native/issues/9439 if (this._autoplay && !this._autoplaying) { clearTimeout(this._enableAutoplayTimeout); this._enableAutoplayTimeout = setTimeout(() => { this.startAutoplay(); }, autoplayDelay + 50); } } // Due to a bug, this event is only fired on iOS // https://github.com/facebook/react-native/issues/6791 // it's fine since we're only fixing an iOS bug in it, so ... _onTouchRelease(event) { const { enableMomentum } = this.props; if (enableMomentum && IS_IOS) { clearTimeout(this._snapNoMomentumTimeout); this._snapNoMomentumTimeout = setTimeout(() => { this._snapToItem(this._activeItem); }, 100); } } _onLayout(event) { const { onLayout } = this.props; // Prevent unneeded actions during the first 'onLayout' (triggered on init) if (this._onLayoutInitDone) { this._initPositionsAndInterpolators(); this._snapToItem(this._activeItem, false, false, false, false); } else { this._onLayoutInitDone = true; } if (onLayout) { onLayout(event); } } _snapScroll(delta) { const { swipeThreshold } = this.props; // When using momentum and releasing the touch with // no velocity, scrollEndActive will be undefined (iOS) if (!this._scrollEndActive && this._scrollEndActive !== 0 && IS_IOS) { this._scrollEndActive = this._scrollStartActive; } if (this._scrollStartActive !== this._scrollEndActive) { // Snap to the new active item this._snapToItem(this._scrollEndActive); } else { // Snap depending on delta if (delta > 0) { if (delta > swipeThreshold) { this._snapToItem(this._scrollStartActive + 1); } else { this._snapToItem(this._scrollEndActive); } } else if (delta < 0) { if (delta < -swipeThreshold) { this._snapToItem(this._scrollStartActive - 1); } else { this._snapToItem(this._scrollEndActive); } } else { // Snap to current this._snapToItem(this._scrollEndActive); } } } _snapToItem( index, animated = true, fireCallback = true, initial = false, lockScroll = true ) { const { enableMomentum, onSnapToItem, onBeforeSnapToItem } = this.props; const itemsLength = this._getCustomDataLength(); const wrappedRef = this._getWrappedRef(); if (!itemsLength || !wrappedRef) { return; } if (!index || index < 0) { index = 0; } else if (itemsLength > 0 && index >= itemsLength) { index = itemsLength - 1; } if (index !== this._previousActiveItem) { this._previousActiveItem = index; // Placed here to allow overscrolling for edges items if (lockScroll && this._canLockScroll()) { this._lockScroll(); } if (fireCallback) { if (onBeforeSnapToItem) { this._canFireBeforeCallback = true; } if (onSnapToItem) { this._canFireCallback = true; } } } this._itemToSnapTo = index; this._scrollOffsetRef = this._positions[index] && this._positions[index].start; this._onScrollTriggered = false; if (!this._scrollOffsetRef && this._scrollOffsetRef !== 0) { return; } this._scrollTo(this._scrollOffsetRef, animated); this._scrollEndOffset = this._currentContentOffset; if (enableMomentum) { // iOS fix, check the note in the constructor if (!initial) { this._ignoreNextMomentum = true; } // When momentum is enabled and the user is overscrolling or swiping very quickly, // 'onScroll' is not going to be triggered for edge items. Then callback won't be // fired and loop won't work since the scrollview is not going to be repositioned. // As a workaround, '_onScroll()' will be called manually for these items if a given // condition hasn't been met after a small delay. // WARNING: this is ok only when relying on 'momentumScrollEnd', not with 'scrollEndDrag' if (index === 0 || index === itemsLength - 1) { clearTimeout(this._edgeItemTimeout); this._edgeItemTimeout = setTimeout(() => { if ( !initial && index === this._activeItem && !this._onScrollTriggered ) { this._onScroll(); } }, 250); } } } _onBeforeSnap(index) { const { onBeforeSnapToItem } = this.props; if (!this._carouselRef) { return; } this._canFireBeforeCallback = false; onBeforeSnapToItem && onBeforeSnapToItem(index); } _onSnap(index) { const { onSnapToItem } = this.props; if (!this._carouselRef) { return; } this._canFireCallback = false; onSnapToItem && onSnapToItem(index); } startAutoplay() { const { autoplayInterval, autoplayDelay } = this.props; this._autoplay = true; if (this._autoplaying) { return; } clearTimeout(this._autoplayTimeout); this._autoplayTimeout = setTimeout(() => { this._autoplaying = true; this._autoplayInterval = setInterval(() => { if (this._autoplaying) { this.snapToNext(); } }, autoplayInterval); }, autoplayDelay); } pauseAutoPlay() { this._autoplaying = false; clearTimeout(this._autoplayTimeout); clearTimeout(this._enableAutoplayTimeout); clearInterval(this._autoplayInterval); } stopAutoplay() { this._autoplay = false; this.pauseAutoPlay(); } snapToItem(index, animated = true, fireCallback = true) { if (!index || index < 0) { index = 0; } const positionIndex = this._getPositionIndex(index); if (positionIndex === this._activeItem) { return; } this._snapToItem(positionIndex, animated, fireCallback); } snapToNext(animated = true, fireCallback = true) { const itemsLength = this._getCustomDataLength(); let newIndex = this._activeItem + 1; if (newIndex > itemsLength - 1) { if (!this._enableLoop()) { return; } newIndex = 0; } this._snapToItem(newIndex, animated, fireCallback); } snapToPrev(animated = true, fireCallback = true) { const itemsLength = this._getCustomDataLength(); let newIndex = this._activeItem - 1; if (newIndex < 0) { if (!this._enableLoop()) { return; } newIndex = itemsLength - 1; } this._snapToItem(newIndex, animated, fireCallback); } // https://github.com/facebook/react-native/issues/1831#issuecomment-231069668 triggerRenderingHack(offset) { // Avoid messing with user scroll if (Date.now() - this._lastScrollDate < 500) { return; } const scrollPosition = this._currentContentOffset; if (!scrollPosition && scrollPosition !== 0) { return; } const scrollOffset = offset || (scrollPosition === 0 ? 1 : -1); this._scrollTo(scrollPosition + scrollOffset, false); } _getSlideInterpolatedStyle(index, animatedValue) { const { layoutCardOffset, slideInterpolatedStyle } = this.props; if (slideInterpolatedStyle) { return slideInterpolatedStyle(index, animatedValue, this.props); } else if (this._shouldUseTinderLayout()) { return tinderAnimatedStyles( index, animatedValue, this.props, layoutCardOffset ); } else if (this._shouldUseStackLayout()) { return stackAnimatedStyles( index, animatedValue, this.props, layoutCardOffset ); } else if (this._shouldUseShiftLayout()) { return shiftAnimatedStyles(index, animatedValue, this.props); } else { return defaultAnimatedStyles(index, animatedValue, this.props); } } _renderItem({ item, index }) { const { interpolators } = this.state; const { hasParallaxImages, itemWidth, itemHeight, keyExtractor, renderItem, sliderHeight, sliderWidth, slideStyle, vertical, } = this.props; const animatedValue = interpolators && interpolators[index]; if (!animatedValue && animatedValue !== 0) { return null; } const animate = this._shouldAnimateSlides(); const Component = animate ? Animated.View : View; const animatedStyle = animate ? this._getSlideInterpolatedStyle(index, animatedValue) : {}; const parallaxProps = hasParallaxImages ? { scrollPosition: this._scrollPos, carouselRef: this._carouselRef, vertical, sliderWidth, sliderHeight, itemWidth, itemHeight, } : undefined; const mainDimension = vertical ? { height: itemHeight } : { width: itemWidth }; const specificProps = this._needsScrollView() ? { key: keyExtractor // @ts-ignore ? keyExtractor(item, index) : this._getKeyExtractor(item, index), } : {}; return ( {renderItem({ item, index }, parallaxProps)} ); } _getComponentOverridableProps = (): any => { const { enableMomentum, itemWidth, itemHeight, loopClonesPerSide, sliderWidth, sliderHeight, vertical, } = this.props; const visibleItems = Math.ceil( vertical ? sliderHeight / itemHeight : sliderWidth / itemWidth ) + 1; const initialNumPerSide = this._enableLoop() ? loopClonesPerSide : 2; const initialNumToRender = visibleItems + initialNumPerSide * 2; const maxToRenderPerBatch = 1 + initialNumToRender * 2; const windowSize = maxToRenderPerBatch; const specificProps = !this._needsScrollView() ? { initialNumToRender: initialNumToRender, maxToRenderPerBatch: maxToRenderPerBatch, windowSize: windowSize, // updateCellsBatchingPeriod } : {}; return { decelerationRate: enableMomentum ? 0.9 : "fast", showsHorizontalScrollIndicator: false, showsVerticalScrollIndicator: false, overScrollMode: "never", automaticallyAdjustContentInsets: false, directionalLockEnabled: true, pinchGestureEnabled: false, scrollsToTop: false, removeClippedSubviews: !this._needsScrollView(), inverted: this._needsRTLAdaptations(), // renderToHardwareTextureAndroid: true, ...specificProps, }; } _getComponentStaticProps() { const { hideCarousel } = this.state; const { containerCustomStyle, contentContainerCustomStyle, keyExtractor, sliderWidth, sliderHeight, style, vertical, } = this.props; const containerStyle = [ containerCustomStyle || style || {}, hideCarousel ? { opacity: 0 } : {}, vertical ? { height: sliderHeight, flexDirection: "column" } : // LTR hack; see https://github.com/facebook/react-native/issues/11960 // and https://github.com/facebook/react-native/issues/13100#issuecomment-328986423 { width: sliderWidth, flexDirection: this._needsRTLAdaptations() ? "row-reverse" : "row", }, ]; const contentContainerStyle = [ vertical ? { paddingTop: this._getContainerInnerMargin(), paddingBottom: this._getContainerInnerMargin(true), } : { paddingLeft: this._getContainerInnerMargin(), paddingRight: this._getContainerInnerMargin(true), }, contentContainerCustomStyle || {}, ]; const specificProps = !this._needsScrollView() ? { // extraData: this.state, renderItem: this._renderItem, numColumns: 1, keyExtractor: keyExtractor || this._getKeyExtractor, } : {}; return { ref: c => (this._carouselRef = c), data: this._getCustomData(), style: containerStyle, contentContainerStyle: contentContainerStyle, horizontal: !vertical, scrollEventThrottle: 1, onScroll: this._onScrollHandler, onScrollBeginDrag: this._onScrollBeginDrag, onScrollEndDrag: this._onScrollEndDrag, onMomentumScrollEnd: this._onMomentumScrollEnd, onResponderRelease: this._onTouchRelease, onStartShouldSetResponderCapture: this._onStartShouldSetResponderCapture, onTouchStart: this._onTouchStart, onTouchEnd: this._onScrollEnd, onLayout: this._onLayout, ...specificProps, }; } render() { const { data, renderItem, useScrollView } = this.props; if (!data || !renderItem) { return null; } const props = { ...this._getComponentOverridableProps(), ...this.props, ...this._getComponentStaticProps(), }; const ScrollViewComponent = typeof useScrollView === "function" ? useScrollView : AnimatedScrollView; return this._needsScrollView() ? ( // @ts-ignore {this._getCustomData().map((item, index) => { return this._renderItem({ item, index }); })} ) : ( // @ts-ignore ); } }