UNPKG

react-native-ui-lib

Version:

[![SWUbanner](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct.svg)](https://stand-with-ukraine.pp.ua)

350 lines (349 loc) • 10.7 kB
import _isUndefined from "lodash/isUndefined"; import _get from "lodash/get"; import _noop from "lodash/noop"; import React, { PureComponent } from 'react'; import { Animated } from 'react-native'; import { Constants } from "../../commons/new"; import asPanViewConsumer from "./asPanViewConsumer"; import PanningProvider from "./panningProvider"; const DEFAULT_DIRECTIONS = [PanningProvider.Directions.UP, PanningProvider.Directions.DOWN, PanningProvider.Directions.LEFT, PanningProvider.Directions.RIGHT]; const DEFAULT_SPEED = 20; const DEFAULT_BOUNCINESS = 6; const DEFAULT_DISMISS_ANIMATION_DURATION = 280; const DEFAULT_ANIMATION_OPTIONS = { speed: DEFAULT_SPEED, bounciness: DEFAULT_BOUNCINESS, duration: DEFAULT_DISMISS_ANIMATION_DURATION }; const MAXIMUM_DRAGS_AFTER_SWIPE = 2; /** * @description: PanDismissibleView component created to making listening to swipe and drag events easier, * @notes: Has to be used as a child of a PanningProvider that also has a PanListenerView. * The PanListenerView is the one that sends the drag\swipe events. * @gif: https://github.com/wix/react-native-ui-lib/blob/master/demo/showcase/PanDismissibleView/PanDismissibleView.gif?raw=true */ class PanDismissibleView extends PureComponent { static displayName = 'IGNORE'; static defaultProps = { directions: DEFAULT_DIRECTIONS, animationOptions: DEFAULT_ANIMATION_OPTIONS, onDismiss: _noop, allowDiagonalDismiss: false }; shouldDismissAfterReset = false; ref = React.createRef(); animTranslateX = new Animated.Value(0); animTranslateY = new Animated.Value(0); left = 0; top = 0; width = 0; height = 0; thresholdX = 0; thresholdY = 0; swipe = {}; counter = 0; constructor(props) { super(props); this.state = { isAnimating: false }; } componentDidUpdate(prevProps) { const { isAnimating } = this.state; const { isPanning, dragDeltas, swipeDirections } = this.props.context; const { isPanning: prevIsPanning, dragDeltas: prevDragDeltas, swipeDirections: prevSwipeDirections } = prevProps.context; if (isPanning !== prevIsPanning) { if (isPanning && !isAnimating) { // do not start a new pan if we're still animating this.onPanStart(); } else { this.onPanEnd(); } } if (isPanning && (dragDeltas.x || dragDeltas.y) && (dragDeltas.x !== prevDragDeltas.x || dragDeltas.y !== prevDragDeltas.y)) { this.onDrag(dragDeltas); } if (isPanning && (swipeDirections.x || swipeDirections.y) && (swipeDirections.x !== prevSwipeDirections.x || swipeDirections.y !== prevSwipeDirections.y)) { this.onSwipe(swipeDirections); } } onLayout = event => { if (this.height === 0) { const layout = event.nativeEvent.layout; const { threshold } = this.props; this.height = layout.height; this.thresholdY = _get(threshold, 'y', layout.height / 2); this.width = layout.width; this.thresholdX = _get(threshold, 'x', layout.width / 2); this.initPositions(); } }; initPositions = (extraDataForSetState, runAfterSetState) => { this.setNativeProps(0, 0); this.animTranslateX = new Animated.Value(0); this.animTranslateY = new Animated.Value(0); this.setState({ ...extraDataForSetState }, runAfterSetState); }; onPanStart = () => { this.swipe = {}; this.counter = 0; }; onDrag = deltas => { const left = deltas.x ? Math.round(deltas.x) : 0; const top = deltas.y ? Math.round(deltas.y) : 0; this.setNativeProps(left, top); if (this.swipe.x || this.swipe.y) { if (this.counter < MAXIMUM_DRAGS_AFTER_SWIPE) { this.counter += 1; } else { this.swipe = {}; } } }; setNativeProps = (left, top) => { if (this.ref.current) { this.ref.current?.setNativeProps?.({ style: { left, top } }); this.left = left; this.top = top; } }; onSwipe = swipeDirections => { this.swipe = swipeDirections; }; onPanEnd = () => { const { directions = DEFAULT_DIRECTIONS } = this.props; if (this.swipe.x || this.swipe.y) { const { isRight, isDown } = this.getDismissAnimationDirection(); this._animateDismiss(isRight, isDown); } else { const endValue = { x: Math.round(this.left), y: Math.round(this.top) }; if (directions.includes(PanningProvider.Directions.LEFT) && endValue.x <= -this.thresholdX || directions.includes(PanningProvider.Directions.RIGHT) && endValue.x >= this.thresholdX || directions.includes(PanningProvider.Directions.UP) && endValue.y <= -this.thresholdY || directions.includes(PanningProvider.Directions.DOWN) && endValue.y >= this.thresholdY) { const { isRight, isDown } = this.getDismissAnimationDirection(); this._animateDismiss(isRight, isDown); } else { this.resetPosition(); } } }; resetPosition = () => { const { animationOptions } = this.props; const { speed, bounciness } = animationOptions || DEFAULT_ANIMATION_OPTIONS; const toX = -this.left; const toY = -this.top; const animations = []; if (!_isUndefined(toX)) { animations.push(Animated.spring(this.animTranslateX, { toValue: Math.round(toX), useNativeDriver: true, speed, bounciness })); } if (!_isUndefined(toY)) { animations.push(Animated.spring(this.animTranslateY, { toValue: Math.round(toY), useNativeDriver: true, speed, bounciness })); } this.setState({ isAnimating: true }, () => { Animated.parallel(animations).start(this.onResetPositionFinished); }); }; onResetPositionFinished = () => { const runAfterSetState = this.shouldDismissAfterReset ? this.animateDismiss : undefined; this.shouldDismissAfterReset = false; this.initPositions({ isAnimating: false }, runAfterSetState); }; getDismissAnimationDirection = () => { const { allowDiagonalDismiss } = this.props; const { swipeDirections, swipeVelocities, dragDirections, dragDeltas } = this.props.context; const hasHorizontalSwipe = !_isUndefined(swipeDirections.x); const hasVerticalSwipe = !_isUndefined(swipeDirections.y); let isRight; let isDown; if (hasHorizontalSwipe || hasVerticalSwipe) { if (!allowDiagonalDismiss && hasHorizontalSwipe && hasVerticalSwipe) { // @ts-ignore if (Math.abs(swipeVelocities.y) > Math.abs(swipeVelocities.x)) { isDown = swipeDirections.y === PanningProvider.Directions.DOWN; } else { isRight = swipeDirections.x === PanningProvider.Directions.RIGHT; } return { isRight, isDown }; } if (hasHorizontalSwipe) { isRight = swipeDirections.x === PanningProvider.Directions.RIGHT; } if (hasVerticalSwipe) { isDown = swipeDirections.y === PanningProvider.Directions.DOWN; } } else { // got here from a drag beyond threshold const hasHorizontalDrag = !_isUndefined(dragDirections.x); const hasVerticalDrag = !_isUndefined(dragDirections.y); if (!allowDiagonalDismiss && hasHorizontalDrag && hasVerticalDrag) { // @ts-ignore if (Math.abs(dragDeltas.y) > Math.abs(dragDeltas.x)) { isDown = dragDirections.y === PanningProvider.Directions.DOWN; } else { isRight = dragDirections.x === PanningProvider.Directions.RIGHT; } return { isRight, isDown }; } if (hasHorizontalDrag) { isRight = dragDirections.x === PanningProvider.Directions.RIGHT; } if (hasVerticalDrag) { isDown = dragDirections.y === PanningProvider.Directions.DOWN; } } return { isRight, isDown }; }; // Send undefined to not animate in the horizontal\vertical direction // isRight === true --> animate to the right // isRight === false --> animate to the left // isDown === true --> animate to the bottom // isDown === false --> animate to the top animateDismiss = () => { const { isAnimating } = this.state; if (isAnimating) { this.shouldDismissAfterReset = true; } else { const { directions = [] } = this.props; const hasUp = directions.includes(PanningProvider.Directions.UP); const hasRight = directions.includes(PanningProvider.Directions.RIGHT); const hasLeft = directions.includes(PanningProvider.Directions.LEFT); const hasDown = !hasUp && !hasLeft && !hasRight; // default const verticalDismiss = hasDown ? true : hasUp ? false : undefined; const horizontalDismiss = hasRight ? true : hasLeft ? false : undefined; this._animateDismiss(horizontalDismiss, verticalDismiss); } }; _animateDismiss = (isRight, isDown) => { const { animationOptions } = this.props; const { duration } = animationOptions || DEFAULT_ANIMATION_OPTIONS; const animations = []; let toX; let toY; if (!_isUndefined(isRight)) { const maxSize = Constants.screenWidth + this.width; toX = isRight ? maxSize : -maxSize; } if (!_isUndefined(isDown)) { const maxSize = Constants.screenHeight + this.height; toY = isDown ? maxSize : -maxSize; } if (!_isUndefined(toX)) { animations.push(Animated.timing(this.animTranslateX, { toValue: Math.round(toX), useNativeDriver: true, duration })); } if (!_isUndefined(toY)) { animations.push(Animated.timing(this.animTranslateY, { toValue: Math.round(toY), useNativeDriver: true, duration })); } this.setState({ isAnimating: true }, () => { Animated.parallel(animations).start(this.onDismissAnimationFinished); }); }; onDismissAnimationFinished = ({ finished }) => { if (finished) { this.props.onDismiss?.(); } }; render() { const { style } = this.props; const { isAnimating } = this.state; const transform = isAnimating ? [{ translateX: this.animTranslateX }, { translateY: this.animTranslateY }] : []; return <Animated.View // @ts-ignore ref={this.ref} style={[style, { transform }]} onLayout={this.onLayout}> {this.props.children} </Animated.View>; } } export default asPanViewConsumer(PanDismissibleView);