xref: /MusicFree/src/components/panels/base/panelBase.tsx (revision 4da0658b1bf77de935d6a60ad1a26e4b92d13606)
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 === 'horizonal' ? 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    useEffect(() => {
68        snapPoint.value = withTiming(1, timingConfig);
69
70        timerRef.current = setTimeout(() => {
71            if (loading) {
72                // 兜底
73                setLoading(false);
74            }
75        }, 400);
76        if (backHandlerRef.current) {
77            backHandlerRef.current.remove();
78            backHandlerRef.current = undefined;
79        }
80        backHandlerRef.current = BackHandler.addEventListener(
81            'hardwareBackPress',
82            () => {
83                snapPoint.value = withTiming(0, timingConfig);
84                return true;
85            },
86        );
87
88        const listenerSubscription = DeviceEventEmitter.addListener(
89            'hidePanel',
90            (callback?: () => void) => {
91                if (callback) {
92                    hideCallbackRef.current.push(callback);
93                }
94                snapPoint.value = withTiming(0, timingConfig);
95            },
96        );
97
98        let keyboardDidShowListener: EmitterSubscription;
99        let keyboardDidHideListener: EmitterSubscription;
100        if (awareKeyboard) {
101            keyboardDidShowListener = Keyboard.addListener(
102                'keyboardDidShow',
103                event => {
104                    setKeyboardHeight(event.endCoordinates.height);
105                },
106            );
107
108            keyboardDidHideListener = Keyboard.addListener(
109                'keyboardDidHide',
110                () => {
111                    setKeyboardHeight(0);
112                },
113            );
114        }
115
116        return () => {
117            if (timerRef.current) {
118                clearTimeout(timerRef.current);
119                timerRef.current = null;
120            }
121            if (backHandlerRef.current) {
122                backHandlerRef.current?.remove();
123                backHandlerRef.current = undefined;
124            }
125            listenerSubscription.remove();
126            keyboardDidShowListener?.remove();
127            keyboardDidHideListener?.remove();
128        };
129    }, []);
130
131    const maskAnimated = useAnimatedStyle(() => {
132        return {
133            opacity: snapPoint.value * 0.5,
134        };
135    });
136
137    const panelAnimated = useAnimatedStyle(() => {
138        return {
139            transform: [
140                orientation === 'vertical'
141                    ? {
142                          translateY: (1 - snapPoint.value) * useAnimatedBase,
143                      }
144                    : {
145                          translateX: (1 - snapPoint.value) * useAnimatedBase,
146                      },
147            ],
148        };
149    }, [orientation]);
150
151    const mountPanel = useCallback(() => {
152        setLoading(false);
153    }, []);
154
155    const unmountPanel = useCallback(() => {
156        panelInfoStore.setValue({
157            name: null,
158            payload: null,
159        });
160        hideCallbackRef.current.forEach(cb => cb?.());
161    }, []);
162
163    useAnimatedReaction(
164        () => snapPoint.value,
165        (result, prevResult) => {
166            if (
167                ((prevResult !== null && result > prevResult) ||
168                    prevResult === null) &&
169                result > 0.8
170            ) {
171                runOnJS(mountPanel)();
172            }
173
174            if (prevResult && result < prevResult && result === 0) {
175                runOnJS(unmountPanel)();
176            }
177        },
178        [],
179    );
180
181    const panelBody = (
182        <Animated.View
183            style={[
184                style.wrapper,
185                {
186                    backgroundColor: colors.backdrop,
187                    height:
188                        orientation === 'horizonal'
189                            ? vh(100) - safeAreaInsets.top
190                            : height -
191                              (isFinite(keyboardHeight) ? keyboardHeight : 0),
192                },
193                panelAnimated,
194            ]}>
195            {renderBody(loading)}
196        </Animated.View>
197    );
198
199    return (
200        <>
201            <Pressable
202                style={style.maskWrapper}
203                onPress={() => {
204                    snapPoint.value = withTiming(0, timingConfig);
205                }}>
206                <Animated.View
207                    style={[style.maskWrapper, style.mask, maskAnimated]}
208                />
209            </Pressable>
210            {keyboardAvoidBehavior === 'none' ? (
211                panelBody
212            ) : (
213                <KeyboardAvoidingView
214                    style={style.kbContainer}
215                    behavior={keyboardAvoidBehavior || 'position'}>
216                    {panelBody}
217                </KeyboardAvoidingView>
218            )}
219        </>
220    );
221}
222
223const style = StyleSheet.create({
224    maskWrapper: {
225        position: 'absolute',
226        width: '100%',
227        height: '100%',
228        top: 0,
229        left: 0,
230        right: 0,
231        bottom: 0,
232        zIndex: 15000,
233    },
234    mask: {
235        backgroundColor: '#000',
236        opacity: 0.5,
237    },
238    wrapper: {
239        position: 'absolute',
240        width: rpx(750),
241        bottom: 0,
242        right: 0,
243        borderTopLeftRadius: rpx(28),
244        borderTopRightRadius: rpx(28),
245        zIndex: 15010,
246    },
247    kbContainer: {
248        zIndex: 15010,
249    },
250});
251