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