react-native-backdrop
Version:
Backdrop component built with Material Guidelines for React Native
264 lines (239 loc) • 6.87 kB
JavaScript
import React, {useState, useRef, useEffect, useCallback, useMemo} from 'react';
import {
Animated,
View,
TouchableOpacity,
PanResponder,
Dimensions,
BackHandler,
} from 'react-native';
import styles from './styles';
const {height} = Dimensions.get('window');
const swipeConfigDefault = {
velocityThreshold: 0.3,
directionalOffsetThreshold: 80,
};
const animationConfigDefault = {
useNativeDriver: true,
duration: 50,
speed: 14,
bounciness: 4,
};
const isValidSwipe = (
velocity,
velocityThreshold,
directionalOffset,
directionalOffsetThreshold,
) =>
Math.abs(velocity) > velocityThreshold &&
Math.abs(directionalOffset) < directionalOffsetThreshold;
const Backdrop = ({
visible = false,
overlayColor = 'rgba(0,0,0,0.3)',
children,
handleOpen = () => {},
handleClose = () => {},
closedHeight = 0,
header = null,
backdropStyle = {},
containerStyle = {backgroundColor: '#fff'},
animationConfig = {},
swipeConfig = {},
beforeOpen = () => {},
afterOpen = () => {},
beforeClose = () => {},
afterClose = () => {},
closeOnBackButton = false,
}) => {
const [contentHeight, setHeight] = useState(0);
const transitionY = useRef(new Animated.Value(height)).current;
useEffect(() => {
visible ? animationStart() : animationEnd();
}, [visible, animationStart, animationEnd]);
useEffect(() => {
closeOnBackButton &&
BackHandler.addEventListener('hardwareBackPress', onBackButtonPress);
return () => {
closeOnBackButton &&
BackHandler.removeEventListener('hardwareBackPress', onBackButtonPress);
};
}, [closeOnBackButton, onBackButtonPress]);
const swipeConfigConcated = useMemo(
() => ({...swipeConfigDefault, ...swipeConfig}),
[swipeConfig],
);
const animationConfigConcated = useMemo(
() => ({
...animationConfigDefault,
...animationConfig,
}),
[animationConfig],
);
const animationStart = useCallback(() => {
Animated.spring(transitionY, {
toValue: 0,
...animationConfigConcated,
}).start(() => afterOpen());
}, [transitionY, afterOpen, animationConfigConcated]);
const animationEnd = useCallback(() => {
Animated.spring(transitionY, {
toValue: contentHeight - closedHeight,
...animationConfigConcated,
}).start(() => afterClose());
}, [
transitionY,
contentHeight,
closedHeight,
afterClose,
animationConfigConcated,
]);
const onLayout = useCallback(
(event) => {
if (!contentHeight || !visible) {
transitionY.setValue(event.nativeEvent.layout.height - closedHeight);
setHeight(event.nativeEvent.layout.height);
}
},
[contentHeight, closedHeight, transitionY, visible],
);
const onBackButtonPress = useCallback(() => {
_handleClose();
return true;
}, [_handleClose]);
const _panResponder = PanResponder.create({
onStartShouldSetPanResponder: (evt) => true,
onPanResponderMove: (e, gestureState) => {
if (visible) {
Animated.event([null, {dy: transitionY}], {useNativeDriver: false})(
e,
gestureState,
);
} else {
transitionY.setValue(gestureState.dy + contentHeight - closedHeight);
}
},
onPanResponderRelease: (evt, gestureState) => {
if (_isValidVerticalSwipe(gestureState)) {
if (gestureState.dy > 0) {
_handleClose();
} else {
_handleOpen();
}
} else {
const {vy, dy} = gestureState;
const halfHeight = dy > contentHeight / 2;
if (vy > 0 && halfHeight) {
_handleClose();
} else {
_handleOpen();
}
}
},
});
const _isValidVerticalSwipe = useCallback(
(gestureState) => {
const {vy, dx} = gestureState;
const {
velocityThreshold,
directionalOffsetThreshold,
} = swipeConfigConcated;
return isValidSwipe(
vy,
velocityThreshold,
dx,
directionalOffsetThreshold,
);
},
[swipeConfigConcated],
);
const _handleOpen = useCallback(() => {
beforeOpen();
animationStart();
handleOpen();
}, [beforeOpen, handleOpen, animationStart]);
const _handleClose = useCallback(() => {
beforeClose();
handleClose();
}, [beforeClose, handleClose]);
const clampedTransition = useMemo(
() =>
transitionY.interpolate({
inputRange: [0, contentHeight ? contentHeight - closedHeight : 1],
outputRange: [
contentHeight > height ? contentHeight - height + closedHeight : 0,
contentHeight ? contentHeight - closedHeight : 1,
],
extrapolate: 'clamp',
}),
[closedHeight, contentHeight, transitionY],
);
const clampedOpacity = useMemo(
() =>
transitionY.interpolate({
inputRange: [0, contentHeight ? contentHeight - closedHeight : 1],
outputRange: [1, 0],
extrapolate: 'clamp',
}),
[closedHeight, contentHeight, transitionY],
);
const clampedContentOpacity = useMemo(
() =>
transitionY.interpolate({
inputRange: [
0,
contentHeight ? (contentHeight - closedHeight) / 1.1 : 0.95,
contentHeight ? contentHeight - closedHeight : 1,
],
outputRange: [1, 1, 0],
extrapolate: 'clamp',
}),
[closedHeight, contentHeight, transitionY],
);
return (
<View pointerEvents="box-none" style={styles.wrapper}>
<Animated.View
style={[
styles.overlayStyle,
backdropStyle,
{
backgroundColor: overlayColor,
opacity: clampedOpacity,
},
]}
pointerEvents={visible ? 'auto' : 'none'}>
<TouchableOpacity
style={styles.overlayTouchable}
onPress={_handleClose}
/>
</Animated.View>
<Animated.View
pointerEvents="box-none"
accessibilityLiveRegion="polite"
style={[
styles.contentContainer,
{
transform: [
{
translateY: clampedTransition,
},
],
opacity:
closedHeight > 0
? contentHeight
? 1
: 0
: clampedContentOpacity,
},
]}>
<View
style={containerStyle}
onLayout={onLayout}
{..._panResponder.panHandlers}>
{header}
{children}
</View>
</Animated.View>
</View>
);
};
export default Backdrop;