@devvie/bottom-sheet
Version:
The 😎smart , 📦tiny , and 🎗flexible bottom sheet your app craves 🚀
392 lines (375 loc) • 18.5 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = void 0;
var _react = _interopRequireWildcard(require("react"));
var _reactNative = require("react-native");
var _constant = require("../../constant");
var _defaultHandleBar = _interopRequireDefault(require("../defaultHandleBar"));
var _container = _interopRequireDefault(require("../container"));
var _normalizeHeight = _interopRequireDefault(require("../../utils/normalizeHeight"));
var _convertHeight = _interopRequireDefault(require("../../utils/convertHeight"));
var _useHandleKeyboardEvents = _interopRequireDefault(require("../../hooks/useHandleKeyboardEvents"));
var _useAnimatedValue = _interopRequireDefault(require("../../hooks/useAnimatedValue"));
var _backdrop = _interopRequireDefault(require("../backdrop"));
var _types = require("./types.d");
var _useHandleAndroidBackButtonClose = _interopRequireDefault(require("../../hooks/useHandleAndroidBackButtonClose"));
var _separatePaddingStyles = _interopRequireDefault(require("../../utils/separatePaddingStyles"));
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
function _extends() { _extends = Object.assign ? Object.assign.bind() : function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
/**
* Main bottom sheet component
*/
const BottomSheet = /*#__PURE__*/(0, _react.forwardRef)((_ref, ref) => {
let {
backdropMaskColor = _constant.DEFAULT_BACKDROP_MASK_COLOR,
children: Children,
animationType = _constant.DEFAULT_ANIMATION,
closeOnBackdropPress = true,
height = _constant.DEFAULT_HEIGHT,
hideDragHandle = false,
android_backdropMaskRippleColor,
dragHandleStyle,
disableBodyPanning = false,
disableDragHandlePanning = false,
customDragHandleComponent,
style: contentContainerStyle,
closeOnDragDown = true,
containerHeight: passedContainerHeight,
customBackdropComponent: CustomBackdropComponent,
customBackdropPosition = _types.CUSTOM_BACKDROP_POSITIONS.BEHIND,
modal = true,
openDuration = _constant.DEFAULT_OPEN_ANIMATION_DURATION,
closeDuration = _constant.DEFAULT_CLOSE_ANIMATION_DURATION,
customEasingFunction,
android_closeOnBackPress = true,
onClose,
onOpen,
onAnimate,
disableKeyboardHandling = false
} = _ref;
/**
* ref instance callable methods
*/
(0, _react.useImperativeHandle)(ref, () => ({
open() {
openBottomSheet();
},
close() {
closeBottomSheet();
}
}));
/**
* If passed container height is a valid number we use that as final container height
* else, it may be a percentage value so then we need to change it to a number (so it can be animated).
* The change is handled with `onLayout` further down
*/
const SCREEN_HEIGHT = (0, _reactNative.useWindowDimensions)().height; // actual container height is measured after layout
const [containerHeight, setContainerHeight] = (0, _react.useState)(SCREEN_HEIGHT);
const [sheetOpen, setSheetOpen] = (0, _react.useState)(false);
// animated properties
const _animatedContainerHeight = (0, _useAnimatedValue.default)(0);
const _animatedBackdropMaskOpacity = (0, _useAnimatedValue.default)(0);
const _animatedHeight = (0, _useAnimatedValue.default)(0);
const contentWrapperRef = (0, _react.useRef)(null);
/** cached _nativeTag property of content container */
const cachedContentWrapperNativeTag = (0, _react.useRef)(undefined);
// here we separate all padding that may be applied via contentContainerStyle prop,
// these paddings will be applied to the `View` diretly wrapping `ChildNodes` in content container.
// All these is so that paddings applied to sheet doesn't affect the drag handle
// TODO: find better way to memoize `separatePaddingStyles` function return value to avoid
// redundant re-runs
const sepStyles = (0, _react.useMemo)(() => (0, _separatePaddingStyles.default)(contentContainerStyle), [contentContainerStyle]);
// Animation utility
const Animators = (0, _react.useMemo)(() => ({
_slideEasingFn(value) {
return value === 1 ? 1 : 1 - Math.pow(2, -10 * value);
},
_springEasingFn(value) {
const c4 = 2 * Math.PI / 2.5;
return value === 0 ? 0 : value === 1 ? 1 : Math.pow(2, -9 * value) * Math.sin((value * 4.5 - 0.75) * c4) + 1;
},
animateContainerHeight(toValue) {
let duration = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0;
return _reactNative.Animated.timing(_animatedContainerHeight, {
toValue: toValue,
useNativeDriver: false,
duration: duration
});
},
animateBackdropMaskOpacity(toValue, duration) {
// we use passed open and close durations when animation type is fade
// but we use half of that for other animation types for good UX
const _duration = animationType === _types.ANIMATIONS.FADE ? duration : duration / 2.5;
return _reactNative.Animated.timing(_animatedBackdropMaskOpacity, {
toValue: toValue,
useNativeDriver: false,
duration: _duration
});
},
animateHeight(toValue, duration) {
return _reactNative.Animated.timing(_animatedHeight, {
toValue,
useNativeDriver: false,
duration: duration,
easing: customEasingFunction && typeof customEasingFunction === 'function' ? customEasingFunction : animationType === _types.ANIMATIONS.SLIDE ? this._slideEasingFn : this._springEasingFn
});
}
}), [animationType, customEasingFunction, _animatedContainerHeight, _animatedBackdropMaskOpacity, _animatedHeight]);
const interpolatedOpacity = (0, _react.useMemo)(() => animationType === _types.ANIMATIONS.FADE ? _animatedBackdropMaskOpacity.interpolate({
inputRange: [0, 0.5, 1],
outputRange: [0, 0.3, 1],
extrapolate: 'clamp'
}) : contentContainerStyle?.opacity, [animationType, contentContainerStyle, _animatedBackdropMaskOpacity]);
/**
* `height` prop converted from percentage e.g `'50%'` to pixel unit e.g `320`,
* relative to `containerHeight` or `DEVICE_SCREEN_HEIGHT`.
* Also auto calculates and adjusts container wrapper height when `containerHeight`
* or `height` changes
*/
const convertedHeight = (0, _react.useMemo)(() => {
const newHeight = (0, _convertHeight.default)(height, containerHeight, hideDragHandle);
// FIXME: we use interface-undefined but existing property `_value` here and it's risky
// @ts-expect-error
const curHeight = _animatedHeight._value;
if (sheetOpen && newHeight !== curHeight) {
if (animationType === _types.ANIMATIONS.FADE) _animatedHeight.setValue(newHeight);else Animators.animateHeight(newHeight, newHeight > curHeight ? openDuration : closeDuration).start();
}
return newHeight;
}, [containerHeight, height, animationType, sheetOpen, Animators, _animatedHeight, closeDuration, hideDragHandle, openDuration]);
/**
* If `disableKeyboardHandling` is false, handles keyboard pop up for both platforms,
* by auto adjusting sheet layout accordingly
*/
const keyboardHandler = (0, _useHandleKeyboardEvents.default)(!disableKeyboardHandling, convertedHeight, sheetOpen, Animators.animateHeight, contentWrapperRef);
/**
* Returns conditioned gesture handlers for content container and handle bar elements
*/
const panHandlersFor = view => {
if (view === 'handlebar' && disableDragHandlePanning) return null;
if (view === 'contentwrapper' && disableBodyPanning) return null;
return _reactNative.PanResponder.create({
onMoveShouldSetPanResponder: evt => {
/**
* `FiberNode._nativeTag` is stable across renders so we use it to determine
* whether content container or it's child should respond to touch move gesture.
*
* The logic is, when content container is laid out, we extract it's _nativeTag property and cache it
* So later when a move gesture event occurs within it, we compare the cached _nativeTag with the _nativeTag of
* the event target's _nativeTag, if they match, then content container should respond, else its children should.
* Also, when the target is the handle bar, we le it handle geture unless panning is disabled through props
*/
return view === 'handlebar' ? true : cachedContentWrapperNativeTag.current ===
// @ts-expect-error
evt?.target?._nativeTag;
},
onPanResponderMove: (_, gestureState) => {
if (gestureState.dy > 0) {
// backdrop opacity relative to the height of the content sheet
// to makes the backdrop more transparent as you drag the content sheet down
const relativeOpacity = 1 - gestureState.dy / convertedHeight;
_animatedBackdropMaskOpacity.setValue(relativeOpacity);
if (animationType !== _types.ANIMATIONS.FADE) _animatedHeight.setValue(convertedHeight - gestureState.dy);
}
},
onPanResponderRelease(_, gestureState) {
if (gestureState.dy >= convertedHeight / 3 && closeOnDragDown) {
closeBottomSheet();
} else {
_animatedBackdropMaskOpacity.setValue(1);
if (animationType !== _types.ANIMATIONS.FADE) Animators.animateHeight(convertedHeight, openDuration / 2).start();
}
}
}).panHandlers;
};
/**
* Polymorphic content container handle bar component
*/
/* eslint-disable react/no-unstable-nested-components, react-native/no-inline-styles */
const PolymorphicHandleBar = () => {
const CustomHandleBar = customDragHandleComponent;
return hideDragHandle ? null : CustomHandleBar && typeof CustomHandleBar === 'function' ? /*#__PURE__*/_react.default.createElement(_reactNative.View, _extends({
style: {
alignSelf: 'center'
}
}, panHandlersFor('handlebar')), /*#__PURE__*/_react.default.createElement(CustomHandleBar, {
_animatedHeight: _animatedHeight
})) : /*#__PURE__*/_react.default.createElement(_defaultHandleBar.default, _extends({
style: dragHandleStyle
}, panHandlersFor('handlebar')));
};
/* eslint-enable react/no-unstable-nested-components, react-native/no-inline-styles */
/**
* Extracts and caches the _nativeTag property of ContentWrapper
*/
let extractNativeTag = (0, _react.useCallback)(_ref2 => {
let {
target
} = _ref2;
const tag = _reactNative.Platform.OS === 'web' ? undefined :
// @ts-expect-error
target?._nativeTag;
if (!cachedContentWrapperNativeTag.current) cachedContentWrapperNativeTag.current = tag;
}, []);
/**
* Expands the bottom sheet.
*/
const openBottomSheet = () => {
// 1. open container
// 2. if using fade animation, set content container height convertedHeight manually, animate backdrop.
// else, animate backdrop and content container height in parallel
Animators.animateContainerHeight(!modal ? convertedHeight : containerHeight).start();
if (animationType === _types.ANIMATIONS.FADE) {
_animatedHeight.setValue(convertedHeight);
Animators.animateBackdropMaskOpacity(1, openDuration).start();
} else {
Animators.animateBackdropMaskOpacity(1, openDuration).start();
Animators.animateHeight(convertedHeight, openDuration).start();
}
setSheetOpen(true);
if (onOpen) {
onOpen();
}
};
const closeBottomSheet = () => {
// 1. fade backdrop
// 2. if using fade animation, close container, set content wrapper height to 0.
// else animate content container height & container height to 0, in sequence
Animators.animateBackdropMaskOpacity(0, closeDuration).start(anim => {
if (anim.finished) {
if (animationType === _types.ANIMATIONS.FADE) {
Animators.animateContainerHeight(0).start();
_animatedHeight.setValue(0);
} else {
Animators.animateHeight(0, closeDuration).start();
Animators.animateContainerHeight(0).start();
}
}
});
setSheetOpen(false);
keyboardHandler?.removeKeyboardListeners();
_reactNative.Keyboard.dismiss();
if (onClose) {
onClose();
}
};
const containerViewLayoutHandler = event => {
const newHeight = event.nativeEvent.layout.height;
setContainerHeight(newHeight);
// incase `containerHeight` prop value changes when bottom sheet is expanded
// we need to manually update the container height
if (sheetOpen) _animatedContainerHeight.setValue(newHeight);
};
/**
* Implementation logic for `onAnimate` prop
*/
(0, _react.useEffect)(() => {
if (onAnimate && typeof onAnimate === 'function') {
const animate = state => onAnimate(state.value);
let listenerId;
if (animationType === 'fade') listenerId = _animatedBackdropMaskOpacity.addListener(animate);else listenerId = _animatedHeight.addListener(animate);
return () => {
if (animationType === 'fade') _animatedBackdropMaskOpacity.removeListener(listenerId);else _animatedHeight.removeListener(listenerId);
};
}
return;
}, [onAnimate, animationType, _animatedBackdropMaskOpacity, _animatedHeight]);
/**
* Handles auto adjusting container view height and clamping
* and normalizing `containerHeight` prop upon change, if its a number.
* Also auto adjusts when orientation changes
*/
(0, _react.useLayoutEffect)(() => {
if (!modal) return; // no auto layout adjustment when backdrop is hidden
else {
if (typeof passedContainerHeight === 'number') {
setContainerHeight((0, _normalizeHeight.default)(passedContainerHeight));
if (sheetOpen) _animatedContainerHeight.setValue(passedContainerHeight);
} else if (typeof passedContainerHeight === 'undefined' && containerHeight !== SCREEN_HEIGHT) {
setContainerHeight(SCREEN_HEIGHT);
if (sheetOpen) _animatedContainerHeight.setValue(SCREEN_HEIGHT);
}
}
}, [passedContainerHeight, SCREEN_HEIGHT, sheetOpen, containerHeight, modal, _animatedContainerHeight]);
/**
* Handles hardware back button press for android
*/
(0, _useHandleAndroidBackButtonClose.default)(android_closeOnBackPress, closeBottomSheet, sheetOpen);
// Children
const ChildNodes = typeof Children === 'function' ? /*#__PURE__*/_react.default.createElement(Children, {
_animatedHeight: _animatedHeight
}) : Children;
return /*#__PURE__*/_react.default.createElement(_react.default.Fragment, null, typeof passedContainerHeight === 'string' ?
/*#__PURE__*/
/**
* Below View handles converting `passedContainerHeight` from string to a number (to be animatable).
* It does this by taking the string height passed via `containerHeight` prop,
* and returning it's numeric equivalent after rendering, via its `onLayout` so we can
* use that as the final container height.
*/
_react.default.createElement(_reactNative.View, {
onLayout: containerViewLayoutHandler,
style: {
height: passedContainerHeight
}
}) : null, /*#__PURE__*/_react.default.createElement(_container.default, {
style: {
height: _animatedContainerHeight
}
}, modal ? /*#__PURE__*/_react.default.createElement(_backdrop.default, {
BackdropComponent: CustomBackdropComponent,
_animatedHeight: _animatedHeight,
animatedBackdropOpacity: _animatedBackdropMaskOpacity,
backdropColor: backdropMaskColor,
backdropPosition: customBackdropPosition,
closeOnPress: closeOnBackdropPress,
containerHeight: containerHeight,
contentContainerHeight: convertedHeight,
pressHandler: closeBottomSheet,
rippleColor: android_backdropMaskRippleColor,
sheetOpen: sheetOpen
}) : null, /*#__PURE__*/_react.default.createElement(_reactNative.Animated.View, _extends({
ref: contentWrapperRef,
key: 'BottomSheetContentContainer',
onLayout: extractNativeTag
/* Merge external and internal styles carefully and orderly */,
style: [!modal ? materialStyles.contentContainerShadow : false, materialStyles.contentContainer,
// we apply styles other than padding here
sepStyles?.otherStyles, {
height: _animatedHeight,
minHeight: _animatedHeight,
opacity: interpolatedOpacity
}]
}, panHandlersFor('contentwrapper')), /*#__PURE__*/_react.default.createElement(PolymorphicHandleBar, null), /*#__PURE__*/_react.default.createElement(_reactNative.View
// we apply padding styles here to not affect drag handle above
, {
style: sepStyles?.paddingStyles
}, ChildNodes))));
});
BottomSheet.displayName = 'BottomSheet';
BottomSheet.ANIMATIONS = _types.ANIMATIONS;
const materialStyles = _reactNative.StyleSheet.create({
contentContainer: {
backgroundColor: '#F7F2FA',
width: '100%',
overflow: 'hidden',
borderTopLeftRadius: 28,
borderTopRightRadius: 28
},
contentContainerShadow: _reactNative.Platform.OS === 'android' ? {
elevation: 7
} : {
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 3
},
shadowOpacity: 0.29,
shadowRadius: 4.65
}
});
var _default = exports.default = BottomSheet;
//# sourceMappingURL=index.js.map