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