UNPKG

rn-sliding-up-panel

Version:

Draggable sliding up panel implemented in React Native

518 lines (419 loc) 14.5 kB
import clamp from 'clamp' import PropTypes from 'prop-types' import React from 'react' import {ViewPropTypes} from 'deprecated-react-native-prop-types' import { Animated, BackHandler, findNodeHandle, Keyboard, PanResponder, Platform, TextInput, UIManager } from 'react-native' import closest from './libs/closest' import * as Constants from './libs/constants' import FlickAnimation from './libs/FlickAnimation' import {statusBarHeight, visibleHeight} from './libs/layout' import measureElement from './libs/measureElement' import styles from './libs/styles' const keyboardShowEvent = Platform.select({ android: 'keyboardDidShow', ios: 'keyboardWillShow' }) const keyboardHideEvent = Platform.select({ android: 'keyboardDidHide', ios: 'keyboardWillHide' }) const usableHeight = visibleHeight() - statusBarHeight() class SlidingUpPanel extends React.PureComponent { static propTypes = { height: PropTypes.number, animatedValue: PropTypes.instanceOf(Animated.Value), draggableRange: PropTypes.shape({ top: PropTypes.number, bottom: PropTypes.number }), snappingPoints: PropTypes.arrayOf(PropTypes.number), minimumVelocityThreshold: PropTypes.number, minimumDistanceThreshold: PropTypes.number, avoidKeyboard: PropTypes.bool, onBackButtonPress: PropTypes.func, onDragStart: PropTypes.func, onDragEnd: PropTypes.func, onMomentumDragStart: PropTypes.func, onMomentumDragEnd: PropTypes.func, onBottomReached: PropTypes.func, allowMomentum: PropTypes.bool, allowDragging: PropTypes.bool, showBackdrop: PropTypes.bool, backdropOpacity: PropTypes.number, friction: PropTypes.number, containerStyle: ViewPropTypes.style, backdropStyle: ViewPropTypes.style, children: PropTypes.oneOfType([PropTypes.element, PropTypes.func]) } static defaultProps = { height: usableHeight, animatedValue: new Animated.Value(0), draggableRange: {top: usableHeight, bottom: 0}, snappingPoints: [], minimumVelocityThreshold: Constants.DEFAULT_MINIMUM_VELOCITY_THRESHOLD, minimumDistanceThreshold: Constants.DEFAULT_MINIMUM_DISTANCE_THRESHOLD, avoidKeyboard: true, onBackButtonPress: null, onDragStart: () => {}, onDragEnd: () => {}, onMomentumDragStart: () => {}, onMomentumDragEnd: () => {}, allowMomentum: true, allowDragging: true, showBackdrop: true, backdropOpacity: 0.75, friction: Constants.DEFAULT_FRICTION, onBottomReached: () => null } // eslint-disable-next-line react/sort-comp _panResponder = PanResponder.create({ onMoveShouldSetPanResponder: this._onMoveShouldSetPanResponder.bind(this), onPanResponderGrant: this._onPanResponderGrant.bind(this), onPanResponderMove: this._onPanResponderMove.bind(this), onPanResponderRelease: this._onPanResponderRelease.bind(this), onPanResponderTerminate: this._onPanResponderTerminate.bind(this), onShouldBlockNativeResponder: () => true, onPanResponderTerminationRequest: () => false }) _keyboardShowListener = Keyboard.addListener( keyboardShowEvent, this._onKeyboardShown.bind(this) ) _keyboardHideListener = Keyboard.addListener( keyboardHideEvent, this._onKeyboardHiden.bind(this) ) _backButtonListener = BackHandler.addEventListener( 'hardwareBackPress', this._onBackButtonPress.bind(this) ) constructor(props) { super(props) this._storeKeyboardPosition = this._storeKeyboardPosition.bind(this) this._isInsideDraggableRange = this._isInsideDraggableRange.bind(this) this._triggerAnimation = this._triggerAnimation.bind(this) this._renderContent = this._renderContent.bind(this) this._renderBackdrop = this._renderBackdrop.bind(this) this.show = this.show.bind(this) this.hide = this.hide.bind(this) this.scrollIntoView = this.scrollIntoView.bind(this) const {top, bottom} = this.props.draggableRange const animatedValue = this.props.animatedValue.__getValue() const initialValue = clamp(animatedValue, bottom, top) // Ensure the animation are within draggable range this.props.animatedValue.setValue(initialValue) this._initialDragPosition = initialValue this._backdropPointerEvents = this._isAtBottom(initialValue) ? 'none' : 'box-only' // prettier-ignore this._flick = new FlickAnimation({max: top, min: bottom}) this._flickAnimationListener = this._flick.onUpdate(value => { this.props.animatedValue.setValue(value) }) this._animatedValueListener = this.props.animatedValue.addListener( this._onAnimatedValueChange.bind(this) ) } componentDidUpdate(prevProps) { if ( prevProps.draggableRange.top !== this.props.draggableRange.top || prevProps.draggableRange.bottom !== this.props.draggableRange.bottom ) { const {top, bottom} = this.props.draggableRange const animatedValue = this.props.animatedValue.__getValue() this._flick.setMax(top) this._flick.setMin(bottom) // If the panel is below the new 'bottom' if (animatedValue < bottom || animatedValue > top) { const newValue = clamp(animatedValue, bottom, top) this.props.animatedValue.setValue(newValue) } } } componentWillUnmount() { if (this._animatedValueListener != null) { this.props.animatedValue.removeListener(this._animatedValueListener) } if (this._keyboardShowListener != null) { this._keyboardShowListener.remove() } if (this._keyboardHideListener != null) { this._keyboardHideListener.remove() } if (this._flickAnimationListener != null) { this._flickAnimationListener.remove() } if (this._backButtonListener != null) { this._backButtonListener.remove() } } _onMoveShouldSetPanResponder(evt, gestureState) { if (!this.props.allowDragging) { return false } const animatedValue = this.props.animatedValue.__getValue() return ( this._isInsideDraggableRange(animatedValue, gestureState) && Math.abs(gestureState.dy) > this.props.minimumDistanceThreshold ) } _onPanResponderGrant(evt, gestureState) { this._flick.stop() const value = this.props.animatedValue.__getValue() this._initialDragPosition = value this.props.onDragStart(value, gestureState) } _onPanResponderMove(evt, gestureState) { const {top, bottom} = this.props.draggableRange const delta = this._initialDragPosition - gestureState.dy const newValue = clamp(delta, top, bottom) this.props.animatedValue.setValue(newValue) } // Trigger when you release your finger _onPanResponderRelease(evt, gestureState) { const animatedValue = this.props.animatedValue.__getValue() if (!this._isInsideDraggableRange(animatedValue, gestureState)) { return true } this._initialDragPosition = animatedValue this.props.onDragEnd(animatedValue, gestureState) if (!this.props.allowMomentum) { return true } if (this.props.snappingPoints.length > 0) { this.props.onMomentumDragStart(animatedValue) const {top, bottom} = this.props.draggableRange const nextPoint = this._flick.predictNextPosition({ fromValue: animatedValue, velocity: gestureState.vy, friction: this.props.friction }) const closestPoint = closest(nextPoint, [ bottom, ...this.props.snappingPoints, top ]) const remainingDistance = animatedValue - closestPoint const velocity = remainingDistance / Constants.TIME_CONSTANT this._flick.start({ velocity, toValue: closestPoint, fromValue: animatedValue, friction: this.props.friction, onMomentumEnd: this.props.onMomentumDragEnd }) return true } if (Math.abs(gestureState.vy) > this.props.minimumVelocityThreshold) { this.props.onMomentumDragStart(animatedValue) this._flick.start({ velocity: gestureState.vy, fromValue: animatedValue, friction: this.props.friction, onMomentumEnd: this.props.onMomentumDragEnd }) } return true } _onPanResponderTerminate(evt, gestureState) { const animatedValue = this.props.animatedValue.__getValue() if (!this._isInsideDraggableRange(animatedValue, gestureState)) { return } this._initialDragPosition = animatedValue this.props.onDragEnd(animatedValue, gestureState) } _onAnimatedValueChange({value}) { const isAtBottom = this._isAtBottom(value) if (isAtBottom) { this.props.onBottomReached() this.props.avoidKeyboard && Keyboard.dismiss() } if (this._backdrop == null) { return } // @TODO: Find a better way to update pointer events when animated value changed if (isAtBottom && this._backdropPointerEvents === 'box-only') { this._backdropPointerEvents = 'none' this._backdrop.setNativeProps({pointerEvents: 'none'}) } if (!isAtBottom && this._backdropPointerEvents === 'none') { this._backdropPointerEvents = 'box-only' this._backdrop.setNativeProps({pointerEvents: 'box-only'}) } } _onKeyboardShown(event) { if (!this.props.avoidKeyboard) { return } this._storeKeyboardPosition(event.endCoordinates.screenY) const node = TextInput.State.currentlyFocusedInput ? findNodeHandle(TextInput.State.currentlyFocusedInput()) : TextInput.State.currentlyFocusedField() if (node != null) { UIManager.viewIsDescendantOf( node, findNodeHandle(this._content), isDescendant => { isDescendant && this.scrollIntoView(node) } ) } } _onKeyboardHiden() { this._storeKeyboardPosition(0) const animatedValue = this.props.animatedValue.__getValue() // Restore last position if (this._lastPosition != null && !this._isAtBottom(animatedValue)) { Animated.timing(this.props.animatedValue, { toValue: this._lastPosition, duration: Constants.KEYBOARD_TRANSITION_DURATION, useNativeDriver: true }).start() } this._lastPosition = null } _onBackButtonPress() { if (this.props.onBackButtonPress) { return this.props.onBackButtonPress() } const value = this.props.animatedValue.__getValue() if (this._isAtBottom(value)) { return false } this.hide() return true } _isInsideDraggableRange(value, gestureState) { const {top, bottom} = this.props.draggableRange if (gestureState.dy > 0) { return value >= bottom } return value <= top } _isAtBottom(value) { const {bottom} = this.props.draggableRange return value <= bottom } _storeKeyboardPosition(value) { this._keyboardYPosition = value } _triggerAnimation(options = {}) { const animatedValue = this.props.animatedValue.__getValue() const remainingDistance = animatedValue - options.toValue const velocity = options.velocity || remainingDistance / Constants.TIME_CONSTANT // prettier-ignore this._flick.start({ velocity, toValue: options.toValue, fromValue: animatedValue, friction: this.props.friction }) } _renderBackdrop() { if (!this.props.showBackdrop) { return null } const {top, bottom} = this.props.draggableRange const {backdropStyle} = this.props const backdropOpacity = this.props.animatedValue.interpolate({ inputRange: [bottom, top], outputRange: [0, this.props.backdropOpacity], extrapolate: 'clamp' }) return ( <Animated.View key="backdrop" pointerEvents={this._backdropPointerEvents} ref={c => (this._backdrop = c)} onTouchStart={() => this._flick.stop()} onTouchEnd={() => this.hide()} style={[styles.backdrop, backdropStyle, {opacity: backdropOpacity}]} /> ) } _renderContent() { const { height, draggableRange: {top, bottom}, containerStyle } = this.props const translateY = this.props.animatedValue.interpolate({ inputRange: [bottom, top], outputRange: [-bottom, -top], extrapolate: 'clamp' }) const transform = {transform: [{translateY}]} const animatedContainerStyles = [ styles.animatedContainer, transform, containerStyle, {height, bottom: -height} ] if (typeof this.props.children === 'function') { return ( <Animated.View key="content" pointerEvents="box-none" ref={c => (this._content = c)} style={animatedContainerStyles}> {this.props.children(this._panResponder.panHandlers)} </Animated.View> ) } return ( <Animated.View key="content" pointerEvents="box-none" ref={c => (this._content = c)} style={animatedContainerStyles} {...this._panResponder.panHandlers}> {this.props.children} </Animated.View> ) } render() { return [this._renderBackdrop(), this._renderContent()] } show(mayBeValueOrOptions) { if (!mayBeValueOrOptions) { const {top} = this.props.draggableRange return this._triggerAnimation({toValue: top}) } if (typeof mayBeValueOrOptions === 'object') { return this._triggerAnimation(mayBeValueOrOptions) } return this._triggerAnimation({toValue: mayBeValueOrOptions}) } hide() { const {bottom} = this.props.draggableRange this._triggerAnimation({toValue: bottom}) } async scrollIntoView(node, options = {}) { if (!this._keyboardYPosition) { return } // Stop any animation when the keyboard starts showing this._flick.stop() const {y} = await measureElement(node) const extraMargin = options.keyboardExtraMargin || Constants.KEYBOARD_EXTRA_MARGIN // prettier-ignore const keyboardActualPos = this._keyboardYPosition - extraMargin if (y > keyboardActualPos) { this._lastPosition = this.props.animatedValue.__getValue() const fromKeyboardToElement = y - keyboardActualPos const transitionDistance = this._lastPosition + fromKeyboardToElement Animated.timing(this.props.animatedValue, { toValue: transitionDistance, duration: Constants.KEYBOARD_TRANSITION_DURATION, useNativeDriver: true }).start() } } } export default SlidingUpPanel