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 === 'horizonal' ? 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 useEffect(() => { 68 snapPoint.value = withTiming(1, timingConfig); 69 70 timerRef.current = setTimeout(() => { 71 if (loading) { 72 // 兜底 73 setLoading(false); 74 } 75 }, 400); 76 if (backHandlerRef.current) { 77 backHandlerRef.current.remove(); 78 backHandlerRef.current = undefined; 79 } 80 backHandlerRef.current = BackHandler.addEventListener( 81 'hardwareBackPress', 82 () => { 83 snapPoint.value = withTiming(0, timingConfig); 84 return true; 85 }, 86 ); 87 88 const listenerSubscription = DeviceEventEmitter.addListener( 89 'hidePanel', 90 (callback?: () => void) => { 91 if (callback) { 92 hideCallbackRef.current.push(callback); 93 } 94 snapPoint.value = withTiming(0, timingConfig); 95 }, 96 ); 97 98 let keyboardDidShowListener: EmitterSubscription; 99 let keyboardDidHideListener: EmitterSubscription; 100 if (awareKeyboard) { 101 keyboardDidShowListener = Keyboard.addListener( 102 'keyboardDidShow', 103 event => { 104 setKeyboardHeight(event.endCoordinates.height); 105 }, 106 ); 107 108 keyboardDidHideListener = Keyboard.addListener( 109 'keyboardDidHide', 110 () => { 111 setKeyboardHeight(0); 112 }, 113 ); 114 } 115 116 return () => { 117 if (timerRef.current) { 118 clearTimeout(timerRef.current); 119 timerRef.current = null; 120 } 121 if (backHandlerRef.current) { 122 backHandlerRef.current?.remove(); 123 backHandlerRef.current = undefined; 124 } 125 listenerSubscription.remove(); 126 keyboardDidShowListener?.remove(); 127 keyboardDidHideListener?.remove(); 128 }; 129 }, []); 130 131 const maskAnimated = useAnimatedStyle(() => { 132 return { 133 opacity: snapPoint.value * 0.5, 134 }; 135 }); 136 137 const panelAnimated = useAnimatedStyle(() => { 138 return { 139 transform: [ 140 orientation === 'vertical' 141 ? { 142 translateY: (1 - snapPoint.value) * useAnimatedBase, 143 } 144 : { 145 translateX: (1 - snapPoint.value) * useAnimatedBase, 146 }, 147 ], 148 }; 149 }, [orientation]); 150 151 const mountPanel = useCallback(() => { 152 setLoading(false); 153 }, []); 154 155 const unmountPanel = useCallback(() => { 156 panelInfoStore.setValue({ 157 name: null, 158 payload: null, 159 }); 160 hideCallbackRef.current.forEach(cb => cb?.()); 161 }, []); 162 163 useAnimatedReaction( 164 () => snapPoint.value, 165 (result, prevResult) => { 166 if ( 167 ((prevResult !== null && result > prevResult) || 168 prevResult === null) && 169 result > 0.8 170 ) { 171 runOnJS(mountPanel)(); 172 } 173 174 if (prevResult && result < prevResult && result === 0) { 175 runOnJS(unmountPanel)(); 176 } 177 }, 178 [], 179 ); 180 181 const panelBody = ( 182 <Animated.View 183 style={[ 184 style.wrapper, 185 { 186 backgroundColor: colors.backdrop, 187 height: 188 orientation === 'horizonal' 189 ? vh(100) - safeAreaInsets.top 190 : height - 191 (isFinite(keyboardHeight) ? keyboardHeight : 0), 192 }, 193 panelAnimated, 194 ]}> 195 {renderBody(loading)} 196 </Animated.View> 197 ); 198 199 return ( 200 <> 201 <Pressable 202 style={style.maskWrapper} 203 onPress={() => { 204 snapPoint.value = withTiming(0, timingConfig); 205 }}> 206 <Animated.View 207 style={[style.maskWrapper, style.mask, maskAnimated]} 208 /> 209 </Pressable> 210 {keyboardAvoidBehavior === 'none' ? ( 211 panelBody 212 ) : ( 213 <KeyboardAvoidingView 214 style={style.kbContainer} 215 behavior={keyboardAvoidBehavior || 'position'}> 216 {panelBody} 217 </KeyboardAvoidingView> 218 )} 219 </> 220 ); 221} 222 223const style = StyleSheet.create({ 224 maskWrapper: { 225 position: 'absolute', 226 width: '100%', 227 height: '100%', 228 top: 0, 229 left: 0, 230 right: 0, 231 bottom: 0, 232 zIndex: 15000, 233 }, 234 mask: { 235 backgroundColor: '#000', 236 opacity: 0.5, 237 }, 238 wrapper: { 239 position: 'absolute', 240 width: rpx(750), 241 bottom: 0, 242 right: 0, 243 borderTopLeftRadius: rpx(28), 244 borderTopRightRadius: rpx(28), 245 zIndex: 15010, 246 }, 247 kbContainer: { 248 zIndex: 15010, 249 }, 250}); 251