1import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; 2import { 3 BackHandler, 4 DeviceEventEmitter, 5 KeyboardAvoidingView, 6 NativeEventSubscription, 7 Pressable, 8 StyleSheet, 9} from 'react-native'; 10import rpx, {vh} from '@/utils/rpx'; 11 12import Animated, { 13 Easing, 14 runOnJS, 15 useAnimatedReaction, 16 useAnimatedStyle, 17 useSharedValue, 18 withTiming, 19} from 'react-native-reanimated'; 20import useColors from '@/hooks/useColors'; 21import {useSafeAreaInsets} from 'react-native-safe-area-context'; 22import useOrientation from '@/hooks/useOrientation'; 23import {panelInfoStore} from '../usePanel'; 24 25const ANIMATION_EASING: Animated.EasingFunction = Easing.out(Easing.exp); 26const ANIMATION_DURATION = 250; 27 28const timingConfig = { 29 duration: ANIMATION_DURATION, 30 easing: ANIMATION_EASING, 31}; 32 33interface IPanelBaseProps { 34 keyboardAvoidBehavior?: 'height' | 'padding' | 'position' | 'none'; 35 height?: number; 36 renderBody: (loading: boolean) => JSX.Element; 37} 38 39export default function (props: IPanelBaseProps) { 40 const {height = vh(60), renderBody, keyboardAvoidBehavior} = props; 41 const snapPoint = useSharedValue(0); 42 43 const colors = useColors(); 44 const [loading, setLoading] = useState(true); // 是否处于弹出状态 45 const timerRef = useRef<any>(); 46 const safeAreaInsets = useSafeAreaInsets(); 47 const orientation = useOrientation(); 48 const useAnimatedBase = useMemo( 49 () => (orientation === 'horizonal' ? rpx(750) : height), 50 [orientation], 51 ); 52 const backHandlerRef = useRef<NativeEventSubscription>(); 53 54 const hideCallbackRef = useRef<Function[]>([]); 55 56 useEffect(() => { 57 snapPoint.value = withTiming(1, timingConfig); 58 timerRef.current = setTimeout(() => { 59 if (loading) { 60 // 兜底 61 setLoading(false); 62 } 63 }, 400); 64 if (backHandlerRef.current) { 65 backHandlerRef.current?.remove(); 66 backHandlerRef.current = undefined; 67 } 68 backHandlerRef.current = BackHandler.addEventListener( 69 'hardwareBackPress', 70 () => { 71 snapPoint.value = withTiming(0, timingConfig); 72 return true; 73 }, 74 ); 75 76 const listenerSubscription = DeviceEventEmitter.addListener( 77 'hidePanel', 78 (callback?: () => void) => { 79 if (callback) { 80 hideCallbackRef.current.push(callback); 81 } 82 snapPoint.value = withTiming(0, timingConfig); 83 }, 84 ); 85 86 return () => { 87 if (timerRef.current) { 88 clearTimeout(timerRef.current); 89 timerRef.current = null; 90 } 91 if (backHandlerRef.current) { 92 backHandlerRef.current?.remove(); 93 backHandlerRef.current = undefined; 94 } 95 listenerSubscription.remove(); 96 }; 97 }, []); 98 99 const maskAnimated = useAnimatedStyle(() => { 100 return { 101 opacity: snapPoint.value * 0.5, 102 }; 103 }); 104 105 const panelAnimated = useAnimatedStyle(() => { 106 return { 107 transform: [ 108 orientation === 'vertical' 109 ? { 110 translateY: (1 - snapPoint.value) * useAnimatedBase, 111 } 112 : { 113 translateX: (1 - snapPoint.value) * useAnimatedBase, 114 }, 115 ], 116 }; 117 }, [orientation]); 118 119 const mountPanel = useCallback(() => { 120 setLoading(false); 121 }, []); 122 123 const unmountPanel = useCallback(() => { 124 panelInfoStore.setValue({ 125 name: null, 126 payload: null, 127 }); 128 hideCallbackRef.current.forEach(cb => cb?.()); 129 }, []); 130 131 useAnimatedReaction( 132 () => snapPoint.value, 133 (result, prevResult) => { 134 if (prevResult && result > prevResult && result > 0.8) { 135 runOnJS(mountPanel)(); 136 } 137 if (prevResult && result < prevResult && result === 0) { 138 runOnJS(unmountPanel)(); 139 } 140 }, 141 [], 142 ); 143 144 const panelBody = ( 145 <Animated.View 146 style={[ 147 style.wrapper, 148 { 149 backgroundColor: colors.backdrop, 150 height: 151 orientation === 'horizonal' 152 ? vh(100) - safeAreaInsets.top 153 : height, 154 }, 155 panelAnimated, 156 ]}> 157 {renderBody(loading)} 158 </Animated.View> 159 ); 160 161 return ( 162 <> 163 <Pressable 164 style={style.maskWrapper} 165 onPress={() => { 166 snapPoint.value = withTiming(0, timingConfig); 167 }}> 168 <Animated.View 169 style={[style.maskWrapper, style.mask, maskAnimated]} 170 /> 171 </Pressable> 172 {keyboardAvoidBehavior === 'none' ? ( 173 panelBody 174 ) : ( 175 <KeyboardAvoidingView 176 style={style.kbContainer} 177 behavior={keyboardAvoidBehavior || 'position'}> 178 {panelBody} 179 </KeyboardAvoidingView> 180 )} 181 </> 182 ); 183} 184 185const style = StyleSheet.create({ 186 maskWrapper: { 187 position: 'absolute', 188 width: '100%', 189 height: '100%', 190 top: 0, 191 left: 0, 192 right: 0, 193 bottom: 0, 194 zIndex: 15000, 195 }, 196 mask: { 197 backgroundColor: '#000', 198 opacity: 0.5, 199 }, 200 wrapper: { 201 position: 'absolute', 202 width: rpx(750), 203 bottom: 0, 204 right: 0, 205 borderTopLeftRadius: rpx(28), 206 borderTopRightRadius: rpx(28), 207 zIndex: 15010, 208 }, 209 kbContainer: { 210 zIndex: 15010, 211 }, 212}); 213