UNPKG

@devvie/bottom-sheet

Version:

The 😎smart , 📦tiny , and 🎗flexible bottom sheet your app craves 🚀

392 lines (375 loc) • 18.5 kB
"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