xref: /MusicFree/src/components/base/toast.tsx (revision 5589cdf32b2bb0f641e5ac7bf1f6152cd6b9b70e)
1import {timingConfig} from '@/constants/commonConst';
2import {fontSizeConst} from '@/constants/uiConst';
3import useColors from '@/hooks/useColors';
4import rpx from '@/utils/rpx';
5import {GlobalState} from '@/utils/stateMapper';
6import {nanoid} from 'nanoid';
7import React, {useCallback, useEffect} from 'react';
8import {Pressable, StyleSheet, Text, View} from 'react-native';
9import {
10    Directions,
11    Gesture,
12    GestureDetector,
13} from 'react-native-gesture-handler';
14import Animated, {
15    cancelAnimation,
16    runOnJS,
17    useAnimatedStyle,
18    useSharedValue,
19    withDelay,
20    withTiming,
21} from 'react-native-reanimated';
22import Icon from '@/components/base/icon.tsx';
23
24export interface IToastConfig {
25    /** 类型 */
26    type: 'success' | 'warn';
27    /** 消息内容 */
28    message?: string;
29    /** 行动点 */
30    actionText?: string;
31    /** 行动点按钮行为 */
32    onActionClick?: () => void;
33    /** 展示时间 */
34    duration?: number;
35}
36
37type IToastConfigInner = IToastConfig & {
38    id: string;
39};
40
41const toastQueue: IToastConfigInner[] = [];
42
43const fixedTop = rpx(250);
44
45const activeToastStore = new GlobalState<IToastConfigInner | null>(null);
46
47const typeConfig = {
48    success: {
49        name: 'check-circle',
50        color: '#457236',
51    },
52    warn: {
53        name: 'exclamation-circle',
54        color: '#de7622',
55    },
56} as const;
57
58export function ToastBaseComponent() {
59    const activeToast = activeToastStore.useValue();
60    const colors = useColors();
61
62    const toastAnim = useSharedValue(0);
63
64    const setNextToast = useCallback(() => {
65        activeToastStore.setValue(toastQueue.shift() || null);
66    }, []);
67
68    useEffect(() => {
69        if (activeToast) {
70            toastAnim.value = withTiming(1, timingConfig.animationSlow, () => {
71                toastAnim.value = withDelay(
72                    activeToast.duration || 1200,
73                    withTiming(0, timingConfig.animationSlow, finished => {
74                        if (finished) {
75                            runOnJS(setNextToast)();
76                        }
77                    }),
78                );
79            });
80        }
81    }, [activeToast]);
82
83    function removeCurrentToast() {
84        if (toastAnim.value === 1) {
85            cancelAnimation(toastAnim);
86            toastAnim.value = withTiming(
87                0,
88                timingConfig.animationSlow,
89                finished => {
90                    if (finished) {
91                        runOnJS(setNextToast)();
92                    }
93                },
94            );
95        }
96    }
97
98    const flingGesture = Gesture.Fling()
99        .direction(Directions.UP)
100        .onEnd(() => {
101            removeCurrentToast();
102        })
103        .runOnJS(true);
104
105    const toastAnimStyle = useAnimatedStyle(() => {
106        return {
107            transform: [
108                {
109                    translateY: (toastAnim.value - 1) * fixedTop,
110                },
111            ],
112            opacity: toastAnim.value,
113        };
114    });
115
116    return activeToast ? (
117        <GestureDetector gesture={flingGesture}>
118            <View style={styles.container}>
119                <Animated.View
120                    style={[
121                        styles.contentContainer,
122                        {
123                            backgroundColor: colors.backdrop,
124                            shadowColor: colors.shadow,
125                        },
126                        toastAnimStyle,
127                    ]}>
128                    <Icon
129                        size={fontSizeConst.appbar}
130                        name={typeConfig[activeToast.type].name}
131                        color={typeConfig[activeToast.type].color}
132                    />
133                    <Text
134                        numberOfLines={1}
135                        style={[styles.text, {color: colors.text}]}>
136                        {activeToast.message}
137                    </Text>
138                    {activeToast.actionText && activeToast.onActionClick ? (
139                        <Pressable
140                            style={[
141                                styles.actionTextContainer,
142                                {backgroundColor: colors.primary},
143                            ]}
144                            onPress={activeToast.onActionClick}>
145                            <Text style={styles.actionText} numberOfLines={1}>
146                                {activeToast.actionText}
147                            </Text>
148                        </Pressable>
149                    ) : null}
150                </Animated.View>
151            </View>
152        </GestureDetector>
153    ) : null;
154}
155
156const styles = StyleSheet.create({
157    container: {
158        position: 'absolute',
159        top: rpx(128),
160        width: '100%',
161        alignItems: 'center',
162        height: rpx(100),
163        zIndex: 20000,
164    },
165    contentContainer: {
166        width: rpx(688),
167        height: '100%',
168        borderRadius: rpx(12),
169        backgroundColor: 'blue',
170        flexDirection: 'row',
171        alignItems: 'center',
172        paddingHorizontal: rpx(24),
173        shadowOffset: {
174            width: 0,
175            height: 2,
176        },
177        shadowOpacity: 0.2,
178        shadowRadius: 1.41,
179
180        elevation: 2,
181    },
182    text: {
183        fontSize: fontSizeConst.content,
184        includeFontPadding: false,
185        flex: 1,
186        marginLeft: rpx(24),
187    },
188    actionText: {
189        fontSize: fontSizeConst.content,
190        includeFontPadding: false,
191        color: 'white',
192    },
193    actionTextContainer: {
194        marginLeft: rpx(24),
195        width: rpx(120),
196        paddingHorizontal: rpx(12),
197        flexDirection: 'row',
198        alignItems: 'center',
199        justifyContent: 'center',
200        borderRadius: rpx(30),
201        height: rpx(58),
202    },
203});
204
205export function showToast(config: IToastConfig) {
206    const id = nanoid();
207    const _config = {
208        ...config,
209        id,
210    };
211    const activeToast = activeToastStore.getValue();
212    if (!activeToast) {
213        activeToastStore.setValue(_config);
214    } else {
215        toastQueue.push(_config);
216    }
217
218    return id;
219}
220