xref: /MusicFree/src/components/base/appBar.tsx (revision da2a2959bbf8b96423ead2452a5f60867d05a8b3)
1import React, {ReactNode, useEffect, useState} from 'react';
2import {
3    LayoutRectangle,
4    StatusBar as OriginalStatusBar,
5    StyleProp,
6    StyleSheet,
7    TouchableWithoutFeedback,
8    View,
9    ViewStyle,
10} from 'react-native';
11import rpx from '@/utils/rpx';
12import useColors from '@/hooks/useColors';
13import StatusBar from './statusBar';
14import color from 'color';
15import IconButton from './iconButton';
16import globalStyle from '@/constants/globalStyle';
17import ThemeText from './themeText';
18import {useNavigation} from '@react-navigation/native';
19import Animated, {
20    Easing,
21    useAnimatedStyle,
22    useSharedValue,
23    withTiming,
24} from 'react-native-reanimated';
25import Portal from './portal';
26import ListItem from './listItem';
27import {IIconName} from '@/components/base/icon.tsx';
28
29interface IAppBarProps {
30    titleTextOpacity?: number;
31    withStatusBar?: boolean;
32    color?: string;
33    actions?: Array<{
34        icon: IIconName;
35        onPress?: () => void;
36    }>;
37    menu?: Array<{
38        icon: IIconName;
39        title: string;
40        show?: boolean;
41        onPress?: () => void;
42    }>;
43    menuWithStatusBar?: boolean;
44    children?: string | ReactNode;
45    containerStyle?: StyleProp<ViewStyle>;
46    contentStyle?: StyleProp<ViewStyle>;
47    actionComponent?: ReactNode;
48    onBackPress?: () => void;
49}
50
51const ANIMATION_EASING: Animated.EasingFunction = Easing.out(Easing.exp);
52const ANIMATION_DURATION = 500;
53
54const timingConfig = {
55    duration: ANIMATION_DURATION,
56    easing: ANIMATION_EASING,
57};
58
59export default function AppBar(props: IAppBarProps) {
60    const {
61        titleTextOpacity = 1,
62        withStatusBar,
63        color: _color,
64        actions = [],
65        menu = [],
66        menuWithStatusBar = true,
67        containerStyle,
68        contentStyle,
69        children,
70        actionComponent,
71        onBackPress,
72    } = props;
73
74    const colors = useColors();
75    const navigation = useNavigation();
76
77    const bgColor = color(colors.appBar ?? colors.primary).toString();
78    const contentColor = _color ?? colors.appBarText;
79
80    const [showMenu, setShowMenu] = useState(false);
81    const [menuIconLayout, setMenuIconLayout] =
82        useState<LayoutRectangle | null>(null);
83    const scaleRate = useSharedValue(0);
84
85    useEffect(() => {
86        if (showMenu) {
87            scaleRate.value = withTiming(1, timingConfig);
88        } else {
89            scaleRate.value = withTiming(0, timingConfig);
90        }
91    }, [showMenu]);
92
93    const transformStyle = useAnimatedStyle(() => {
94        return {
95            opacity: scaleRate.value,
96        };
97    });
98
99    return (
100        <>
101            {withStatusBar ? <StatusBar backgroundColor={bgColor} /> : null}
102            <View
103                style={[
104                    styles.container,
105                    containerStyle,
106                    {backgroundColor: bgColor},
107                ]}>
108                <IconButton
109                    name="arrow-left"
110                    sizeType="normal"
111                    color={contentColor}
112                    style={globalStyle.notShrink}
113                    onPress={
114                        onBackPress ||
115                        (() => {
116                            navigation.goBack();
117                        })
118                    }
119                />
120                <View style={[globalStyle.grow, styles.content, contentStyle]}>
121                    {typeof children === 'string' ? (
122                        <ThemeText
123                            fontSize="title"
124                            fontWeight="bold"
125                            numberOfLines={1}
126                            color={
127                                titleTextOpacity !== 1
128                                    ? color(contentColor)
129                                          .alpha(titleTextOpacity)
130                                          .toString()
131                                    : contentColor
132                            }>
133                            {children}
134                        </ThemeText>
135                    ) : (
136                        children
137                    )}
138                </View>
139                {actions.map((action, index) => (
140                    <IconButton
141                        key={index}
142                        name={action.icon}
143                        sizeType="normal"
144                        color={contentColor}
145                        style={[globalStyle.notShrink, styles.rightButton]}
146                        onPress={action.onPress}
147                    />
148                ))}
149                {actionComponent ?? null}
150                {menu?.length ? (
151                    <IconButton
152                        name="ellipsis-vertical"
153                        sizeType="normal"
154                        onLayout={evt => {
155                            setMenuIconLayout(evt.nativeEvent.layout);
156                        }}
157                        color={contentColor}
158                        style={[globalStyle.notShrink, styles.rightButton]}
159                        onPress={() => {
160                            setShowMenu(true);
161                        }}
162                    />
163                ) : null}
164            </View>
165            <Portal>
166                {showMenu ? (
167                    <TouchableWithoutFeedback
168                        onPress={() => {
169                            setShowMenu(false);
170                        }}>
171                        <View style={styles.blocker} />
172                    </TouchableWithoutFeedback>
173                ) : null}
174                <>
175                    <Animated.View
176                        pointerEvents={showMenu ? 'auto' : 'none'}
177                        style={[
178                            {
179                                borderBottomColor: colors.background,
180                                left:
181                                    (menuIconLayout?.x ?? 0) +
182                                    (menuIconLayout?.width ?? 0) / 2 -
183                                    rpx(10),
184                                top:
185                                    (menuIconLayout?.y ?? 0) +
186                                    (menuIconLayout?.height ?? 0) +
187                                    (menuWithStatusBar
188                                        ? OriginalStatusBar.currentHeight ?? 0
189                                        : 0),
190                            },
191                            transformStyle,
192                            styles.bubbleCorner,
193                        ]}
194                    />
195                    <Animated.View
196                        pointerEvents={showMenu ? 'auto' : 'none'}
197                        style={[
198                            {
199                                backgroundColor: colors.background,
200                                right: rpx(24),
201                                top:
202                                    (menuIconLayout?.y ?? 0) +
203                                    (menuIconLayout?.height ?? 0) +
204                                    rpx(20) +
205                                    (menuWithStatusBar
206                                        ? OriginalStatusBar.currentHeight ?? 0
207                                        : 0),
208                                shadowColor: colors.shadow,
209                            },
210                            transformStyle,
211                            styles.menu,
212                        ]}>
213                        {menu.map(it =>
214                            it.show !== false ? (
215                                <ListItem
216                                    key={it.title}
217                                    withHorizontalPadding
218                                    heightType="small"
219                                    onPress={() => {
220                                        setShowMenu(false);
221                                        // async
222                                        setTimeout(() => {
223                                            it.onPress?.();
224                                        }, 20);
225                                    }}>
226                                    <ListItem.ListItemIcon icon={it.icon} />
227                                    <ListItem.Content title={it.title} />
228                                </ListItem>
229                            ) : null,
230                        )}
231                    </Animated.View>
232                </>
233            </Portal>
234        </>
235    );
236}
237
238const styles = StyleSheet.create({
239    container: {
240        width: '100%',
241        zIndex: 10000,
242        height: rpx(88),
243        flexDirection: 'row',
244        alignItems: 'center',
245        paddingHorizontal: rpx(24),
246    },
247    content: {
248        flexDirection: 'row',
249        flexBasis: 0,
250        alignItems: 'center',
251        paddingHorizontal: rpx(24),
252    },
253    rightButton: {
254        marginLeft: rpx(28),
255    },
256    blocker: {
257        position: 'absolute',
258        bottom: 0,
259        left: 0,
260        width: '100%',
261        height: '100%',
262        zIndex: 10010,
263    },
264    bubbleCorner: {
265        position: 'absolute',
266        borderColor: 'transparent',
267        borderWidth: rpx(10),
268        zIndex: 10012,
269        transformOrigin: 'right top',
270        opacity: 0,
271    },
272    menu: {
273        width: rpx(340),
274        maxHeight: rpx(600),
275        borderRadius: rpx(8),
276        zIndex: 10011,
277        position: 'absolute',
278        opacity: 0,
279        shadowOffset: {
280            width: 0,
281            height: 2,
282        },
283        shadowOpacity: 0.23,
284        shadowRadius: 2.62,
285        elevation: 4,
286    },
287});
288