diff --git a/Libraries/Experimental/SwipeableRow/SwipeableRow.js b/Libraries/Experimental/SwipeableRow/SwipeableRow.js index 5e9e796cdcdb79..76eb13dcfc78c3 100644 --- a/Libraries/Experimental/SwipeableRow/SwipeableRow.js +++ b/Libraries/Experimental/SwipeableRow/SwipeableRow.js @@ -14,11 +14,9 @@ const Animated = require('Animated'); const I18nManager = require('I18nManager'); const PanResponder = require('PanResponder'); const React = require('React'); -const PropTypes = require('prop-types'); const StyleSheet = require('StyleSheet'); const View = require('View'); -const createReactClass = require('create-react-class'); const emptyFunction = require('fbjs/lib/emptyFunction'); import type {LayoutEvent, PressEvent} from 'CoreEventTypes'; @@ -55,21 +53,28 @@ const RIGHT_SWIPE_BOUNCE_BACK_DURATION = 300; * how far the finger swipes, and not the actual animation distance. */ const RIGHT_SWIPE_THRESHOLD = 30 * SLOW_SPEED_SWIPE_FACTOR; +const DEFAULT_SWIPE_THRESHOLD = 30; type Props = $ReadOnly<{| children?: ?React.Node, isOpen?: ?boolean, maxSwipeDistance?: ?number, - onClose?: ?Function, - onOpen?: ?Function, - onSwipeEnd?: ?Function, - onSwipeStart?: ?Function, + onClose?: ?() => void, + onOpen?: ?() => void, + onSwipeEnd?: ?() => void, + onSwipeStart?: ?() => void, preventSwipeRight?: ?boolean, shouldBounceOnMount?: ?boolean, slideoutView?: ?React.Node, swipeThreshold?: ?number, |}>; +type State = { + currentLeft: Animated.Value, + isSwipeableViewRendered: boolean, + rowHeight: ?number, +}; + /** * 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 @@ -77,74 +82,98 @@ type Props = $ReadOnly<{| * used in a normal ListView. See the renderRow for SwipeableListView to see how * to use this component separately. */ -const SwipeableRow = createReactClass({ - displayName: 'SwipeableRow', - _panResponder: {}, - _previousLeft: CLOSED_LEFT_POSITION, - _timeoutID: (null: ?TimeoutID), - - propTypes: { - children: PropTypes.any, - isOpen: PropTypes.bool, - preventSwipeRight: PropTypes.bool, - maxSwipeDistance: PropTypes.number.isRequired, - onOpen: PropTypes.func.isRequired, - onClose: 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, - }, +class SwipeableRow extends React.Component { + _handleMoveShouldSetPanResponderCapture = ( + event: PressEvent, + gestureState: GestureState, + ): boolean => { + // Decides whether a swipe is responded to by this component or its child + return gestureState.dy < 10 && this._isValidSwipe(gestureState); + }; - 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), - }; - }, + _handlePanResponderGrant = ( + event: PressEvent, + gestureState: GestureState, + ): void => {}; - getDefaultProps(): Object { - return { - isOpen: false, - preventSwipeRight: false, - maxSwipeDistance: 0, - onOpen: emptyFunction, - onClose: emptyFunction, - onSwipeEnd: emptyFunction, - onSwipeStart: emptyFunction, - swipeThreshold: 30, - }; - }, + _handlePanResponderMove = ( + event: PressEvent, + gestureState: GestureState, + ): void => { + if (this._isSwipingExcessivelyRightFromClosedPosition(gestureState)) { + return; + } - UNSAFE_componentWillMount(): void { - this._panResponder = PanResponder.create({ - onMoveShouldSetPanResponderCapture: this - ._handleMoveShouldSetPanResponderCapture, - onPanResponderGrant: this._handlePanResponderGrant, - onPanResponderMove: this._handlePanResponderMove, - onPanResponderRelease: this._handlePanResponderEnd, - onPanResponderTerminationRequest: this._onPanResponderTerminationRequest, - onPanResponderTerminate: this._handlePanResponderEnd, - onShouldBlockNativeResponder: (event, gestureState) => false, - }); - }, + this.props.onSwipeStart && this.props.onSwipeStart(); + + if (this._isSwipingRightFromClosed(gestureState)) { + this._swipeSlowSpeed(gestureState); + } else { + this._swipeFullSpeed(gestureState); + } + }; + + _onPanResponderTerminationRequest = ( + event: PressEvent, + gestureState: GestureState, + ): boolean => { + return false; + }; + + _handlePanResponderEnd = ( + event: PressEvent, + gestureState: GestureState, + ): void => { + const horizontalDistance = IS_RTL ? -gestureState.dx : gestureState.dx; + if (this._isSwipingRightFromClosed(gestureState)) { + this.props.onOpen && this.props.onOpen(); + this._animateBounceBack(RIGHT_SWIPE_BOUNCE_BACK_DURATION); + } else if (this._shouldAnimateRemainder(gestureState)) { + if (horizontalDistance < 0) { + // Swiped left + this.props.onOpen && this.props.onOpen(); + this._animateToOpenPositionWith(gestureState.vx, horizontalDistance); + } else { + // Swiped right + this.props.onClose && this.props.onClose(); + this._animateToClosedPosition(); + } + } else { + if (this._previousLeft === CLOSED_LEFT_POSITION) { + this._animateToClosedPosition(); + } else { + this._animateToOpenPosition(); + } + } + + this.props.onSwipeEnd && this.props.onSwipeEnd(); + }; + + _panResponder = PanResponder.create({ + onMoveShouldSetPanResponderCapture: this + ._handleMoveShouldSetPanResponderCapture, + onPanResponderGrant: this._handlePanResponderGrant, + onPanResponderMove: this._handlePanResponderMove, + onPanResponderRelease: this._handlePanResponderEnd, + onPanResponderTerminationRequest: this._onPanResponderTerminationRequest, + onPanResponderTerminate: this._handlePanResponderEnd, + onShouldBlockNativeResponder: (event, gestureState) => false, + }); + + _previousLeft = CLOSED_LEFT_POSITION; + _timeoutID: ?TimeoutID = null; + + state = { + 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, + }; componentDidMount(): void { if (this.props.shouldBounceOnMount) { @@ -156,23 +185,26 @@ const SwipeableRow = createReactClass({ this._animateBounceBack(ON_MOUNT_BOUNCE_DURATION); }, ON_MOUNT_BOUNCE_DELAY); } - }, + } - UNSAFE_componentWillReceiveProps(nextProps: Object): void { + UNSAFE_componentWillReceiveProps(nextProps: $Shape): void { /** * We do not need an "animateOpen(noCallback)" because this animation is * handled internally by this component. */ - if (this.props.isOpen && !nextProps.isOpen) { + const isOpen = this.props.isOpen ?? false; + const nextIsOpen = nextProps.isOpen ?? false; + + if (isOpen && !nextIsOpen) { this._animateToClosedPosition(); } - }, + } componentWillUnmount() { if (this._timeoutID != null) { clearTimeout(this._timeoutID); } - }, + } render(): React.Element { // The view hidden behind the main view @@ -201,61 +233,34 @@ const SwipeableRow = createReactClass({ {swipeableView} ); - }, + } close(): void { - this.props.onClose(); + this.props.onClose && this.props.onClose(); this._animateToClosedPosition(); - }, + } - _onSwipeableViewLayout(event: LayoutEvent): void { + _onSwipeableViewLayout = (event: LayoutEvent): void => { this.setState({ isSwipeableViewRendered: true, rowHeight: event.nativeEvent.layout.height, }); - }, - - _handleMoveShouldSetPanResponderCapture( - event: PressEvent, - gestureState: GestureState, - ): boolean { - // Decides whether a swipe is responded to by this component or its child - return gestureState.dy < 10 && this._isValidSwipe(gestureState); - }, - - _handlePanResponderGrant( - event: PressEvent, - gestureState: GestureState, - ): void {}, - - _handlePanResponderMove(event: PressEvent, gestureState: GestureState): void { - if (this._isSwipingExcessivelyRightFromClosedPosition(gestureState)) { - return; - } - - this.props.onSwipeStart(); - - if (this._isSwipingRightFromClosed(gestureState)) { - this._swipeSlowSpeed(gestureState); - } else { - this._swipeFullSpeed(gestureState); - } - }, + }; _isSwipingRightFromClosed(gestureState: GestureState): boolean { const gestureStateDx = IS_RTL ? -gestureState.dx : gestureState.dx; return this._previousLeft === CLOSED_LEFT_POSITION && gestureStateDx > 0; - }, + } _swipeFullSpeed(gestureState: GestureState): void { this.state.currentLeft.setValue(this._previousLeft + gestureState.dx); - }, + } _swipeSlowSpeed(gestureState: GestureState): void { this.state.currentLeft.setValue( this._previousLeft + gestureState.dx / SLOW_SPEED_SWIPE_FACTOR, ); - }, + } _isSwipingExcessivelyRightFromClosedPosition( gestureState: GestureState, @@ -270,14 +275,7 @@ const SwipeableRow = createReactClass({ this._isSwipingRightFromClosed(gestureState) && gestureStateDx > RIGHT_SWIPE_THRESHOLD ); - }, - - _onPanResponderTerminationRequest( - event: PressEvent, - gestureState: GestureState, - ): boolean { - return false; - }, + } _animateTo( toValue: number, @@ -292,14 +290,15 @@ const SwipeableRow = createReactClass({ this._previousLeft = toValue; callback(); }); - }, + } _animateToOpenPosition(): void { - const maxSwipeDistance = IS_RTL - ? -this.props.maxSwipeDistance - : this.props.maxSwipeDistance; - this._animateTo(-maxSwipeDistance); - }, + const maxSwipeDistance = this.props.maxSwipeDistance ?? 0; + const directionAwareMaxSwipeDistance = IS_RTL + ? -maxSwipeDistance + : maxSwipeDistance; + this._animateTo(-directionAwareMaxSwipeDistance); + } _animateToOpenPositionWith(speed: number, distMoved: number): void { /** @@ -310,26 +309,25 @@ const SwipeableRow = createReactClass({ speed > HORIZONTAL_FULL_SWIPE_SPEED_THRESHOLD ? speed : HORIZONTAL_FULL_SWIPE_SPEED_THRESHOLD; + const maxSwipeDistance = this.props.maxSwipeDistance ?? 0; /** * Calculate the duration the row should take to swipe the remaining distance * at the same speed the user swiped (or the speed threshold) */ - const duration = Math.abs( - (this.props.maxSwipeDistance - Math.abs(distMoved)) / speed, - ); - const maxSwipeDistance = IS_RTL - ? -this.props.maxSwipeDistance - : this.props.maxSwipeDistance; - this._animateTo(-maxSwipeDistance, duration); - }, + const duration = Math.abs((maxSwipeDistance - Math.abs(distMoved)) / speed); + const directionAwareMaxSwipeDistance = IS_RTL + ? -maxSwipeDistance + : maxSwipeDistance; + this._animateTo(-directionAwareMaxSwipeDistance, duration); + } _animateToClosedPosition(duration: number = SWIPE_DURATION): void { this._animateTo(CLOSED_LEFT_POSITION, duration); - }, + } - _animateToClosedPositionDuringBounce(): void { + _animateToClosedPositionDuringBounce = (): void => { this._animateToClosedPosition(RIGHT_SWIPE_BOUNCE_BACK_DURATION); - }, + }; _animateBounceBack(duration: number): void { /** @@ -344,12 +342,13 @@ const SwipeableRow = createReactClass({ duration, this._animateToClosedPositionDuringBounce, ); - }, + } // Ignore swipes due to user's finger moving slightly when tapping _isValidSwipe(gestureState: GestureState): boolean { + const preventSwipeRight = this.props.preventSwipeRight ?? false; if ( - this.props.preventSwipeRight && + preventSwipeRight && this._previousLeft === CLOSED_LEFT_POSITION && gestureState.dx > 0 ) { @@ -357,49 +356,19 @@ const SwipeableRow = createReactClass({ } return Math.abs(gestureState.dx) > HORIZONTAL_SWIPE_DISTANCE_THRESHOLD; - }, + } _shouldAnimateRemainder(gestureState: GestureState): boolean { /** * If user has swiped past a certain distance, animate the rest of the way * if they let go */ + const swipeThreshold = this.props.swipeThreshold ?? DEFAULT_SWIPE_THRESHOLD; return ( - Math.abs(gestureState.dx) > this.props.swipeThreshold || + Math.abs(gestureState.dx) > swipeThreshold || gestureState.vx > HORIZONTAL_FULL_SWIPE_SPEED_THRESHOLD ); - }, - - _handlePanResponderEnd(event: PressEvent, gestureState: GestureState): void { - const horizontalDistance = IS_RTL ? -gestureState.dx : 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._animateToOpenPositionWith(gestureState.vx, horizontalDistance); - } else { - // Swiped right - this.props.onClose(); - this._animateToClosedPosition(); - } - } else { - if (this._previousLeft === CLOSED_LEFT_POSITION) { - this._animateToClosedPosition(); - } else { - this._animateToOpenPosition(); - } - } - - this.props.onSwipeEnd(); - }, -}); - -// TODO: Delete this when `SwipeableRow` uses class syntax. -class TypedSwipeableRow extends React.Component { - close() {} + } } const styles = StyleSheet.create({ @@ -412,4 +381,4 @@ const styles = StyleSheet.create({ }, }); -module.exports = ((SwipeableRow: any): Class); +module.exports = SwipeableRow;