From 48689481753af22986851c908f6d7b6d8f0a9567 Mon Sep 17 00:00:00 2001 From: Fred Liu Date: Tue, 7 Jun 2016 09:07:30 -0700 Subject: [PATCH] NUX-y bounce Summary: On mount, bounce the 1st row so users know it's swipeable. Reviewed By: fkgozali Differential Revision: D3395214 fbshipit-source-id: 6d391209014a6a7957a2160734d8ef6548b7693b --- .../SwipeableRow/SwipeableListView.js | 22 +++++- .../SwipeableListViewDataSource.js | 12 +++ .../Experimental/SwipeableRow/SwipeableRow.js | 78 +++++++++++++++---- 3 files changed, 96 insertions(+), 16 deletions(-) diff --git a/Libraries/Experimental/SwipeableRow/SwipeableListView.js b/Libraries/Experimental/SwipeableRow/SwipeableListView.js index 1572d4710..4600483d4 100644 --- a/Libraries/Experimental/SwipeableRow/SwipeableListView.js +++ b/Libraries/Experimental/SwipeableRow/SwipeableListView.js @@ -47,8 +47,10 @@ const SwipeableListView = React.createClass({ }, _listViewRef: (null: ?string), + _shouldBounceFirstRowOnMount: false, propTypes: { + bounceFirstRowOnMount: PropTypes.bool.isRequired, dataSource: PropTypes.instanceOf(SwipeableListViewDataSource).isRequired, maxSwipeDistance: PropTypes.number, // Callback method to render the swipeable view @@ -59,6 +61,7 @@ const SwipeableListView = React.createClass({ getDefaultProps(): Object { return { + bounceFirstRowOnMount: false, renderQuickActions: () => null, }; }, @@ -69,6 +72,10 @@ const SwipeableListView = React.createClass({ }; }, + componentWillMount(): void { + this._shouldBounceFirstRowOnMount = this.props.bounceFirstRowOnMount; + }, + componentWillReceiveProps(nextProps: Object): void { if ( this.state.dataSource.getDataSource() !== nextProps.dataSource.getDataSource() @@ -114,7 +121,11 @@ const SwipeableListView = React.createClass({ } }, - _renderRow(rowData: Object, sectionID: string, rowID: string): ReactElement { + _renderRow( + rowData: Object, + sectionID: string, + rowID: string, + ): ReactElement { const slideoutView = this.props.renderQuickActions(rowData, sectionID, rowID); // If renderRowSlideout is unspecified or returns falsey, don't allow swipe @@ -122,6 +133,12 @@ const SwipeableListView = React.createClass({ return this.props.renderRow(rowData, sectionID, rowID); } + let shouldBounceOnMount = false; + if (this._shouldBounceFirstRowOnMount) { + this._shouldBounceFirstRowOnMount = false; + shouldBounceOnMount = rowID === this.props.dataSource.getFirstRowID(); + } + return ( this._onOpen(rowData.id)} onSwipeEnd={() => this._setListViewScrollable(true)} - onSwipeStart={() => this._setListViewScrollable(false)}> + onSwipeStart={() => this._setListViewScrollable(false)} + shouldBounceOnMount={shouldBounceOnMount}> {this.props.renderRow(rowData, sectionID, rowID)} ); diff --git a/Libraries/Experimental/SwipeableRow/SwipeableListViewDataSource.js b/Libraries/Experimental/SwipeableRow/SwipeableListViewDataSource.js index b39a671f5..4c01bf1a0 100644 --- a/Libraries/Experimental/SwipeableRow/SwipeableListViewDataSource.js +++ b/Libraries/Experimental/SwipeableRow/SwipeableListViewDataSource.js @@ -88,6 +88,18 @@ class SwipeableListViewDataSource { return this._openRowID; } + getFirstRowID(): ?string { + /** + * If rowIdentities is specified, find the first data row from there since + * we don't want to attempt to bounce section headers. If unspecified, find + * the first data row from _dataBlob. + */ + if (this.rowIdentities) { + return this.rowIdentities[0] && this.rowIdentities[0][0]; + } + return Object.keys(this._dataBlob)[0]; + } + setOpenRowID(rowID: string): SwipeableListViewDataSource { this._previousOpenRowID = this._openRowID; this._openRowID = rowID; diff --git a/Libraries/Experimental/SwipeableRow/SwipeableRow.js b/Libraries/Experimental/SwipeableRow/SwipeableRow.js index 09838bfe6..e615fc804 100644 --- a/Libraries/Experimental/SwipeableRow/SwipeableRow.js +++ b/Libraries/Experimental/SwipeableRow/SwipeableRow.js @@ -27,28 +27,41 @@ const Animated = require('Animated'); const PanResponder = require('PanResponder'); const React = require('React'); const StyleSheet = require('StyleSheet'); +const TimerMixin = require('react-timer-mixin'); const View = require('View'); const {PropTypes} = React; const emptyFunction = require('fbjs/lib/emptyFunction'); +// NOTE: Eventually convert these consts to an input object of configurations + // Position of the left of the swipable item when closed const CLOSED_LEFT_POSITION = 0; // Minimum swipe distance before we recognize it as such const HORIZONTAL_SWIPE_DISTANCE_THRESHOLD = 15; -// Distance left of closed position to bounce back when right-swiping from closed -const RIGHT_SWIPE_BOUNCE_BACK_DISTANCE = 30; +// Minimum swipe speed before we fully animate the user's action (open/close) +const HORIZONTAL_FULL_SWIPE_SPEED_THRESHOLD = 0.5; // Factor to divide by to get slow speed; i.e. 4 means 1/4 of full speed const SLOW_SPEED_SWIPE_FACTOR = 4; +// Time, in milliseconds, of how long the animated swipe should be +const SWIPE_DURATION = 200; + +/** + * On SwipeableListView mount, the 1st item will bounce to show users it's + * possible to swipe + */ +const ON_MOUNT_BOUNCE_DELAY = 700; +const ON_MOUNT_BOUNCE_DURATION = 200; + +// Distance left of closed position to bounce back when right-swiping from closed +const RIGHT_SWIPE_BOUNCE_BACK_DISTANCE = 30; /** * Max distance of right swipe to allow (right swipes do functionally nothing). * Must be multiplied by SLOW_SPEED_SWIPE_FACTOR because gestureState.dx tracks * how far the finger swipes, and not the actual animation distance. */ const RIGHT_SWIPE_THRESHOLD = 30 * SLOW_SPEED_SWIPE_FACTOR; -// Time, in milliseconds, of how long the animated swipe should be -const SWIPE_DURATION = 200; /** * Creates a swipable row that allows taps on the main item and a custom View @@ -58,12 +71,16 @@ const SwipeableRow = React.createClass({ _panResponder: {}, _previousLeft: CLOSED_LEFT_POSITION, + mixins: [TimerMixin], + propTypes: { isOpen: PropTypes.bool, maxSwipeDistance: PropTypes.number.isRequired, onOpen: PropTypes.func.isRequired, onSwipeEnd: PropTypes.func.isRequired, onSwipeStart: PropTypes.func.isRequired, + // Should bounce the row on mount + shouldBounceOnMount: PropTypes.bool, /** * A ReactElement that is unveiled when the user swipes */ @@ -116,6 +133,18 @@ const SwipeableRow = React.createClass({ }); }, + componentDidMount(): void { + if (this.props.shouldBounceOnMount) { + /** + * Do the on mount bounce after a delay because if we animate when other + * components are loading, the animation will be laggy + */ + this.setTimeout(() => { + this._animateBounceBack(ON_MOUNT_BOUNCE_DURATION); + }, ON_MOUNT_BOUNCE_DELAY); + } + }, + componentWillReceiveProps(nextProps: Object): void { /** * We do not need an "animateOpen(noCallback)" because this animation is @@ -126,6 +155,15 @@ const SwipeableRow = React.createClass({ } }, + shouldComponentUpdate(nextProps: Object, nextState: Object): boolean { + if (this.props.shouldBounceOnMount && !nextProps.shouldBounceOnMount) { + // No need to rerender if SwipeableListView is disabling the bounce flag + return false; + } + + return true; + }, + render(): ReactElement { // The view hidden behind the main view let slideOutView; @@ -231,12 +269,16 @@ const SwipeableRow = React.createClass({ return false; }, - _animateTo(toValue: number, callback: Function = emptyFunction): void { + _animateTo( + toValue: number, + duration: number = SWIPE_DURATION, + callback: Function = emptyFunction, + ): void { Animated.timing( this.state.currentLeft, { - duration: SWIPE_DURATION, - toValue: toValue, + duration, + toValue, }, ).start(() => { this._previousLeft = toValue; @@ -252,13 +294,14 @@ const SwipeableRow = React.createClass({ this._animateTo(CLOSED_LEFT_POSITION); }, - _animateRightSwipeBounceBack(): void { + _animateBounceBack(duration: number = SWIPE_DURATION): void { /** * When swiping right, we want to bounce back past closed position on release * so users know they should swipe right to get content. */ this._animateTo( -RIGHT_SWIPE_BOUNCE_BACK_DISTANCE, + duration, this._animateToClosedPosition, ); }, @@ -268,15 +311,24 @@ const SwipeableRow = React.createClass({ return Math.abs(gestureState.dx) > HORIZONTAL_SWIPE_DISTANCE_THRESHOLD; }, + _shouldAnimateRemainder(gestureState: Object): boolean { + /** + * If user has swiped past a certain distance, animate the rest of the way + * if they let go + */ + return ( + Math.abs(gestureState.dx) > this.props.swipeThreshold || + gestureState.vx > HORIZONTAL_FULL_SWIPE_SPEED_THRESHOLD + ); + }, + _handlePanResponderEnd(event: Object, gestureState: Object): void { const horizontalDistance = gestureState.dx; if (this._isSwipingRightFromClosed(gestureState)) { this.props.onOpen(); - this._animateRightSwipeBounceBack(); - } else if (Math.abs(horizontalDistance) > this.props.swipeThreshold) { - // Overswiped - + this._animateBounceBack(); + } else if (this._shouldAnimateRemainder(gestureState)) { if (horizontalDistance < 0) { // Swiped left this.props.onOpen(); @@ -286,8 +338,6 @@ const SwipeableRow = React.createClass({ this._animateToClosedPosition(); } } else { - // Swiping from closed but let go before fully - if (this._previousLeft === CLOSED_LEFT_POSITION) { this._animateToClosedPosition(); } else {