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