xref: /MusicFree/src/components/panels/base/panelBase.tsx (revision 6613e77203923e5b1742a49281bfa5de03fc1440)
1import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
2import {
3    BackHandler,
4    DeviceEventEmitter,
5    EmitterSubscription,
6    Keyboard,
7    KeyboardAvoidingView,
8    NativeEventSubscription,
9    Pressable,
10    StyleSheet,
11} from 'react-native';
12import rpx, {vh} from '@/utils/rpx';
13
14import Animated, {
15    Easing,
16    runOnJS,
17    useAnimatedReaction,
18    useAnimatedStyle,
19    useSharedValue,
20    withTiming,
21    EasingFunction,
22} from 'react-native-reanimated';
23import useColors from '@/hooks/useColors';
24import {useSafeAreaInsets} from 'react-native-safe-area-context';
25import useOrientation from '@/hooks/useOrientation';
26import {panelInfoStore} from '../usePanel';
27
28const ANIMATION_EASING: EasingFunction = Easing.out(Easing.exp);
29const ANIMATION_DURATION = 250;
30
31const timingConfig = {
32    duration: ANIMATION_DURATION,
33    easing: ANIMATION_EASING,
34};
35
36interface IPanelBaseProps {
37    keyboardAvoidBehavior?: 'height' | 'padding' | 'position' | 'none';
38    height?: number;
39    renderBody: (loading: boolean) => JSX.Element;
40    awareKeyboard?: boolean;
41}
42
43export default function (props: IPanelBaseProps) {
44    const {
45        height = vh(60),
46        renderBody,
47        keyboardAvoidBehavior,
48        awareKeyboard,
49    } = props;
50    const snapPoint = useSharedValue(0);
51
52    const colors = useColors();
53    const [loading, setLoading] = useState(true); // 是否处于弹出状态
54    const timerRef = useRef<any>();
55    const safeAreaInsets = useSafeAreaInsets();
56    const orientation = useOrientation();
57    const useAnimatedBase = useMemo(
58        () => (orientation === 'horizontal' ? rpx(750) : height),
59        [orientation],
60    );
61
62    const backHandlerRef = useRef<NativeEventSubscription>();
63
64    const hideCallbackRef = useRef<Function[]>([]);
65
66    const [keyboardHeight, setKeyboardHeight] = useState(0);
67
68    useEffect(() => {
69        snapPoint.value = withTiming(1, timingConfig);
70
71        timerRef.current = setTimeout(() => {
72            if (loading) {
73                // 兜底
74                setLoading(false);
75            }
76        }, 400);
77        if (backHandlerRef.current) {
78            backHandlerRef.current.remove();
79            backHandlerRef.current = undefined;
80        }
81        backHandlerRef.current = BackHandler.addEventListener(
82            'hardwareBackPress',
83            () => {
84                snapPoint.value = withTiming(0, timingConfig);
85                return true;
86            },
87        );
88
89        const listenerSubscription = DeviceEventEmitter.addListener(
90            'hidePanel',
91            (callback?: () => void) => {
92                if (callback) {
93                    hideCallbackRef.current.push(callback);
94                }
95                snapPoint.value = withTiming(0, timingConfig);
96            },
97        );
98
99        let keyboardDidShowListener: EmitterSubscription;
100        let keyboardDidHideListener: EmitterSubscription;
101        if (awareKeyboard) {
102            keyboardDidShowListener = Keyboard.addListener(
103                'keyboardDidShow',
104                event => {
105                    setKeyboardHeight(event.endCoordinates.height);
106                },
107            );
108
109            keyboardDidHideListener = Keyboard.addListener(
110                'keyboardDidHide',
111                () => {
112                    setKeyboardHeight(0);
113                },
114            );
115        }
116
117        return () => {
118            if (timerRef.current) {
119                clearTimeout(timerRef.current);
120                timerRef.current = null;
121            }
122            if (backHandlerRef.current) {
123                backHandlerRef.current?.remove();
124                backHandlerRef.current = undefined;
125            }
126            listenerSubscription.remove();
127            keyboardDidShowListener?.remove();
128            keyboardDidHideListener?.remove();
129        };
130    }, []);
131
132    const maskAnimated = useAnimatedStyle(() => {
133        return {
134            opacity: snapPoint.value * 0.5,
135        };
136    });
137
138    const panelAnimated = useAnimatedStyle(() => {
139        return {
140            transform: [
141                orientation === 'vertical'
142                    ? {
143                          translateY: (1 - snapPoint.value) * useAnimatedBase,
144                      }
145                    : {
146                          translateX: (1 - snapPoint.value) * useAnimatedBase,
147                      },
148            ],
149        };
150    }, [orientation]);
151
152    const mountPanel = useCallback(() => {
153        setLoading(false);
154    }, []);
155
156    const unmountPanel = useCallback(() => {
157        panelInfoStore.setValue({
158            name: null,
159            payload: null,
160        });
161        hideCallbackRef.current.forEach(cb => cb?.());
162    }, []);
163
164    useAnimatedReaction(
165        () => snapPoint.value,
166        (result, prevResult) => {
167            if (
168                ((prevResult !== null && result > prevResult) ||
169                    prevResult === null) &&
170                result > 0.8
171            ) {
172                runOnJS(mountPanel)();
173            }
174
175            if (prevResult && result < prevResult && result === 0) {
176                runOnJS(unmountPanel)();
177            }
178        },
179        [],
180    );
181
182    const panelBody = (
183        <Animated.View
184            style={[
185                style.wrapper,
186                {
187                    backgroundColor: colors.backdrop,
188                    height:
189                        orientation === 'horizontal'
190                            ? vh(100) - safeAreaInsets.top
191                            : height -
192                              (isFinite(keyboardHeight) ? keyboardHeight : 0),
193                },
194                panelAnimated,
195            ]}>
196            {renderBody(loading)}
197        </Animated.View>
198    );
199
200    return (
201        <>
202            <Pressable
203                style={style.maskWrapper}
204                onPress={() => {
205                    snapPoint.value = withTiming(0, timingConfig);
206                }}>
207                <Animated.View
208                    style={[style.maskWrapper, style.mask, maskAnimated]}
209                />
210            </Pressable>
211            {keyboardAvoidBehavior === 'none' ? (
212                panelBody
213            ) : (
214                <KeyboardAvoidingView
215                    style={style.kbContainer}
216                    behavior={keyboardAvoidBehavior || 'position'}>
217                    {panelBody}
218                </KeyboardAvoidingView>
219            )}
220        </>
221    );
222}
223
224const style = StyleSheet.create({
225    maskWrapper: {
226        position: 'absolute',
227        width: '100%',
228        height: '100%',
229        top: 0,
230        left: 0,
231        right: 0,
232        bottom: 0,
233        zIndex: 15000,
234    },
235    mask: {
236        backgroundColor: '#000',
237        opacity: 0.5,
238    },
239    wrapper: {
240        position: 'absolute',
241        width: rpx(750),
242        bottom: 0,
243        right: 0,
244        borderTopLeftRadius: rpx(28),
245        borderTopRightRadius: rpx(28),
246        zIndex: 15010,
247    },
248    kbContainer: {
249        zIndex: 15010,
250    },
251});
252