185 lines
5.6 KiB
TypeScript
185 lines
5.6 KiB
TypeScript
import { useBackHandler } from '@react-native-community/hooks';
|
|
import * as Haptics from 'expo-haptics';
|
|
import React, { forwardRef, isValidElement, useEffect, useImperativeHandle, useRef, useState, useCallback } from 'react';
|
|
import { Keyboard, useWindowDimensions } from 'react-native';
|
|
import { Easing, useDerivedValue, useSharedValue } from 'react-native-reanimated';
|
|
import BottomSheet, { BottomSheetBackdrop } from '@gorhom/bottom-sheet';
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
|
|
import { useTheme } from '../../theme';
|
|
import { isIOS, isTablet } from '../../lib/methods/helpers';
|
|
import { Handle } from './Handle';
|
|
import { TActionSheetOptions } from './Provider';
|
|
import BottomSheetContent from './BottomSheetContent';
|
|
import styles, { ITEM_HEIGHT } from './styles';
|
|
|
|
export const ACTION_SHEET_ANIMATION_DURATION = 250;
|
|
const HANDLE_HEIGHT = 28;
|
|
const CANCEL_HEIGHT = 64;
|
|
|
|
const ANIMATION_CONFIG = {
|
|
duration: ACTION_SHEET_ANIMATION_DURATION,
|
|
// https://easings.net/#easeInOutCubic
|
|
easing: Easing.bezier(0.645, 0.045, 0.355, 1.0)
|
|
};
|
|
|
|
const ActionSheet = React.memo(
|
|
forwardRef(({ children }: { children: React.ReactElement }, ref) => {
|
|
const { colors } = useTheme();
|
|
const { height: windowHeight } = useWindowDimensions();
|
|
const { bottom } = useSafeAreaInsets();
|
|
const bottomSheetRef = useRef<BottomSheet>(null);
|
|
const [data, setData] = useState<TActionSheetOptions>({} as TActionSheetOptions);
|
|
const [isVisible, setVisible] = useState(false);
|
|
const animatedContentHeight = useSharedValue(0);
|
|
const animatedHandleHeight = useSharedValue(0);
|
|
const animatedDataSnaps = useSharedValue<TActionSheetOptions['snaps']>([]);
|
|
const animatedSnapPoints = useDerivedValue(() => {
|
|
if (animatedDataSnaps.value?.length) {
|
|
return animatedDataSnaps.value;
|
|
}
|
|
const contentWithHandleHeight = animatedContentHeight.value + animatedHandleHeight.value;
|
|
// Bottom sheet requires a default value to work
|
|
if (contentWithHandleHeight === 0) {
|
|
return ['25%'];
|
|
}
|
|
return [contentWithHandleHeight];
|
|
}, [data]);
|
|
|
|
const handleContentLayout = useCallback(
|
|
({
|
|
nativeEvent: {
|
|
layout: { height }
|
|
}
|
|
}) => {
|
|
/**
|
|
* This logic is only necessary to prevent the action sheet from
|
|
* occupying the entire screen when the dynamic content is too big.
|
|
*/
|
|
animatedContentHeight.value = Math.min(height, windowHeight * 0.8);
|
|
},
|
|
[animatedContentHeight, windowHeight]
|
|
);
|
|
|
|
const maxSnap = Math.min(
|
|
(ITEM_HEIGHT + 0.5) * (data?.options?.length || 0) +
|
|
HANDLE_HEIGHT +
|
|
// Custom header height
|
|
(data?.headerHeight || 0) +
|
|
// Insets bottom height (Notch devices)
|
|
bottom +
|
|
// Cancel button height
|
|
(data?.hasCancel ? CANCEL_HEIGHT : 0),
|
|
windowHeight * 0.8
|
|
);
|
|
|
|
/*
|
|
* if the action sheet cover more than 60% of the screen height,
|
|
* we'll provide more one snap of 50%
|
|
*/
|
|
const snaps = maxSnap > windowHeight * 0.6 && !data.snaps ? ['50%', maxSnap] : [maxSnap];
|
|
|
|
const toggleVisible = () => setVisible(!isVisible);
|
|
|
|
const hide = () => {
|
|
bottomSheetRef.current?.close();
|
|
};
|
|
|
|
const show = (options: TActionSheetOptions) => {
|
|
setData(options);
|
|
if (options.snaps?.length) {
|
|
animatedDataSnaps.value = options.snaps;
|
|
}
|
|
toggleVisible();
|
|
};
|
|
|
|
useBackHandler(() => {
|
|
if (isVisible) {
|
|
hide();
|
|
}
|
|
return isVisible;
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (isVisible) {
|
|
Keyboard.dismiss();
|
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
}
|
|
}, [isVisible]);
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
showActionSheet: show,
|
|
hideActionSheet: hide
|
|
}));
|
|
|
|
const renderHandle = () => (
|
|
<>
|
|
<Handle />
|
|
{isValidElement(data?.customHeader) ? data.customHeader : null}
|
|
</>
|
|
);
|
|
|
|
const onClose = () => {
|
|
toggleVisible();
|
|
data?.onClose && data?.onClose();
|
|
animatedDataSnaps.value = [];
|
|
};
|
|
|
|
const renderBackdrop = useCallback(
|
|
props => (
|
|
<BottomSheetBackdrop
|
|
{...props}
|
|
appearsOnIndex={0}
|
|
// Backdrop should be visible all the time bottom sheet is open
|
|
disappearsOnIndex={-1}
|
|
opacity={colors.backdropOpacity}
|
|
/>
|
|
),
|
|
[]
|
|
);
|
|
|
|
const bottomSheet = isTablet ? styles.bottomSheet : {};
|
|
|
|
// Must need this prop to avoid keyboard dismiss
|
|
// when is android tablet and the input text is focused
|
|
const androidTablet: any = isTablet && !isIOS ? { android_keyboardInputMode: 'adjustResize' } : {};
|
|
|
|
return (
|
|
<>
|
|
{children}
|
|
{isVisible && (
|
|
<BottomSheet
|
|
ref={bottomSheetRef}
|
|
// If data.options exist, we calculate snaps to be precise, otherwise we cal
|
|
snapPoints={data.options?.length ? snaps : animatedSnapPoints}
|
|
handleHeight={animatedHandleHeight}
|
|
// We need undefined to enable vertical swipe gesture inside the bottom sheet like in reaction picker
|
|
contentHeight={data.snaps?.length || data.options?.length ? undefined : animatedContentHeight}
|
|
animationConfigs={ANIMATION_CONFIG}
|
|
animateOnMount={true}
|
|
backdropComponent={renderBackdrop}
|
|
handleComponent={renderHandle}
|
|
enablePanDownToClose
|
|
style={{ ...styles.container, ...bottomSheet }}
|
|
backgroundStyle={{ backgroundColor: colors.focusedBackground }}
|
|
onChange={index => index === -1 && onClose()}
|
|
// We need this to allow horizontal swipe gesture inside the bottom sheet like in reaction picker
|
|
enableContentPanningGesture={data?.enableContentPanningGesture ?? true}
|
|
{...androidTablet}
|
|
>
|
|
<BottomSheetContent
|
|
options={data?.options}
|
|
hide={hide}
|
|
children={data?.children}
|
|
hasCancel={data?.hasCancel}
|
|
onLayout={handleContentLayout}
|
|
/>
|
|
</BottomSheet>
|
|
)}
|
|
</>
|
|
);
|
|
})
|
|
);
|
|
|
|
export default ActionSheet;
|