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 - keyboardHeight, 183 }, 184 panelAnimated, 185 ]}> 186 {renderBody(loading)} 187 </Animated.View> 188 ); 189 190 return ( 191 <> 192 <Pressable 193 style={style.maskWrapper} 194 onPress={() => { 195 snapPoint.value = withTiming(0, timingConfig); 196 }}> 197 <Animated.View 198 style={[style.maskWrapper, style.mask, maskAnimated]} 199 /> 200 </Pressable> 201 {keyboardAvoidBehavior === 'none' ? ( 202 panelBody 203 ) : ( 204 <KeyboardAvoidingView 205 style={style.kbContainer} 206 behavior={keyboardAvoidBehavior || 'position'}> 207 {panelBody} 208 </KeyboardAvoidingView> 209 )} 210 </> 211 ); 212} 213 214const style = StyleSheet.create({ 215 maskWrapper: { 216 position: 'absolute', 217 width: '100%', 218 height: '100%', 219 top: 0, 220 left: 0, 221 right: 0, 222 bottom: 0, 223 zIndex: 15000, 224 }, 225 mask: { 226 backgroundColor: '#000', 227 opacity: 0.5, 228 }, 229 wrapper: { 230 position: 'absolute', 231 width: rpx(750), 232 bottom: 0, 233 right: 0, 234 borderTopLeftRadius: rpx(28), 235 borderTopRightRadius: rpx(28), 236 zIndex: 15010, 237 }, 238 kbContainer: { 239 zIndex: 15010, 240 }, 241}); 242