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 height?: number; 35 renderBody: (loading: boolean) => JSX.Element; 36} 37 38export default function (props: IPanelBaseProps) { 39 const {height = vh(60), renderBody} = props; 40 const snapPoint = useSharedValue(0); 41 42 const colors = useColors(); 43 const [loading, setLoading] = useState(true); // 是否处于弹出状态 44 const timerRef = useRef<any>(); 45 const safeAreaInsets = useSafeAreaInsets(); 46 const orientation = useOrientation(); 47 const useAnimatedBase = useMemo( 48 () => (orientation === 'horizonal' ? rpx(750) : height), 49 [orientation], 50 ); 51 const backHandlerRef = useRef<NativeEventSubscription>(); 52 53 const hideCallbackRef = useRef<Function[]>([]); 54 55 useEffect(() => { 56 snapPoint.value = withTiming(1, timingConfig); 57 timerRef.current = setTimeout(() => { 58 if (loading) { 59 // 兜底 60 setLoading(false); 61 } 62 }, 400); 63 if (backHandlerRef.current) { 64 backHandlerRef.current?.remove(); 65 backHandlerRef.current = undefined; 66 } 67 backHandlerRef.current = BackHandler.addEventListener( 68 'hardwareBackPress', 69 () => { 70 snapPoint.value = withTiming(0, timingConfig); 71 return true; 72 }, 73 ); 74 75 const listenerSubscription = DeviceEventEmitter.addListener( 76 'hidePanel', 77 (callback?: () => void) => { 78 if (callback) { 79 hideCallbackRef.current.push(callback); 80 } 81 snapPoint.value = withTiming(0, timingConfig); 82 }, 83 ); 84 85 return () => { 86 if (timerRef.current) { 87 clearTimeout(timerRef.current); 88 timerRef.current = null; 89 } 90 if (backHandlerRef.current) { 91 backHandlerRef.current?.remove(); 92 backHandlerRef.current = undefined; 93 } 94 listenerSubscription.remove(); 95 }; 96 }, []); 97 98 const maskAnimated = useAnimatedStyle(() => { 99 return { 100 opacity: withTiming(snapPoint.value * 0.5, timingConfig), 101 }; 102 }); 103 104 const panelAnimated = useAnimatedStyle(() => { 105 return { 106 transform: [ 107 orientation === 'vertical' 108 ? { 109 translateY: withTiming( 110 (1 - snapPoint.value) * useAnimatedBase, 111 timingConfig, 112 ), 113 } 114 : { 115 translateX: withTiming( 116 (1 - snapPoint.value) * useAnimatedBase, 117 timingConfig, 118 ), 119 }, 120 ], 121 }; 122 }, [orientation]); 123 124 const mountPanel = useCallback(() => { 125 setLoading(false); 126 }, []); 127 128 const unmountPanel = useCallback(() => { 129 panelInfoStore.setValue({ 130 name: null, 131 payload: null, 132 }); 133 hideCallbackRef.current.forEach(cb => cb?.()); 134 }, []); 135 136 useAnimatedReaction( 137 () => snapPoint.value, 138 (result, prevResult) => { 139 if (prevResult && result > prevResult && result > 0.8) { 140 runOnJS(mountPanel)(); 141 } 142 if (prevResult && result < prevResult && result === 0) { 143 runOnJS(unmountPanel)(); 144 } 145 }, 146 [], 147 ); 148 149 return ( 150 <> 151 <Pressable 152 style={style.maskWrapper} 153 onPress={() => { 154 snapPoint.value = withTiming(0, timingConfig); 155 }}> 156 <Animated.View 157 style={[style.maskWrapper, style.mask, maskAnimated]} 158 /> 159 </Pressable> 160 <KeyboardAvoidingView behavior="position"> 161 <Animated.View 162 style={[ 163 style.wrapper, 164 { 165 backgroundColor: colors.primary, 166 height: 167 orientation === 'horizonal' 168 ? vh(100) - safeAreaInsets.top 169 : height, 170 }, 171 panelAnimated, 172 ]}> 173 {renderBody(loading)} 174 </Animated.View> 175 </KeyboardAvoidingView> 176 </> 177 ); 178} 179 180const style = StyleSheet.create({ 181 maskWrapper: { 182 position: 'absolute', 183 width: '100%', 184 height: '100%', 185 top: 0, 186 left: 0, 187 right: 0, 188 bottom: 0, 189 }, 190 mask: { 191 backgroundColor: '#333333', 192 opacity: 0.5, 193 }, 194 wrapper: { 195 position: 'absolute', 196 width: rpx(750), 197 bottom: 0, 198 right: 0, 199 borderTopLeftRadius: rpx(28), 200 borderTopRightRadius: rpx(28), 201 }, 202}); 203