xref: /MusicFree/src/components/panels/base/panelBase.tsx (revision adf41771e5c3ca7c27879b461cece7e444d1dc58)
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: snapPoint.value * 0.5,
102        };
103    });
104
105    const panelAnimated = useAnimatedStyle(() => {
106        return {
107            transform: [
108                orientation === 'vertical'
109                    ? {
110                          translateY: (1 - snapPoint.value) * useAnimatedBase,
111                      }
112                    : {
113                          translateX: (1 - snapPoint.value) * useAnimatedBase,
114                      },
115            ],
116        };
117    }, [orientation]);
118
119    const mountPanel = useCallback(() => {
120        setLoading(false);
121    }, []);
122
123    const unmountPanel = useCallback(() => {
124        panelInfoStore.setValue({
125            name: null,
126            payload: null,
127        });
128        hideCallbackRef.current.forEach(cb => cb?.());
129    }, []);
130
131    useAnimatedReaction(
132        () => snapPoint.value,
133        (result, prevResult) => {
134            if (prevResult && result > prevResult && result > 0.8) {
135                runOnJS(mountPanel)();
136            }
137            if (prevResult && result < prevResult && result === 0) {
138                runOnJS(unmountPanel)();
139            }
140        },
141        [],
142    );
143
144    const panelBody = (
145        <Animated.View
146            style={[
147                style.wrapper,
148                {
149                    backgroundColor: colors.backdrop,
150                    height:
151                        orientation === 'horizonal'
152                            ? vh(100) - safeAreaInsets.top
153                            : height,
154                },
155                panelAnimated,
156            ]}>
157            {renderBody(loading)}
158        </Animated.View>
159    );
160
161    return (
162        <>
163            <Pressable
164                style={style.maskWrapper}
165                onPress={() => {
166                    snapPoint.value = withTiming(0, timingConfig);
167                }}>
168                <Animated.View
169                    style={[style.maskWrapper, style.mask, maskAnimated]}
170                />
171            </Pressable>
172            {keyboardAvoidBehavior === 'none' ? (
173                panelBody
174            ) : (
175                <KeyboardAvoidingView
176                    style={style.kbContainer}
177                    behavior={keyboardAvoidBehavior || 'position'}>
178                    {panelBody}
179                </KeyboardAvoidingView>
180            )}
181        </>
182    );
183}
184
185const style = StyleSheet.create({
186    maskWrapper: {
187        position: 'absolute',
188        width: '100%',
189        height: '100%',
190        top: 0,
191        left: 0,
192        right: 0,
193        bottom: 0,
194        zIndex: 15000,
195    },
196    mask: {
197        backgroundColor: '#000',
198        opacity: 0.5,
199    },
200    wrapper: {
201        position: 'absolute',
202        width: rpx(750),
203        bottom: 0,
204        right: 0,
205        borderTopLeftRadius: rpx(28),
206        borderTopRightRadius: rpx(28),
207        zIndex: 15010,
208    },
209    kbContainer: {
210        zIndex: 15010,
211    },
212});
213