|
| 1 | +import React, { |
| 2 | + type PropsWithChildren, |
| 3 | + useEffect, |
| 4 | + useMemo, |
| 5 | + useRef, |
| 6 | + useState, |
| 7 | +} from 'react'; |
| 8 | +import { |
| 9 | + Dimensions, |
| 10 | + Modal, |
| 11 | + Platform, |
| 12 | + Pressable, |
| 13 | + StyleSheet, |
| 14 | + View, |
| 15 | +} from 'react-native'; |
| 16 | +import { |
| 17 | + Gesture, |
| 18 | + GestureDetector, |
| 19 | + GestureHandlerRootView, |
| 20 | + type GestureType, |
| 21 | +} from 'react-native-gesture-handler'; |
| 22 | +import Animated, { |
| 23 | + runOnJS, |
| 24 | + useAnimatedStyle, |
| 25 | + useSharedValue, |
| 26 | + withTiming, |
| 27 | +} from 'react-native-reanimated'; |
| 28 | +import { useBottomSheetState } from './hooks/useBottomSheetState'; |
| 29 | +import { closeSheet as closeSheetInternal } from '../store/bottom-sheet-state-store'; |
| 30 | + |
| 31 | +type Props = PropsWithChildren; |
| 32 | + |
| 33 | +const { height: SCREEN_H } = Dimensions.get('window'); |
| 34 | +const SPRING = { duration: 200 }; |
| 35 | +const MAX_BOTTOM_SHEET_HEIGHT = Math.round(SCREEN_H * 0.4); |
| 36 | +const BACKDROP_OPACITY = 0.4; |
| 37 | + |
| 38 | +export function BottomSheet({ children }: Props) { |
| 39 | + const { open, height } = useBottomSheetState(); |
| 40 | + const [bottomSheetModalOpen, setBottomSheetModalOpen] = useState(open); |
| 41 | + const maxTranslateY = SCREEN_H; |
| 42 | + const translateY = useSharedValue(maxTranslateY); |
| 43 | + |
| 44 | + const bottomSheetHeight = useMemo( |
| 45 | + () => Math.min(height, MAX_BOTTOM_SHEET_HEIGHT), |
| 46 | + [height], |
| 47 | + ); |
| 48 | + const bottomSheetY = SCREEN_H - bottomSheetHeight; |
| 49 | + |
| 50 | + useEffect(() => { |
| 51 | + if (open) { |
| 52 | + setBottomSheetModalOpen(true); |
| 53 | + } else { |
| 54 | + translateY.value = withTiming(maxTranslateY, SPRING, (finished) => { |
| 55 | + if (finished) { |
| 56 | + runOnJS(setBottomSheetModalOpen)(false); |
| 57 | + } |
| 58 | + }); |
| 59 | + } |
| 60 | + }, [setBottomSheetModalOpen, maxTranslateY, open, translateY]); |
| 61 | + |
| 62 | + useEffect(() => { |
| 63 | + if (bottomSheetModalOpen && open) { |
| 64 | + translateY.value = withTiming(bottomSheetY, SPRING); |
| 65 | + } |
| 66 | + }, [open, bottomSheetModalOpen, bottomSheetY, translateY]); |
| 67 | + |
| 68 | + const dragStartY = useRef(0); |
| 69 | + const panRef = useRef<GestureType | undefined>(undefined); |
| 70 | + const scrollRef = useRef<GestureType | undefined>(undefined); |
| 71 | + |
| 72 | + const nativeScroll = useMemo(() => Gesture.Native().withRef(scrollRef), []); |
| 73 | + |
| 74 | + const pan = Gesture.Pan() |
| 75 | + .withRef(panRef) |
| 76 | + .requireExternalGestureToFail(scrollRef) |
| 77 | + .onBegin(() => { |
| 78 | + dragStartY.current = 0; |
| 79 | + }) |
| 80 | + .onUpdate((e) => { |
| 81 | + if (dragStartY.current === 0) dragStartY.current = translateY.value; |
| 82 | + const next = dragStartY.current + e.translationY; |
| 83 | + const minY = bottomSheetY; |
| 84 | + const maxY = maxTranslateY; |
| 85 | + const overTop = next < minY; |
| 86 | + const overBottom = next > maxY; |
| 87 | + translateY.value = overTop |
| 88 | + ? minY - (minY - next) * 0.2 |
| 89 | + : overBottom |
| 90 | + ? maxY + (next - maxY) * 0.2 |
| 91 | + : next; |
| 92 | + }) |
| 93 | + .onEnd((e) => { |
| 94 | + // very basic momentum bias |
| 95 | + const projected = e.translationY + e.velocityY * 0.15; |
| 96 | + const nearest = |
| 97 | + projected >= bottomSheetHeight * 0.5 ? maxTranslateY : bottomSheetY; |
| 98 | + translateY.value = withTiming(nearest, SPRING, (finished) => { |
| 99 | + if (finished && nearest === maxTranslateY) |
| 100 | + runOnJS(closeSheetInternal)(); |
| 101 | + }); |
| 102 | + }); |
| 103 | + |
| 104 | + const backdropStyle = useAnimatedStyle(() => { |
| 105 | + const minY = bottomSheetY; |
| 106 | + const t = |
| 107 | + 1 - |
| 108 | + Math.min( |
| 109 | + 1, |
| 110 | + Math.max(0, (translateY.value - minY) / (maxTranslateY - minY)), |
| 111 | + ); |
| 112 | + return { |
| 113 | + opacity: t * BACKDROP_OPACITY, |
| 114 | + pointerEvents: t > 0.02 ? 'auto' : ('none' as any), |
| 115 | + }; |
| 116 | + }); |
| 117 | + |
| 118 | + const sheetStyle = useAnimatedStyle(() => ({ |
| 119 | + transform: [{ translateY: translateY.value }], |
| 120 | + })); |
| 121 | + |
| 122 | + const sheetContentStyle = useMemo( |
| 123 | + () => ({ height: bottomSheetHeight }), |
| 124 | + [bottomSheetHeight], |
| 125 | + ); |
| 126 | + |
| 127 | + return ( |
| 128 | + <Modal visible={bottomSheetModalOpen} transparent={true}> |
| 129 | + <GestureHandlerRootView style={styles.gestureHandlerRootView}> |
| 130 | + <View style={StyleSheet.absoluteFill} pointerEvents={'box-none'}> |
| 131 | + <Animated.View |
| 132 | + style={[StyleSheet.absoluteFill, styles.backdrop, backdropStyle]} |
| 133 | + > |
| 134 | + <Pressable |
| 135 | + style={StyleSheet.absoluteFill} |
| 136 | + onPress={closeSheetInternal} |
| 137 | + /> |
| 138 | + </Animated.View> |
| 139 | + |
| 140 | + <GestureDetector gesture={pan}> |
| 141 | + <Animated.View style={[styles.sheet, sheetStyle]}> |
| 142 | + <View style={styles.handleContainer} pointerEvents={'none'}> |
| 143 | + <View style={styles.handle} /> |
| 144 | + </View> |
| 145 | + |
| 146 | + <View style={[styles.content, sheetContentStyle]}> |
| 147 | + <View style={styles.contentContainer}> |
| 148 | + {open ? ( |
| 149 | + <GestureDetector gesture={nativeScroll}> |
| 150 | + {children} |
| 151 | + </GestureDetector> |
| 152 | + ) : null} |
| 153 | + </View> |
| 154 | + </View> |
| 155 | + </Animated.View> |
| 156 | + </GestureDetector> |
| 157 | + </View> |
| 158 | + </GestureHandlerRootView> |
| 159 | + </Modal> |
| 160 | + ); |
| 161 | +} |
| 162 | + |
| 163 | +const styles = StyleSheet.create({ |
| 164 | + gestureHandlerRootView: { flex: 1 }, |
| 165 | + backdrop: { backgroundColor: '#000' }, |
| 166 | + sheet: { |
| 167 | + position: 'absolute', |
| 168 | + overflow: 'hidden', |
| 169 | + left: 0, |
| 170 | + right: 0, |
| 171 | + top: 0, |
| 172 | + height: SCREEN_H, |
| 173 | + transform: [{ translateY: SCREEN_H }], |
| 174 | + borderTopLeftRadius: 20, |
| 175 | + borderTopRightRadius: 20, |
| 176 | + backgroundColor: 'white', |
| 177 | + ...Platform.select({ |
| 178 | + ios: { |
| 179 | + shadowColor: '#000', |
| 180 | + shadowOpacity: 0.2, |
| 181 | + shadowOffset: { width: 0, height: -4 }, |
| 182 | + shadowRadius: 12, |
| 183 | + }, |
| 184 | + android: { elevation: 18 }, |
| 185 | + }), |
| 186 | + }, |
| 187 | + handleContainer: { alignItems: 'center', paddingVertical: 8 }, |
| 188 | + handle: { width: 40, height: 4, borderRadius: 2, backgroundColor: '#444' }, |
| 189 | + content: { flexGrow: 0 }, |
| 190 | + contentContainer: { flex: 1, minHeight: 0 }, |
| 191 | +}); |
0 commit comments