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: withTiming(snapPoint.value * 0.5, timingConfig), 102 }; 103 }); 104 105 const panelAnimated = useAnimatedStyle(() => { 106 return { 107 transform: [ 108 orientation === 'vertical' 109 ? { 110 translateY: withTiming( 111 (1 - snapPoint.value) * useAnimatedBase, 112 timingConfig, 113 ), 114 } 115 : { 116 translateX: withTiming( 117 (1 - snapPoint.value) * useAnimatedBase, 118 timingConfig, 119 ), 120 }, 121 ], 122 }; 123 }, [orientation]); 124 125 const mountPanel = useCallback(() => { 126 setLoading(false); 127 }, []); 128 129 const unmountPanel = useCallback(() => { 130 panelInfoStore.setValue({ 131 name: null, 132 payload: null, 133 }); 134 hideCallbackRef.current.forEach(cb => cb?.()); 135 }, []); 136 137 useAnimatedReaction( 138 () => snapPoint.value, 139 (result, prevResult) => { 140 if (prevResult && result > prevResult && result > 0.8) { 141 runOnJS(mountPanel)(); 142 } 143 if (prevResult && result < prevResult && result === 0) { 144 runOnJS(unmountPanel)(); 145 } 146 }, 147 [], 148 ); 149 150 const panelBody = ( 151 <Animated.View 152 style={[ 153 style.wrapper, 154 { 155 backgroundColor: colors.backdrop, 156 height: 157 orientation === 'horizonal' 158 ? vh(100) - safeAreaInsets.top 159 : height, 160 }, 161 panelAnimated, 162 ]}> 163 {renderBody(loading)} 164 </Animated.View> 165 ); 166 167 return ( 168 <> 169 <Pressable 170 style={style.maskWrapper} 171 onPress={() => { 172 snapPoint.value = withTiming(0, timingConfig); 173 }}> 174 <Animated.View 175 style={[style.maskWrapper, style.mask, maskAnimated]} 176 /> 177 </Pressable> 178 {keyboardAvoidBehavior === 'none' ? ( 179 panelBody 180 ) : ( 181 <KeyboardAvoidingView 182 style={style.kbContainer} 183 behavior={keyboardAvoidBehavior || 'position'}> 184 {panelBody} 185 </KeyboardAvoidingView> 186 )} 187 </> 188 ); 189} 190 191const style = StyleSheet.create({ 192 maskWrapper: { 193 position: 'absolute', 194 width: '100%', 195 height: '100%', 196 top: 0, 197 left: 0, 198 right: 0, 199 bottom: 0, 200 zIndex: 15000, 201 }, 202 mask: { 203 backgroundColor: '#000', 204 opacity: 0.5, 205 }, 206 wrapper: { 207 position: 'absolute', 208 width: rpx(750), 209 bottom: 0, 210 right: 0, 211 borderTopLeftRadius: rpx(28), 212 borderTopRightRadius: rpx(28), 213 zIndex: 15010, 214 }, 215 kbContainer: { 216 zIndex: 15010, 217 }, 218}); 219