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