/** * 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 = 15; // 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 = 50; /** * 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: { 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 { // The view hidden behind the main view let slideOutView; if (this.state.isSwipeableViewRendered) { slideOutView = ( {this.props.slideoutView} ); } // The swipeable item const swipeableView = ( {this.props.children} ); return ( {slideOutView} {swipeableView} ); }, _onSwipeableViewLayout(event: Object): void { if (!this.state.isSwipeableViewRendered) { 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(): void { this._animateTo(CLOSED_LEFT_POSITION); }, _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, ); }, // 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(); } 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;