2016-05-04 22:49:02 +00:00
|
|
|
/**
|
|
|
|
* 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 View = require('View');
|
|
|
|
|
|
|
|
const {PropTypes} = React;
|
|
|
|
|
|
|
|
// Position of the left of the swipable item when closed
|
|
|
|
const CLOSED_LEFT_POSITION = 0;
|
2016-05-05 02:50:09 +00:00
|
|
|
// Minimum swipe distance before we recognize it as such
|
|
|
|
const HORIZONTAL_SWIPE_DISTANCE_THRESHOLD = 15;
|
2016-05-04 22:49:02 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Creates a swipable row that allows taps on the main item and a custom View
|
|
|
|
* on the item hidden behind the row
|
|
|
|
*/
|
|
|
|
const SwipeableRow = React.createClass({
|
|
|
|
_panResponder: {},
|
|
|
|
_previousLeft: CLOSED_LEFT_POSITION,
|
|
|
|
|
|
|
|
propTypes: {
|
|
|
|
/**
|
|
|
|
* Left position of the maximum open swipe. If unspecified, swipe will open
|
|
|
|
* fully to the left
|
|
|
|
*/
|
|
|
|
maxSwipeDistance: PropTypes.number,
|
|
|
|
/**
|
|
|
|
* 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),
|
|
|
|
/**
|
|
|
|
* scrollViewWidth can change based on orientation, thus it's stored as a
|
|
|
|
* state variable. This means all styles depending on it will be inline
|
|
|
|
*/
|
|
|
|
scrollViewWidth: 0,
|
|
|
|
};
|
|
|
|
},
|
|
|
|
|
|
|
|
getDefaultProps(): Object {
|
|
|
|
return {
|
|
|
|
swipeThreshold: 50,
|
|
|
|
};
|
|
|
|
},
|
|
|
|
|
|
|
|
componentWillMount(): void {
|
|
|
|
this._panResponder = PanResponder.create({
|
|
|
|
onStartShouldSetPanResponder: this._handleStartShouldSetPanResponder,
|
2016-05-05 02:50:09 +00:00
|
|
|
onStartShouldSetPanResponderCapture: this._handleStartShouldSetPanResponderCapture,
|
2016-05-04 22:49:02 +00:00
|
|
|
onMoveShouldSetPanResponder: this._handleMoveShouldSetPanResponder,
|
2016-05-05 02:50:09 +00:00
|
|
|
onMoveShouldSetPanResponderCapture: this._handleMoveShouldSetPanResponderCapture,
|
2016-05-04 22:49:02 +00:00
|
|
|
onPanResponderGrant: (event, gesture) => {},
|
|
|
|
onPanResponderMove: this._handlePanResponderMove,
|
|
|
|
onPanResponderRelease: this._handlePanResponderEnd,
|
2016-05-05 02:50:09 +00:00
|
|
|
onPanResponderTerminationRequest: this._handlePanResponderTerminationRequest,
|
2016-05-04 22:49:02 +00:00
|
|
|
onPanResponderTerminate: this._handlePanResponderEnd,
|
|
|
|
});
|
|
|
|
},
|
|
|
|
|
|
|
|
render(): ReactElement {
|
|
|
|
// The view hidden behind the main view
|
|
|
|
const slideOutView = (
|
|
|
|
<View style={[
|
|
|
|
styles.slideOutContainer,
|
|
|
|
{
|
|
|
|
right: -this.state.scrollViewWidth,
|
|
|
|
width: this.state.scrollViewWidth,
|
|
|
|
},
|
|
|
|
]}>
|
|
|
|
{this.props.slideoutView}
|
|
|
|
</View>
|
|
|
|
);
|
|
|
|
|
|
|
|
// The swipable item
|
|
|
|
const mainView = (
|
|
|
|
<Animated.View
|
|
|
|
style={{
|
|
|
|
left: this.state.currentLeft,
|
|
|
|
width: this.state.scrollViewWidth,
|
|
|
|
}}>
|
|
|
|
{this.props.children}
|
|
|
|
</Animated.View>
|
|
|
|
);
|
|
|
|
|
|
|
|
return (
|
|
|
|
<View
|
|
|
|
{...this._panResponder.panHandlers}
|
|
|
|
style={styles.container}
|
|
|
|
onLayout={this._onLayoutChange}>
|
|
|
|
{slideOutView}
|
|
|
|
{mainView}
|
|
|
|
</View>
|
|
|
|
);
|
|
|
|
},
|
|
|
|
|
2016-05-05 02:50:09 +00:00
|
|
|
_handlePanResponderTerminationRequest(
|
|
|
|
event: Object,
|
|
|
|
gestureState: Object,
|
|
|
|
): boolean {
|
|
|
|
return false;
|
2016-05-04 22:49:02 +00:00
|
|
|
},
|
|
|
|
|
2016-05-05 02:50:09 +00:00
|
|
|
_handleStartShouldSetPanResponder(
|
|
|
|
event: Object,
|
|
|
|
gestureState: Object,
|
|
|
|
): boolean {
|
|
|
|
return false;
|
2016-05-04 22:49:02 +00:00
|
|
|
},
|
|
|
|
|
2016-05-05 02:50:09 +00:00
|
|
|
_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);
|
|
|
|
},
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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;
|
|
|
|
},
|
2016-05-04 22:49:02 +00:00
|
|
|
|
2016-05-05 02:50:09 +00:00
|
|
|
_shouldAllowSwipe(gestureState: Object): boolean {
|
2016-05-04 22:49:02 +00:00
|
|
|
return (
|
2016-05-05 02:50:09 +00:00
|
|
|
this._isSwipeWithinOpenLimit(this._previousLeft + gestureState.dx) &&
|
2016-05-04 22:49:02 +00:00
|
|
|
(
|
2016-05-05 02:50:09 +00:00
|
|
|
this._isSwipingLeftFromClosed(gestureState) ||
|
|
|
|
this._isSwipingFromSemiOpened(gestureState)
|
2016-05-04 22:49:02 +00:00
|
|
|
)
|
|
|
|
);
|
|
|
|
},
|
|
|
|
|
2016-05-05 02:50:09 +00:00
|
|
|
_isSwipingLeftFromClosed(gestureState: Object): boolean {
|
|
|
|
return this._previousLeft === CLOSED_LEFT_POSITION && gestureState.vx < 0;
|
2016-05-04 22:49:02 +00:00
|
|
|
},
|
|
|
|
|
|
|
|
// User is swiping left/right from a state between fully open and fully closed
|
2016-05-05 02:50:09 +00:00
|
|
|
_isSwipingFromSemiOpened(gestureState: Object): boolean {
|
2016-05-04 22:49:02 +00:00
|
|
|
return (
|
|
|
|
this._isSwipeableSomewhatOpen() &&
|
2016-05-05 02:50:09 +00:00
|
|
|
this._isBoundedSwipe(gestureState)
|
2016-05-04 22:49:02 +00:00
|
|
|
);
|
|
|
|
},
|
|
|
|
|
|
|
|
_isSwipeableSomewhatOpen(): boolean {
|
|
|
|
return this._previousLeft < CLOSED_LEFT_POSITION;
|
|
|
|
},
|
|
|
|
|
2016-05-05 02:50:09 +00:00
|
|
|
_isBoundedSwipe(gestureState: Object): boolean {
|
2016-05-04 22:49:02 +00:00
|
|
|
return (
|
2016-05-05 02:50:09 +00:00
|
|
|
this._isBoundedLeftSwipe(gestureState) ||
|
|
|
|
this._isBoundedRightSwipe(gestureState)
|
2016-05-04 22:49:02 +00:00
|
|
|
);
|
|
|
|
},
|
|
|
|
|
2016-05-05 02:50:09 +00:00
|
|
|
_isBoundedLeftSwipe(gestureState: Object): boolean {
|
2016-05-04 22:49:02 +00:00
|
|
|
return (
|
2016-05-05 02:50:09 +00:00
|
|
|
gestureState.dx < 0 && -this._previousLeft < this.state.scrollViewWidth
|
2016-05-04 22:49:02 +00:00
|
|
|
);
|
|
|
|
},
|
|
|
|
|
2016-05-05 02:50:09 +00:00
|
|
|
_isBoundedRightSwipe(gestureState: Object): boolean {
|
|
|
|
const horizontalDistance = gestureState.dx;
|
|
|
|
|
2016-05-04 22:49:02 +00:00
|
|
|
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),
|
|
|
|
});
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
_animateTo(toValue: number): void {
|
|
|
|
Animated.timing(this.state.currentLeft, {toValue: toValue}).start(() => {
|
|
|
|
this._previousLeft = toValue;
|
|
|
|
|
|
|
|
this.setState({
|
|
|
|
currentLeft: new Animated.Value(this._previousLeft),
|
|
|
|
});
|
|
|
|
});
|
|
|
|
},
|
|
|
|
|
|
|
|
_animateOpen(): void {
|
|
|
|
const toValue = this.props.maxSwipeDistance
|
|
|
|
? -this.props.maxSwipeDistance
|
|
|
|
: -this.state.scrollViewWidth;
|
|
|
|
this._animateTo(toValue);
|
|
|
|
},
|
|
|
|
|
|
|
|
_animateClose(): void {
|
|
|
|
this._animateTo(CLOSED_LEFT_POSITION);
|
|
|
|
},
|
|
|
|
|
|
|
|
_handlePanResponderEnd(event: Object, gestureState: Object): void {
|
|
|
|
const horizontalDistance = gestureState.dx;
|
|
|
|
this._postReleaseAnimate(horizontalDistance);
|
|
|
|
|
|
|
|
if (this._shouldAllowSwipe(gestureState)) {
|
|
|
|
this._previousLeft += horizontalDistance;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
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),
|
|
|
|
});
|
|
|
|
},
|
|
|
|
|
|
|
|
_onLayoutChange(event: Object): void {
|
|
|
|
const width = event.nativeEvent.layout.width;
|
|
|
|
|
|
|
|
if (width !== this.state.scrollViewWidth) {
|
|
|
|
this.setState({
|
|
|
|
scrollViewWidth: width,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
|
|
|
const styles = StyleSheet.create({
|
|
|
|
container: {
|
|
|
|
flex: 1,
|
|
|
|
flexDirection: 'row',
|
|
|
|
},
|
|
|
|
slideOutContainer: {
|
|
|
|
flex: 1,
|
|
|
|
flexDirection: 'column',
|
|
|
|
alignItems: 'flex-end',
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
|
|
|
module.exports = SwipeableRow;
|