diff --git a/Libraries/Experimental/SwipeableRow/SwipeableListView.js b/Libraries/Experimental/SwipeableRow/SwipeableListView.js index b4fb8b30f..e824ff3cf 100644 --- a/Libraries/Experimental/SwipeableRow/SwipeableListView.js +++ b/Libraries/Experimental/SwipeableRow/SwipeableListView.js @@ -49,7 +49,7 @@ const SwipeableListView = React.createClass({ _listViewRef: (null: ?string), propTypes: { - dataSource: PropTypes.object.isRequired, // SwipeableListViewDataSource + dataSource: PropTypes.instanceOf(SwipeableListViewDataSource).isRequired, maxSwipeDistance: PropTypes.number, // Callback method to render the swipeable view renderRow: PropTypes.func.isRequired, @@ -65,14 +65,16 @@ const SwipeableListView = React.createClass({ getInitialState(): Object { return { - dataSource: this.props.dataSource.getDataSource(), + dataSource: this.props.dataSource, }; }, componentWillReceiveProps(nextProps: Object): void { - if ('dataSource' in nextProps && this.state.dataSource !== nextProps.dataSource) { + if ( + this.state.dataSource.getDataSource() !== nextProps.dataSource.getDataSource() + ) { this.setState({ - dataSource: nextProps.dataSource.getDataSource(), + dataSource: nextProps.dataSource, }); } }, @@ -84,12 +86,27 @@ const SwipeableListView = React.createClass({ ref={(ref) => { this._listViewRef = ref; }} - dataSource={this.state.dataSource} + dataSource={this.state.dataSource.getDataSource()} renderRow={this._renderRow} + scrollEnabled={this.state.scrollEnabled} /> ); }, + /** + * This is a work-around to lock vertical `ListView` scrolling on iOS and + * mimic Android behaviour. Locking vertical scrolling when horizontal + * scrolling is active allows us to significantly improve framerates + * (from high 20s to almost consistently 60 fps) + */ + _setListViewScrollable(value: boolean): void { + if (this._listViewRef && this._listViewRef.setNativeProps) { + this._listViewRef.setNativeProps({ + scrollEnabled: value, + }); + } + }, + // Passing through ListView's getScrollResponder() function getScrollResponder(): ?Object { if (this._listViewRef && this._listViewRef.getScrollResponder) { @@ -111,7 +128,9 @@ const SwipeableListView = React.createClass({ isOpen={rowData.id === this.props.dataSource.getOpenRowID()} maxSwipeDistance={this.props.maxSwipeDistance} key={rowID} - onOpen={() => this._onOpen(rowData.id)}> + onOpen={() => this._onOpen(rowData.id)} + onSwipeEnd={() => this._setListViewScrollable(true)} + onSwipeStart={() => this._setListViewScrollable(false)}> {this.props.renderRow(rowData, sectionID, rowID)} ); @@ -119,7 +138,7 @@ const SwipeableListView = React.createClass({ _onOpen(rowID: string): void { this.setState({ - dataSource: this.props.dataSource.setOpenRowID(rowID), + dataSource: this.state.dataSource.setOpenRowID(rowID), }); }, }); diff --git a/Libraries/Experimental/SwipeableRow/SwipeableListViewDataSource.js b/Libraries/Experimental/SwipeableRow/SwipeableListViewDataSource.js index 8d57f37ef..b39a671f5 100644 --- a/Libraries/Experimental/SwipeableRow/SwipeableListViewDataSource.js +++ b/Libraries/Experimental/SwipeableRow/SwipeableListViewDataSource.js @@ -88,15 +88,17 @@ class SwipeableListViewDataSource { return this._openRowID; } - setOpenRowID(rowID: string): ListViewDataSource { + setOpenRowID(rowID: string): SwipeableListViewDataSource { this._previousOpenRowID = this._openRowID; this._openRowID = rowID; - return this._dataSource.cloneWithRowsAndSections( + this._dataSource = this._dataSource.cloneWithRowsAndSections( this._dataBlob, this.sectionIdentities, this.rowIdentities ); + + return this; } } diff --git a/Libraries/Experimental/SwipeableRow/SwipeableRow.js b/Libraries/Experimental/SwipeableRow/SwipeableRow.js index d3c9a5e90..0fba64f0a 100644 --- a/Libraries/Experimental/SwipeableRow/SwipeableRow.js +++ b/Libraries/Experimental/SwipeableRow/SwipeableRow.js @@ -32,6 +32,8 @@ const View = require('View'); const {PropTypes} = React; +const emptyFunction = require('emptyFunction'); + // Position of the left of the swipable item when closed const CLOSED_LEFT_POSITION = 0; // Minimum swipe distance before we recognize it as such @@ -42,13 +44,6 @@ const HORIZONTAL_SWIPE_DISTANCE_THRESHOLD = 15; * on the item hidden behind the row */ const SwipeableRow = React.createClass({ - /** - * In order to render component A beneath component B, A must be rendered - * before B. However, this will cause "flickering", aka we see A briefly then - * B. To counter this, _isSwipeableViewRendered flag is used to set component - * A to be transparent until component B is loaded. - */ - _isSwipeableViewRendered: false, _panResponder: {}, _previousLeft: CLOSED_LEFT_POSITION, @@ -60,6 +55,8 @@ const SwipeableRow = React.createClass({ */ maxSwipeDistance: PropTypes.number, onOpen: PropTypes.func, + onSwipeEnd: PropTypes.func.isRequired, + onSwipeStart: PropTypes.func.isRequired, /** * A ReactElement that is unveiled when the user swipes */ @@ -75,6 +72,13 @@ const SwipeableRow = React.createClass({ getInitialState(): Object { return { currentLeft: new Animated.Value(this._previousLeft), + /** + * In order to render component A beneath component B, A must be rendered + * before B. However, this will cause "flickering", aka we see A briefly + * then B. To counter this, _isSwipeableViewRendered flag is used to set + * component A to be transparent until component B is loaded. + */ + isSwipeableViewRendered: false, /** * scrollViewWidth can change based on orientation, thus it's stored as a * state variable. This means all styles depending on it will be inline @@ -86,20 +90,23 @@ const SwipeableRow = React.createClass({ getDefaultProps(): Object { return { isOpen: false, - swipeThreshold: 50, + onSwipeEnd: emptyFunction, + onSwipeStart: emptyFunction, + swipeThreshold: 30, }; }, componentWillMount(): void { this._panResponder = PanResponder.create({ - onStartShouldSetPanResponder: this._handleStartShouldSetPanResponder, - onStartShouldSetPanResponderCapture: this._handleStartShouldSetPanResponderCapture, - onMoveShouldSetPanResponder: this._handleMoveShouldSetPanResponder, + onStartShouldSetPanResponder: (event, gestureState) => true, + // Don't capture child's start events + onStartShouldSetPanResponderCapture: (event, gestureState) => false, + onMoveShouldSetPanResponder: (event, gestureState) => false, onMoveShouldSetPanResponderCapture: this._handleMoveShouldSetPanResponderCapture, - onPanResponderGrant: (event, gesture) => {}, + onPanResponderGrant: this._handlePanResponderGrant, onPanResponderMove: this._handlePanResponderMove, onPanResponderRelease: this._handlePanResponderEnd, - onPanResponderTerminationRequest: this._handlePanResponderTerminationRequest, + onPanResponderTerminationRequest: this._onPanResponderTerminationRequest, onPanResponderTerminate: this._handlePanResponderEnd, }); }, @@ -110,7 +117,7 @@ const SwipeableRow = React.createClass({ * handled internally by this component. */ if (this.props.isOpen && !nextProps.isOpen) { - this._animateClose(); + this._animateToClosedPosition(); } }, @@ -123,7 +130,7 @@ const SwipeableRow = React.createClass({ }, ]; if (Platform.OS === 'ios') { - slideoutStyle.push({opacity: this._isSwipeableViewRendered ? 1 : 0}); + slideoutStyle.push({opacity: this.state.isSwipeableViewRendered ? 1 : 0}); } // The view hidden behind the main view @@ -138,7 +145,7 @@ const SwipeableRow = React.createClass({ {this.props.children} @@ -157,181 +164,82 @@ const SwipeableRow = React.createClass({ }, _onSwipeableViewLayout(event: Object): void { - if (!this._isSwipeableViewRendered) { - this._isSwipeableViewRendered = true; + if (!this._isSwipeableViewRendered && this.state.scrollViewWidth !== 0) { + this.setState({ + isSwipeableViewRendered: true, + }); } }, - _handlePanResponderTerminationRequest( - event: Object, - gestureState: Object, - ): boolean { - return false; - }, - - _handleStartShouldSetPanResponder( - event: Object, - gestureState: Object, - ): boolean { - return false; - }, - - _handleStartShouldSetPanResponderCapture( - event: Object, - gestureState: Object, - ): boolean { - return false; - }, - - _handleMoveShouldSetPanResponder( - event: Object, - gestureState: Object, - ): boolean { - return false; - }, - _handleMoveShouldSetPanResponderCapture( event: Object, gestureState: Object, ): boolean { - return this._isValidSwipe(gestureState); + // Decides whether a swipe is responded to by this component or its child + return gestureState.dy < 10 && this._isValidSwipe(gestureState); }, - /** - * User might move their finger slightly when tapping; let's ignore that - * unless we are sure they are swiping. - */ - _isValidSwipe(gestureState: Object): boolean { - return Math.abs(gestureState.dx) > HORIZONTAL_SWIPE_DISTANCE_THRESHOLD; - }, + _handlePanResponderGrant(event: Object, gestureState: Object): void { - _shouldAllowSwipe(gestureState: Object): boolean { - return ( - this._isSwipeWithinOpenLimit(this._previousLeft + gestureState.dx) && - ( - this._isSwipingLeftFromClosed(gestureState) || - this._isSwipingFromSemiOpened(gestureState) - ) - ); - }, - - _isSwipingLeftFromClosed(gestureState: Object): boolean { - return this._previousLeft === CLOSED_LEFT_POSITION && gestureState.vx < 0; - }, - - // User is swiping left/right from a state between fully open and fully closed - _isSwipingFromSemiOpened(gestureState: Object): boolean { - return ( - this._isSwipeableSomewhatOpen() && - this._isBoundedSwipe(gestureState) - ); - }, - - _isSwipeableSomewhatOpen(): boolean { - return this._previousLeft < CLOSED_LEFT_POSITION; - }, - - _isBoundedSwipe(gestureState: Object): boolean { - return ( - this._isBoundedLeftSwipe(gestureState) || - this._isBoundedRightSwipe(gestureState) - ); - }, - - _isBoundedLeftSwipe(gestureState: Object): boolean { - return ( - gestureState.dx < 0 && -this._previousLeft < this.state.scrollViewWidth - ); - }, - - _isBoundedRightSwipe(gestureState: Object): boolean { - const horizontalDistance = gestureState.dx; - - return ( - horizontalDistance > 0 && - this._previousLeft + horizontalDistance <= CLOSED_LEFT_POSITION - ); - }, - - _isSwipeWithinOpenLimit(distance: number): boolean { - const maxSwipeDistance = this.props.maxSwipeDistance; - - return maxSwipeDistance - ? Math.abs(distance) <= maxSwipeDistance - : true; }, _handlePanResponderMove(event: Object, gestureState: Object): void { - if (this._shouldAllowSwipe(gestureState)) { - this.setState({ - currentLeft: new Animated.Value(this._previousLeft + gestureState.dx), - }); - } + this.props.onSwipeStart(); + this.state.currentLeft.setValue(this._previousLeft + gestureState.dx); }, - // Animation for after a user lifts their finger after swiping - _postReleaseAnimate(horizontalDistance: number): void { - if (horizontalDistance < 0) { - if (horizontalDistance < -this.props.swipeThreshold) { - // Swiped left far enough, animate to fully opened state - this._animateOpen(); - return; - } - // Did not swipe left enough, animate to closed - this._animateClose(); - } else if (horizontalDistance > 0) { - if (horizontalDistance > this.props.swipeThreshold) { - // Swiped right far enough, animate to closed state - this._animateClose(); - return; - } - // Did not swipe right enough, animate to opened - this._animateOpen(); - } + _onPanResponderTerminationRequest(event: Object, gestureState: Object): boolean { + return false; }, _animateTo(toValue: number): void { - Animated.timing(this.state.currentLeft, {toValue: toValue}).start(() => { + Animated.timing( + this.state.currentLeft, + { + toValue: toValue, + }, + ).start(() => { this._previousLeft = toValue; }); }, - _animateOpen(): void { - this.props.onOpen && this.props.onOpen(); - + _animateToOpenPosition(): void { const toValue = this.props.maxSwipeDistance ? -this.props.maxSwipeDistance : -this.state.scrollViewWidth; this._animateTo(toValue); }, - _animateClose(): void { + _animateToClosedPosition(): void { this._animateTo(CLOSED_LEFT_POSITION); }, + // Ignore swipes due to user's finger moving slightly when tapping + _isValidSwipe(gestureState: Object): boolean { + return Math.abs(gestureState.dx) > HORIZONTAL_SWIPE_DISTANCE_THRESHOLD; + }, + _handlePanResponderEnd(event: Object, gestureState: Object): void { const horizontalDistance = gestureState.dx; - this._postReleaseAnimate(horizontalDistance); - if (this._shouldAllowSwipe(gestureState)) { - this._previousLeft += horizontalDistance; - return; + if (Math.abs(horizontalDistance) > this.props.swipeThreshold) { + if (horizontalDistance < 0) { + // Swiped left + this.props.onOpen && this.props.onOpen(); + this._animateToOpenPosition(); + } else { + // Swiped right + this._animateToClosedPosition(); + } + } else { + if (this._previousLeft === CLOSED_LEFT_POSITION) { + this._animateToClosedPosition(); + } else { + this._animateToOpenPosition(); + } } - if (this._previousLeft + horizontalDistance >= 0) { - // We are swiping back to close or somehow swiped past close - this._previousLeft = 0; - } else if ( - this.props.maxSwipeDistance && - !this._isSwipeWithinOpenLimit(this._previousLeft + horizontalDistance) - ) { - // We are swiping past the max swipe distance? - this._previousLeft = -this.props.maxSwipeDistance; - } - - this.setState({ - currentLeft: new Animated.Value(this._previousLeft), - }); + this.props.onSwipeEnd(); }, _onLayoutChange(event: Object): void {