diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d16602fe..7287ca974 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,17 @@ +## v3.2.0 +* Refactor callback handling. **Make sure to use the new prop `callbackOffsetMargin` if you experience missed callbacks.** +* Make item's scale and opacity animation follow scroll value (thanks [@hammadj](https://github.com/hammadj)) +* `Pagination` component: make dots tappable with new props `tappableDots` and `carouselRef` (see the [example](https://github.com/archriss/react-native-snap-carousel/blob/master/example/src/index.js)) +* Fix state and scroll issues when the currently active item is being dynamically removed +* Improve snap feeling when momentum is disabled (default) +* Add prop `callbackOffsetMargin` +* Remove props `animationFunc`, `animationOptions`, `scrollEndDragDebounceValue`, `snapOnAndroid`, and `useNativeOnScroll` + ## v3.1.0 * `Pagination` component: add new props for advanced customization ## v3.0.0 -###WARNING +### WARNING * **Do not use this version as some temporary code was pushed to `npm` by mistake. Make sure to use version `3.1.0` instead.** ### Breaking changes * Plugin is now built on top of `FlatList`, which allows for huge performance optimizations. From now on, items must be rendered using props `data` and `renderItem`. diff --git a/README.md b/README.md index dbcecf84b..0f1c9f97d 100644 --- a/README.md +++ b/README.md @@ -164,17 +164,15 @@ Prop | Description | Type | Default Prop | Description | Type | Default ------ | ------ | ------ | ------ -`activeSlideOffset` | From slider's center, minimum slide distance to be scrolled before being set to active | Number | `25` +`activeSlideOffset` | From slider's center, minimum slide distance to be scrolled before being set to active. | Number | `20` `apparitionDelay` | `FlatList`'s init is a real mess, with lots of unneeded flickers and slides movement. This prop controls the delay during which the carousel will be hidden when mounted. | Number | `250` +`callbackOffsetMargin` | Scroll events might not be triggered often enough to get a precise measure and, therefore, to provide a reliable callback. This usually is an Android issue, which might be linked to the version of React Native you're using (see ["Unreliable callbacks"](#unreliable-callbacks)). To work around this, you can define a small margin that will increase the "sweet spot"'s width. The default value should cover most cases, but **you will want to increase it if you experience missed callbacks**. | Number | `5` `enableMomentum` | See [momentum](#momentum) | Boolean | `false` `enableSnap` | If enabled, releasing the touch will scroll to the center of the nearest/active item | Boolean | `true` `firstItem` | Index of the first item to display | Number | `0` `hasParallaxImages` | Whether the carousel contains `` components or not. Required for specific data to be passed to children. | Boolean | `false` -`scrollEndDragDebounceValue` | **When momentum is disabled**, this prop defines the timeframe during which multiple callback calls should be "grouped" into a single one. This debounce also helps smoothing the snap effect by providing a bit of inertia when touch is released. **Note that this will delay callback's execution.** | Number | `50` for iOS, `150` for Android `shouldOptimizeUpdates` | Whether to implement a `shouldComponentUpdate` strategy to minimize updates | Boolean | `true` -`snapOnAndroid` | Snapping on android is sometimes choppy, especially when swiping quickly, so you can disable it | Boolean | `true` `swipeThreshold` | Delta x when swiping to trigger the snap | Number | `20` -`useNativeOnScroll` | Move `onScroll` events to the native thread in order to prevent the tiny lag associated with RN's JS bridge. **Activate this if you have a `transform` and/or `opacity` animation that needs to follow carousel's scroll position closely**. More info in [this post](https://facebook.github.io/react-native/blog/2017/02/14/using-native-driver-for-animated.html). Note that it will be activated if `hasParallaxImages` is set to `true` and/or if `scrollEventThrottle` is set to less than `16`. | Boolean | `false` `vertical` | Layout slides vertically instead of horizontally | Boolean | `false` ### Autoplay @@ -190,8 +188,6 @@ Prop | Description | Type | Default Prop | Description | Type | Default ------ | ------ | ------ | ------ `activeSlideAlignment` | Determine active slide's alignment relative to the carousel. Possible values are: `'start'`, `'center'` and `'end'`. | String | `'center'` -`animationFunc` | Animated animation to use; you must provide the name of the method. Note that it will only be applied to the scale animation since opacity's animation type will always be set to `timing` (no one wants the opacity to 'bounce' around) | String | `timing` -`animationOptions` | Animation options to be merged with the default ones. Can be used without `animationFunc`. Note that opacity's easing will be kept linear. | Object | `{ duration: 600, easing: Easing.elastic(1) }` `containerCustomStyle` | Optional styles for Scrollview's global wrapper | View Style Object | `{}` `contentContainerCustomStyle` | Optional styles for Scrollview's items container | View Style Object | `{}` `inactiveSlideOpacity` | Value of the opacity effect applied to inactive slides | Number | `0.7` @@ -321,6 +317,14 @@ const styles = Stylesheet.create({ ``` +### Carousel's stretched height + +Since `` is, ultimately, based on ``, it inherits [its default styles](https://github.com/facebook/react-native/blob/master/Libraries/Components/ScrollView/ScrollView.js#L864) and particularly `{ flexGrow: 1 }`. This means that, by default, **the carousel container will stretch to fill up all available space**. + +If this is not what you're after, you can prevent this behavior by passing `{ flexGrow: 0 }` to prop `containerCustomStyle`. + +Alternatively, you can either use this prop to pass a custom height to the container, or wrap the carousel in a `` with a fixed height. + ### Understanding styles Here is a screenshot that should help you understand how each of the above variables is used. @@ -400,6 +404,10 @@ export class MyCarousel extends Component { [This plugin](https://github.com/shichongrui/react-native-on-layout) can also prove useful. +### Native-powered animations + +Scroll events have been moved to the native thread in order to prevent the tiny lag associated with React Native's JavaScript bridge. This is really useful when displaying a `transform` and/or `opacity` animation that needs to follow carousel's scroll position closely. You can find more info in [this post from Facebook](https://facebook.github.io/react-native/blog/2017/02/14/using-native-driver-for-animated.html). + ## Known issues ### React Native version @@ -427,9 +435,13 @@ We're trying to work around these issues, but the result is not always as smooth ### Unreliable callbacks -When `enableMomentum` is disabled, providing a reliable callback is really tricky since no `scrollEnd` event has been exposed yet for the `ScrollView` component. We can only rely on the `scrollEndDrag` event, which comes with a huge bunch of issues. See [#34](https://github.com/archriss/react-native-snap-carousel/issues/34) for more information. +When `enableMomentum` is disabled (default behavior), providing a reliable callback is really tricky since no `scrollEnd` event has been exposed yet for the `ScrollView` component. We can only rely on the `scrollEndDrag` event, which comes with a huge bunch of issues. See [#34](https://github.com/archriss/react-native-snap-carousel/issues/34) for more information. + +Version 2.3.0 tackled these issues with all sorts of flags and hacks. But you could still be facing the following one: **when you build a debug version of your app without enabling JS remote debugging, timers may desynchronize and cause a complete callback mess**. Try to either enable remote debugging or build a production version of your app, and everything should get back to normal. + +Callback handling has been completely revamped in version 3.2.0, in a less hacky and more reliable way. There is one issue though: callbacks now rely on scroll events. Usually, this is not a problem since the plugin features a native-powered scroll. **But there has been [a regression in React Native 0.46.x](https://github.com/facebook/react-native/issues/15769), that has been fixed in version 0.48.2.** -Version 2.3.0 tackled these issues with a bunch of flags and hacks. But you could still be facing the following one: **when you build a debug version of your app without enabling JS remote debugging**, timers will desynchronize and callbacks will be a complete mess. Try to either enable remote debugging or build a production version of your app, and everything should get back to normal. +If you're using an in-between version, you're in for some trouble since events won't be fired frequently enough (particularly on Android). **We've added a prop `callbackOffsetMargin` to help with this situation.** ### Error with Jest @@ -447,6 +459,7 @@ As such, this feature should be considered experimental since it might break wit ## TODO +- [ ] Implement a custom `PanResponder` for better control over carousel's callbacks and overall feeling - [ ] Implement 'loop' mode - [ ] Handle changing major props on-the-fly - [ ] Handle autoplay properly when updating children's length diff --git a/example/package.json b/example/package.json index f0c5dc1cf..0e88a4302 100644 --- a/example/package.json +++ b/example/package.json @@ -7,11 +7,11 @@ }, "dependencies": { "react": "16.0.0-alpha.12", - "react-native": "~0.47.2", + "react-native": "~0.48.3", "react-native-linear-gradient": "^2.3.0", "react-native-snap-carousel": "file:../" }, "devDependencies": { - "babel-preset-react-native": "3.0.1" + "babel-preset-react-native": "3.0.2" } } diff --git a/package.json b/package.json index 4f5940cf9..36e013669 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-snap-carousel", - "version": "3.1.0", + "version": "3.2.0", "description": "Swiper component for React Native with previews, snapping effect, parallax images, performant handling of huge numbers of items, and RTL support. Compatible with Android & iOS.", "main": "src/index.js", "repository": { @@ -31,7 +31,6 @@ "author": "Archriss (github.com/archriss)", "license": "ISC", "dependencies": { - "lodash.debounce": "4.0.8", "prop-types": "^15.5.10", "react-addons-shallow-compare": "15.6.0" }, diff --git a/src/carousel/Carousel.js b/src/carousel/Carousel.js index 49f879fff..11a9bfb81 100644 --- a/src/carousel/Carousel.js +++ b/src/carousel/Carousel.js @@ -2,7 +2,6 @@ import React, { Component } from 'react'; import { View, FlatList, Animated, Platform, I18nManager, ViewPropTypes } from 'react-native'; import PropTypes from 'prop-types'; import shallowCompare from 'react-addons-shallow-compare'; -import _debounce from 'lodash.debounce'; const IS_IOS = Platform.OS === 'ios'; @@ -32,6 +31,7 @@ export default class Carousel extends Component { autoplay: PropTypes.bool, autoplayDelay: PropTypes.number, autoplayInterval: PropTypes.number, + callbackOffsetMargin: PropTypes.number, containerCustomStyle: ViewPropTypes ? ViewPropTypes.style : View.propTypes.style, contentContainerCustomStyle: ViewPropTypes ? ViewPropTypes.style : View.propTypes.style, enableMomentum: PropTypes.bool, @@ -40,23 +40,21 @@ export default class Carousel extends Component { hasParallaxImages: PropTypes.bool, inactiveSlideOpacity: PropTypes.number, inactiveSlideScale: PropTypes.number, - scrollEndDragDebounceValue: PropTypes.number, slideStyle: Animated.View.propTypes.style, shouldOptimizeUpdates: PropTypes.bool, - snapOnAndroid: PropTypes.bool, swipeThreshold: PropTypes.number, - useNativeOnScroll: PropTypes.bool, vertical: PropTypes.bool, onSnapToItem: PropTypes.func }; static defaultProps = { activeSlideAlignment: 'center', - activeSlideOffset: 25, + activeSlideOffset: 20, apparitionDelay: 250, autoplay: false, autoplayDelay: 5000, autoplayInterval: 3000, + callbackOffsetMargin: 5, containerCustomStyle: {}, contentContainerCustomStyle: {}, enableMomentum: false, @@ -65,12 +63,9 @@ export default class Carousel extends Component { hasParallaxImages: false, inactiveSlideOpacity: 0.7, inactiveSlideScale: 0.9, - scrollEndDragDebounceValue: IS_IOS ? 50 : 150, slideStyle: {}, shouldOptimizeUpdates: true, - snapOnAndroid: true, swipeThreshold: 20, - useNativeOnScroll: false, vertical: false } @@ -89,51 +84,37 @@ export default class Carousel extends Component { this._positions = []; this._currentContentOffset = 0; // store ScrollView's scroll position - this._hasFiredEdgeItemCallback = true; // deal with overscroll and callback this._canFireCallback = false; // used only when `enableMomentum` is set to `false` - this._isShortSnapping = false; // used only when `enableMomentum` is set to `false` + this._callbackScrollOffset = null; // used only when `enableMomentum` is set to `false` - this._initInterpolators = this._initInterpolators.bind(this); this._getItemLayout = this._getItemLayout.bind(this); + 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._onScrollEnd = this._snapEnabled || props.autoplay ? this._onScrollEnd.bind(this) : undefined; - this._onScrollBeginDrag = this._snapEnabled ? this._onScrollBeginDrag.bind(this) : undefined; + 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._onTouchRelease = this._onTouchRelease.bind(this); - this._onLayout = this._onLayout.bind(this); - this._onSnap = this._onSnap.bind(this); // Native driver for scroll events - if (this._shouldUseNativeOnScroll(props)) { - const scrollEventConfig = { - listener: this._onScroll, - useNativeDriver: true - }; - this._scrollPos = new Animated.Value(0); - this._onScrollHandler = props.vertical ? - Animated.event( - [{ nativeEvent: { contentOffset: { y: this._scrollPos } } }], - scrollEventConfig - ) : Animated.event( - [{ nativeEvent: { contentOffset: { x: this._scrollPos } } }], - scrollEventConfig - ); - } else { - this._onScrollHandler = this._onScroll; - } - - // Debounce `_onScrollEndDrag` execution - // This aims at improving snap feeling and callback reliability - this._onScrollEndDragDebounced = !props.scrollEndDragDebounceValue ? - this._onScrollEndDragDebounced.bind(this) : - _debounce( - this._onScrollEndDragDebounced, - props.scrollEndDragDebounceValue, - { leading: false, trailing: true } - ).bind(this); + const scrollEventConfig = { + listener: this._onScroll, + useNativeDriver: true + }; + this._scrollPos = new Animated.Value(0); + this._onScrollHandler = props.vertical ? + Animated.event( + [{ nativeEvent: { contentOffset: { y: this._scrollPos } } }], + scrollEventConfig + ) : Animated.event( + [{ nativeEvent: { contentOffset: { x: this._scrollPos } } }], + scrollEventConfig + ); // This bool aims at fixing an iOS bug due to scrollTo that triggers onMomentumScrollEnd. // onMomentumScrollEnd fires this._snapScroll, thus creating an infinite loop. @@ -155,23 +136,18 @@ export default class Carousel extends Component { } componentDidMount () { - const { firstItem, autoplay, apparitionDelay } = this.props; - const _firstItem = this._getFirstItem(firstItem); - - this._initInterpolators(this.props); - - setTimeout(() => { - this.snapToItem(_firstItem, false, false, true); + const { apparitionDelay } = this.props; - if (autoplay) { - this.startAutoplay(); - } - }, 0); + this._initPositionsAndInterpolators(); - // hide FlatList's awful init - setTimeout(() => { - this.setState({ hideCarousel: false }); - }, apparitionDelay); + if (apparitionDelay) { + // Hide FlatList's awful init + this._apparitionTimeout = setTimeout(() => { + this._didMountDelayedInit(); + }, apparitionDelay); + } else { + this._didMountDelayedInit(); + } } shouldComponentUpdate (nextProps, nextState) { @@ -184,7 +160,7 @@ export default class Carousel extends Component { componentWillReceiveProps (nextProps) { const { activeItem, interpolators, previousFirstItem, previousItemsLength } = this.state; - const { data, firstItem, itemHeight, itemWidth, sliderHeight, sliderWidth, vertical } = nextProps; + const { data, firstItem, itemHeight, itemWidth, sliderHeight, sliderWidth } = nextProps; const itemsLength = data.length; @@ -200,7 +176,7 @@ export default class Carousel extends Component { const hasNewItemWidth = itemWidth && itemWidth !== this.props.itemWidth; const hasNewItemHeight = itemHeight && itemHeight !== this.props.itemHeight; - // Prevent issue with dynamically removed items + // Prevent issues with dynamically removed items if (nextActiveItem > itemsLength - 1) { nextActiveItem = itemsLength - 1; } @@ -211,22 +187,16 @@ export default class Carousel extends Component { activeItem: nextActiveItem, previousItemsLength: itemsLength }, () => { - this._positions = []; - this._calcCardPositions(nextProps); - this._initInterpolators(nextProps); - - // Handle state and scroll issue when dynamically removing items (see #133) - const itemRemoved = previousItemsLength > itemsLength; - - if (itemRemoved) { - // trigger scroll to hack item's active animation - this._flatlist.scrollToOffset({ - offset: this._positions[nextActiveItem].start + 1, - horizontal: !vertical, - animated: false - }); - this.snapToItem(nextActiveItem, false, true); - } else if (hasNewSliderWidth || hasNewSliderHeight || hasNewItemWidth || + this._initPositionsAndInterpolators(nextProps); + + // 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 (previousItemsLength > itemsLength) { + this._hackActiveSlideAnimation(nextActiveItem); + } + + if (hasNewSliderWidth || hasNewSliderHeight || hasNewItemWidth || hasNewItemHeight || (IS_RTL && !nextProps.vertical)) { this.snapToItem(nextActiveItem, false, false); } @@ -243,17 +213,14 @@ export default class Carousel extends Component { componentWillUnmount () { this.stopAutoplay(); + clearTimeout(this._apparitionTimeout); + clearTimeout(this._hackSlideAnimationTimeout); clearTimeout(this._enableAutoplayTimeout); clearTimeout(this._autoplayTimeout); clearTimeout(this._snapNoMomentumTimeout); clearTimeout(this._scrollToTimeout); } - get _snapEnabled () { - const { enableSnap, snapOnAndroid } = this.props; - return enableSnap && (IS_IOS || snapOnAndroid); - } - get currentIndex () { return this.state.activeItem; } @@ -267,13 +234,6 @@ export default class Carousel extends Component { return inactiveSlideOpacity < 1 || inactiveSlideScale < 1; } - _shouldUseNativeOnScroll (props = this.props) { - return props.useNativeOnScroll || - props.hasParallaxImages || - props.scrollEventThrottle < 16 || - this._shouldAnimateSlides(props); - } - _getCustomIndex (index, props = this.props) { const itemsLength = props.data && props.data.length; @@ -296,36 +256,42 @@ export default class Carousel extends Component { return index; } - _calcCardPositions (props = this.props) { - const { data, itemWidth, itemHeight, vertical } = props; - const sizeRef = vertical ? itemHeight : itemWidth; + _didMountDelayedInit () { + const { firstItem, autoplay } = this.props; + const _firstItem = this._getFirstItem(firstItem); - data.forEach((itemData, index) => { - const _index = this._getCustomIndex(index, props); - this._positions[index] = { - start: _index * sizeRef, - end: _index * sizeRef + sizeRef - }; - }); + this.snapToItem(_firstItem, false, false, true); + this._hackActiveSlideAnimation(_firstItem, 'start'); + this.setState({ hideCarousel: false }); + + if (autoplay) { + this.startAutoplay(); + } } - _initInterpolators (props = this.props) { + _initPositionsAndInterpolators (props = this.props) { const { data, itemWidth, itemHeight, vertical } = props; - const sizeRef = vertical ? itemHeight : itemWidth; + let interpolators = []; + this._positions = []; data.forEach((itemData, index) => { + const _index = this._getCustomIndex(index, props); const start = (index - 1) * sizeRef; const middle = index * sizeRef; const end = (index + 1) * sizeRef; - const value = this._shouldAnimateSlides(props) ? this._scrollPos.interpolate({ inputRange: [start, middle, end], outputRange: [0, 1, 0], extrapolate: 'clamp' }) : 1; + this._positions[index] = { + start: _index * sizeRef, + end: _index * sizeRef + sizeRef + }; + interpolators.push({ opacity: value, scale: value @@ -335,29 +301,53 @@ export default class Carousel extends Component { this.setState({ interpolators }); } - _getScrollOffset (event) { - const { vertical } = this.props; - return (event && event.nativeEvent && event.nativeEvent.contentOffset && - event.nativeEvent.contentOffset[vertical ? 'y' : 'x']) || 0; + _hackActiveSlideAnimation (index, goTo) { + const { data, vertical } = this.props; + + if (IS_IOS || !this._flatlist || !this._positions[index]) { + return; + } + + const itemsLength = data && data.length; + const direction = goTo || itemsLength === 1 ? 'start' : 'end'; + const offset = this._positions[index].start; + const commonOptions = { + horizontal: !vertical, + animated: false + }; + + this._flatlist.scrollToOffset({ + offset: offset + (direction === 'start' ? -1 : 1), + ...commonOptions + }); + + this._hackSlideAnimationTimeout = setTimeout(() => { + this._flatlist.scrollToOffset({ + offset: offset, + ...commonOptions + }); + }, 50); // works randomly when set to '0' } - _getActiveItem (offset) { - const { activeSlideOffset } = this.props; - const center = this._getCenter(offset); + _getKeyExtractor (item, index) { + return `carousel-item-${index}`; + } - for (let i = 0; i < this._positions.length; i++) { - const { start, end } = this._positions[i]; - if (center + activeSlideOffset >= start && center - activeSlideOffset <= end) { - return i; - } - } + _getItemLayout (data, index) { + const { itemWidth, itemHeight, vertical } = this.props; + const itemSize = vertical ? itemHeight : itemWidth; - const lastIndex = this._positions.length - 1; - if (this._positions[lastIndex] && center - activeSlideOffset > this._positions[lastIndex].end) { - return lastIndex; - } + return { + length: itemSize, + offset: itemSize * index, + index + }; + } - return 0; + _getScrollOffset (event) { + const { vertical } = this.props; + return (event && event.nativeEvent && event.nativeEvent.contentOffset && + event.nativeEvent.contentOffset[vertical ? 'y' : 'x']) || 0; } _getContainerInnerMargin (opposite = false) { @@ -374,45 +364,41 @@ export default class Carousel extends Component { } } - _getCenter (offset) { + _getViewportOffet () { const { sliderWidth, sliderHeight, itemWidth, itemHeight, vertical, activeSlideAlignment } = this.props; - let viewportOffset; if (activeSlideAlignment === 'start') { - viewportOffset = vertical ? itemHeight / 2 : itemWidth / 2; + return vertical ? itemHeight / 2 : itemWidth / 2; } else if (activeSlideAlignment === 'end') { - viewportOffset = vertical ? + return vertical ? sliderHeight - (itemHeight / 2) : sliderWidth - (itemWidth / 2); } else { - viewportOffset = vertical ? sliderHeight / 2 : sliderWidth / 2; + return vertical ? sliderHeight / 2 : sliderWidth / 2; } - - return offset + viewportOffset - (this._getContainerInnerMargin() * (IS_RTL ? -1 : 1)); } - _getKeyExtractor (item, index) { - return `carousel-item-${index}`; + _getCenter (offset) { + return offset + + this._getViewportOffet() - + (this._getContainerInnerMargin() * (IS_RTL ? -1 : 1)); } - _getItemLayout (data, index) { - const { itemWidth, itemHeight, vertical } = this.props; - const itemSize = vertical ? itemHeight : itemWidth; + _getActiveItem (offset) { + const { activeSlideOffset, swipeThreshold } = this.props; + const center = this._getCenter(offset); + const centerOffset = activeSlideOffset || swipeThreshold; - return { - length: itemSize, - offset: itemSize * index, - index - }; - } + for (let i = 0; i < this._positions.length; i++) { + const { start, end } = this._positions[i]; + if (center + centerOffset >= start && center - centerOffset <= end) { + return i; + } + } - _getItemOffset (index) { - // 'viewPosition' doesn't work for the first item - // It is always aligned to the left - // Unfortunately, 'viewOffset' doesn't work on Android ATM - if ((!IS_RTL && index === 0) || - (IS_RTL && index === this.props.data.length - 1)) { - return this._getContainerInnerMargin(); + const lastIndex = this._positions.length - 1; + if (this._positions[lastIndex] && center - centerOffset > this._positions[lastIndex].end) { + return lastIndex; } return 0; @@ -420,40 +406,21 @@ export default class Carousel extends Component { _onScroll (event) { const { activeItem } = this.state; - const { data, enableMomentum, onScroll } = this.props; + const { enableMomentum, onScroll, callbackOffsetMargin } = this.props; const scrollOffset = this._getScrollOffset(event); const nextActiveItem = this._getActiveItem(scrollOffset); - const itemsLength = data.length; this._currentContentOffset = scrollOffset; if (enableMomentum) { clearTimeout(this._snapNoMomentumTimeout); - } - - if (activeItem !== nextActiveItem) { - if (activeItem === 0 || activeItem === itemsLength - 1) { - this._hasFiredEdgeItemCallback = false; - } - - // WARNING: `setState()` is asynchronous - this.setState({ activeItem: nextActiveItem }, () => { - // When "short snapping", we can rely on the "activeItem/nextActiveItem" comparison - if (!enableMomentum && this._canFireCallback && this._isShortSnapping) { - this._isShortSnapping = false; - this._onSnap(nextActiveItem); - } - }); - } - - // When scrolling, we need to check that we are not "short snapping", - // that the new slide is different from the very first one, - // that we are scrolling to the relevant slide, - // and that callback can be fired - if (!enableMomentum && this._canFireCallback && !this._isShortSnapping && - (this._scrollStartActive !== nextActiveItem || !this._hasFiredEdgeItemCallback) && - this._itemToSnapTo === nextActiveItem) { + } else if (this._canFireCallback && + activeItem !== nextActiveItem && + nextActiveItem === this._itemToSnapTo && + (scrollOffset >= this._callbackScrollOffset - callbackOffsetMargin || + scrollOffset <= this._callbackScrollOffset + callbackOffsetMargin)) { + this._canFireCallback = false; this.setState({ activeItem: nextActiveItem }, () => { this._onSnap(nextActiveItem); }); @@ -489,26 +456,21 @@ export default class Carousel extends Component { _onScrollEndDrag (event) { const { onScrollEndDrag } = this.props; - // event.persist(); // See https://stackoverflow.com/a/24679479 - this._onScrollEndDragDebounced(); + if (this._flatlist) { + this._onScrollEnd && this._onScrollEnd(); + } if (onScrollEndDrag) { onScrollEndDrag(event); } } - _onScrollEndDragDebounced (event) { - if (this._flatlist && this._onScrollEnd) { - this._onScrollEnd(); - } - } - // Used when `enableMomentum` is ENABLED _onMomentumScrollEnd (event) { const { onMomentumScrollEnd } = this.props; - if (this._flatlist && this._onScrollEnd) { - this._onScrollEnd(); + if (this._flatlist) { + this._onScrollEnd && this._onScrollEnd(); } if (onMomentumScrollEnd) { @@ -517,7 +479,7 @@ export default class Carousel extends Component { } _onScrollEnd (event) { - const { autoplay } = this.props; + const { autoplay, enableSnap } = this.props; if (this._ignoreNextMomentum) { // iOS fix @@ -528,9 +490,8 @@ export default class Carousel extends Component { this._scrollEndOffset = this._currentContentOffset; this._scrollEndActive = this._getActiveItem(this._scrollEndOffset); - if (this._snapEnabled) { - const delta = this._scrollEndOffset - this._scrollStartOffset; - this._snapScroll(delta); + if (enableSnap) { + this._snapScroll(this._scrollEndOffset - this._scrollStartOffset); } if (autoplay) { @@ -560,7 +521,7 @@ export default class Carousel extends Component { _onLayout (event) { const { onLayout } = this.props; - this._calcCardPositions(); + this._initPositionsAndInterpolators(); this.snapToItem(this.currentIndex, false, false); if (onLayout) { @@ -578,15 +539,9 @@ export default class Carousel extends Component { } if (this._scrollStartActive !== this._scrollEndActive) { - // Flag necessary in order to fire the callback - // at the right time in `_onScroll()` - this._isShortSnapping = false; - // Snap to the new active item this.snapToItem(this._scrollEndActive); } else { - this._isShortSnapping = true; - // Snap depending on delta if (delta > 0) { if (delta > swipeThreshold) { @@ -616,21 +571,10 @@ export default class Carousel extends Component { } _onSnap (index) { - const { data, enableMomentum, onSnapToItem } = this.props; - const itemsLength = data.length; + const { onSnapToItem } = this.props; if (this._flatlist) { - if (enableMomentum) { - onSnapToItem && onSnapToItem(index); - } else if (this._canFireCallback) { - this._canFireCallback = false; - - if (index === 0 || index === itemsLength - 1) { - this._hasFiredEdgeItemCallback = true; - } - - onSnapToItem && onSnapToItem(index); - } + onSnapToItem && onSnapToItem(index); } } @@ -659,7 +603,7 @@ export default class Carousel extends Component { snapToItem (index, animated = true, fireCallback = true, initial = false) { const { previousActiveItem } = this.state; - const { data, enableMomentum, scrollEndDragDebounceValue } = this.props; + const { data, enableMomentum, onSnapToItem } = this.props; const itemsLength = data.length; if (!itemsLength) { @@ -672,72 +616,49 @@ export default class Carousel extends Component { if (itemsLength > 0 && index >= itemsLength) { index = itemsLength - 1; - this._isShortSnapping = false; // prevent issue #105 - if (this._scrollStartActive === itemsLength - 1 && this._hasFiredEdgeItemCallback) { - fireCallback = false; - } } else if (index < 0) { index = 0; - this._isShortSnapping = false; // prevent issue #105 - if (this._scrollStartActive === 0 && this._hasFiredEdgeItemCallback) { - fireCallback = false; - } } else if (enableMomentum && index === previousActiveItem) { fireCallback = false; } // Make sure the component hasn't been unmounted - if (this._flatlist) { - if (enableMomentum) { - this.setState({ previousActiveItem: index }); - // Callback can be fired here when relying on 'onMomentumScrollEnd' - if (fireCallback) { - this._onSnap(index); - } - } else { - // `_onScrollEndDragDebounced()` might occur when "peaking" to another item - // Therefore we need to make sure that callback is fired when scrolling - // back to the right one - this._itemToSnapTo = index; - - // Callback needs to be fired while scrolling when relying on 'onScrollEndDrag' - // Thus we need a flag on top of the debounce function to call it only once - this._canFireCallback = this.props.onSnapToItem && fireCallback; - - // If user has scrolled to an edge item before the end of `scrollEndDragDebounceValue` - // `onScroll()` won't be triggered and callback is not going to be fired - // So we check if scroll position has been updated after a small delay and, - // if not, it's safe to assume that callback should be called - const scrollPosition = this._currentContentOffset; - clearTimeout(this._scrollToTimeout); - this._scrollToTimeout = setTimeout(() => { - if (scrollPosition === this._currentContentOffset && this._canFireCallback) { - this._onSnap(index); - } - }, Math.max(500, scrollEndDragDebounceValue + 50)); + if (!this._flatlist) { + return; + } + + if (enableMomentum) { + this.setState({ previousActiveItem: index }); + // Callback can be fired here when relying on 'onMomentumScrollEnd' + if (fireCallback) { + this._onSnap(index); } + } else { + // 'scrollEndDrag' might be fired when "peaking" to another item. We need to + // make sure that callback is fired when scrolling back to the right one. + this._itemToSnapTo = index; + + // Callback needs to be fired while scrolling when relying on 'scrollEndDrag'. + // Thus we need a flag to make sure that it's going to be called only once. + if (onSnapToItem && fireCallback) { + this._canFireCallback = true; + this._callbackScrollOffset = this._positions[index] && this._positions[index].start; + } + } - // Unfortunately, 'viewPosition' is quite buggy at the moment - // Moreover, 'viewOffset' just doesn't work on Android - // const viewOffset = this._getItemOffset(index); - // let viewPosition = 0.5; - // if (activeSlideAlignment === 'start') { - // viewPosition = IS_RTL ? 1 : 0; - // } else if (activeSlideAlignment === 'end') { - // viewPosition = IS_RTL ? 0 : 1; - // } - - this._flatlist.scrollToIndex({ - index, - viewPosition: 0, - viewOffset: 0, - animated - }); + this._flatlist.scrollToIndex({ + index, + viewPosition: 0, + viewOffset: 0, + animated + }); - // iOS fix, check the note in the constructor - if (!initial && IS_IOS) { - this._ignoreNextMomentum = true; - } + // Android hack since `onScroll` sometimes seems to not be triggered + this._hackActiveSlideAnimation(index); + + // iOS fix, check the note in the constructor + if (!initial && IS_IOS && enableMomentum) { + this._ignoreNextMomentum = true; } } @@ -826,9 +747,9 @@ export default class Carousel extends Component { itemHeight, keyExtractor, renderItem, - scrollEventThrottle, sliderWidth, sliderHeight, + style, vertical } = this.props; @@ -836,11 +757,8 @@ export default class Carousel extends Component { return false; } - const nativePoweredScroll = this._shouldUseNativeOnScroll(); - const Component = nativePoweredScroll ? AnimatedFlatList : FlatList; - - const style = [ - containerCustomStyle || {}, + const containerStyle = [ + containerCustomStyle || style || {}, hideCarousel ? { opacity: 0 } : {}, vertical ? { height: sliderHeight, flexDirection: 'column' } : @@ -857,24 +775,25 @@ export default class Carousel extends Component { paddingRight: this._getContainerInnerMargin(true) } ]; - const visibleItems = Math.ceil(vertical ? sliderHeight / itemHeight : sliderWidth / itemWidth) + 1; return ( - { if (c) { this._flatlist = nativePoweredScroll ? c._component : c; } }} + ref={(c) => { if (c) { this._flatlist = c._component; } }} data={data} renderItem={this._renderItem} // extraData={this.state} @@ -882,10 +801,10 @@ export default class Carousel extends Component { keyExtractor={keyExtractor || this._getKeyExtractor} initialScrollIndex={firstItem || undefined} numColumns={1} - style={style} + style={containerStyle} contentContainerStyle={contentContainerStyle} horizontal={!vertical} - scrollEventThrottle={nativePoweredScroll ? 1 : (scrollEventThrottle || 16)} + scrollEventThrottle={1} onScroll={this._onScrollHandler} onScrollBeginDrag={this._onScrollBeginDrag} onScrollEndDrag={this._onScrollEndDrag} diff --git a/src/parallaximage/README.md b/src/parallaximage/README.md index cd18ca7d5..52e905e69 100644 --- a/src/parallaximage/README.md +++ b/src/parallaximage/README.md @@ -19,9 +19,7 @@ All [`` props](https://facebook.github.io/react-native/docs/image.html# ## Usage -The first thing you need to do is to **set `hasParallaxImages` to `true` on your ``**. This has two consequences: -- migrating scroll events to the native driver for top-notch performances -- your custom `renderItem` function now has access to a second argument that must be passed to the ``. +The first thing you need to do is to **set `hasParallaxImages` to `true` for your ``**. This will make a new argument available in your `renderItem()` function, which must then be passed to the ``. Here is an example that shows how to connect images to your carousel (note the `parallaxProps` argument).