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
This commit is contained in:
Fred Liu 2016-06-07 09:07:30 -07:00 committed by Facebook Github Bot 6
parent 79dcbc7b29
commit 4868948175
3 changed files with 96 additions and 16 deletions

View File

@ -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<any> {
_renderRow(
rowData: Object,
sectionID: string,
rowID: string,
): ReactElement<any> {
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 (
<SwipeableRow
slideoutView={slideoutView}
@ -130,7 +147,8 @@ const SwipeableListView = React.createClass({
key={rowID}
onOpen={() => this._onOpen(rowData.id)}
onSwipeEnd={() => this._setListViewScrollable(true)}
onSwipeStart={() => this._setListViewScrollable(false)}>
onSwipeStart={() => this._setListViewScrollable(false)}
shouldBounceOnMount={shouldBounceOnMount}>
{this.props.renderRow(rowData, sectionID, rowID)}
</SwipeableRow>
);

View File

@ -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;

View File

@ -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<any> {
// 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 {