react-native/Libraries/Experimental/SwipeableRow/SwipeableRow.js

373 lines
11 KiB
JavaScript

/**
* Copyright (c) 2013-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*
* The examples provided by Facebook are for non-commercial testing and
* evaluation purposes only.
*
* Facebook reserves all rights not expressly granted.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
* OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL
* FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. *
*
* @providesModule SwipeableRow
* @flow
*/
'use strict';
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 = 10;
// Minimum swipe speed before we fully animate the user's action (open/close)
const HORIZONTAL_FULL_SWIPE_SPEED_THRESHOLD = 0.3;
// 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 = 400;
// Distance left of closed position to bounce back when right-swiping from closed
const RIGHT_SWIPE_BOUNCE_BACK_DISTANCE = 50;
const RIGHT_SWIPE_BOUNCE_BACK_DURATION = 400;
/**
* 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;
/**
* Creates a swipable row that allows taps on the main item and a custom View
* on the item hidden behind the row. Typically this should be used in
* conjunction with SwipeableListView for additional functionality, but can be
* used in a normal ListView. See the renderRow for SwipeableListView to see how
* to use this component separately.
*/
const SwipeableRow = React.createClass({
_panResponder: {},
_previousLeft: CLOSED_LEFT_POSITION,
mixins: [TimerMixin],
propTypes: {
children: PropTypes.any,
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
*/
slideoutView: PropTypes.node.isRequired,
/**
* The minimum swipe distance required before fully animating the swipe. If
* the user swipes less than this distance, the item will return to its
* previous (open/close) position.
*/
swipeThreshold: PropTypes.number.isRequired,
},
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,
rowHeight: (null: ?number),
};
},
getDefaultProps(): Object {
return {
isOpen: false,
maxSwipeDistance: 0,
onOpen: emptyFunction,
onSwipeEnd: emptyFunction,
onSwipeStart: emptyFunction,
swipeThreshold: 30,
};
},
componentWillMount(): void {
this._panResponder = PanResponder.create({
onStartShouldSetPanResponder: (event, gestureState) => true,
// Don't capture child's start events
onStartShouldSetPanResponderCapture: (event, gestureState) => false,
onMoveShouldSetPanResponder: (event, gestureState) => false,
onMoveShouldSetPanResponderCapture: this._handleMoveShouldSetPanResponderCapture,
onPanResponderGrant: this._handlePanResponderGrant,
onPanResponderMove: this._handlePanResponderMove,
onPanResponderRelease: this._handlePanResponderEnd,
onPanResponderTerminationRequest: this._onPanResponderTerminationRequest,
onPanResponderTerminate: this._handlePanResponderEnd,
});
},
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
* handled internally by this component.
*/
if (this.props.isOpen && !nextProps.isOpen) {
this._animateToClosedPosition();
}
},
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;
if (this.state.isSwipeableViewRendered) {
slideOutView = (
<View style={[
styles.slideOutContainer,
{height: this.state.rowHeight},
]}>
{this.props.slideoutView}
</View>
);
}
// The swipeable item
const swipeableView = (
<Animated.View
onLayout={this._onSwipeableViewLayout}
style={[
styles.swipeableContainer,
{
transform: [{translateX: this.state.currentLeft}],
},
]}>
{this.props.children}
</Animated.View>
);
return (
<View
{...this._panResponder.panHandlers}>
{slideOutView}
{swipeableView}
</View>
);
},
_onSwipeableViewLayout(event: Object): void {
this.setState({
isSwipeableViewRendered: true,
rowHeight: event.nativeEvent.layout.height,
});
},
_handleMoveShouldSetPanResponderCapture(
event: Object,
gestureState: Object,
): boolean {
// Decides whether a swipe is responded to by this component or its child
return gestureState.dy < 10 && this._isValidSwipe(gestureState);
},
_handlePanResponderGrant(event: Object, gestureState: Object): void {
},
_handlePanResponderMove(event: Object, gestureState: Object): void {
if (this._isSwipingExcessivelyRightFromClosedPosition(gestureState)) {
return;
}
this.props.onSwipeStart();
if (this._isSwipingRightFromClosed(gestureState)) {
this._swipeSlowSpeed(gestureState);
} else {
this._swipeFullSpeed(gestureState);
}
},
_isSwipingRightFromClosed(gestureState: Object): boolean {
return this._previousLeft === CLOSED_LEFT_POSITION && gestureState.dx > 0;
},
_swipeFullSpeed(gestureState: Object): void {
this.state.currentLeft.setValue(this._previousLeft + gestureState.dx);
},
_swipeSlowSpeed(gestureState: Object): void {
this.state.currentLeft.setValue(
this._previousLeft + gestureState.dx / SLOW_SPEED_SWIPE_FACTOR,
);
},
_isSwipingExcessivelyRightFromClosedPosition(gestureState: Object): boolean {
/**
* We want to allow a BIT of right swipe, to allow users to know that
* swiping is available, but swiping right does not do anything
* functionally.
*/
return (
this._isSwipingRightFromClosed(gestureState) &&
gestureState.dx > RIGHT_SWIPE_THRESHOLD
);
},
_onPanResponderTerminationRequest(
event: Object,
gestureState: Object,
): boolean {
return false;
},
_animateTo(
toValue: number,
duration: number = SWIPE_DURATION,
callback: Function = emptyFunction,
): void {
Animated.timing(
this.state.currentLeft,
{
duration,
toValue,
},
).start(() => {
this._previousLeft = toValue;
callback();
});
},
_animateToOpenPosition(): void {
this._animateTo(-this.props.maxSwipeDistance);
},
_animateToClosedPosition(duration: number = SWIPE_DURATION): void {
this._animateTo(CLOSED_LEFT_POSITION, duration);
},
_animateToClosedPositionDuringBounce(): void {
this._animateToClosedPosition(RIGHT_SWIPE_BOUNCE_BACK_DURATION);
},
_animateBounceBack(duration: number): 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._animateToClosedPositionDuringBounce,
);
},
// Ignore swipes due to user's finger moving slightly when tapping
_isValidSwipe(gestureState: Object): boolean {
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._animateBounceBack(RIGHT_SWIPE_BOUNCE_BACK_DURATION);
} else if (this._shouldAnimateRemainder(gestureState)) {
if (horizontalDistance < 0) {
// Swiped left
this.props.onOpen();
this._animateToOpenPosition();
} else {
// Swiped right
this._animateToClosedPosition();
}
} else {
if (this._previousLeft === CLOSED_LEFT_POSITION) {
this._animateToClosedPosition();
} else {
this._animateToOpenPosition();
}
}
this.props.onSwipeEnd();
},
});
const styles = StyleSheet.create({
slideOutContainer: {
bottom: 0,
left: 0,
position: 'absolute',
right: 0,
top: 0,
},
swipeableContainer: {
flex: 1,
},
});
module.exports = SwipeableRow;