xref: /MusicFree/src/components/panels/base/panelBase.tsx (revision 410a159129b1f6a7a1f44fde7bfad9a46f91e161)
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