Skip to content

Commit e5d8ad5

Browse files
committed
feat: custom toast
1 parent bb2d963 commit e5d8ad5

File tree

8 files changed

+367
-6
lines changed

8 files changed

+367
-6
lines changed

.cursor/rules/app.mdc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ This project is a monorepo for cross-platform development.
1212

1313
Before starting, you need to know the current technical stack structure and the construction of the monorepo.
1414

15+
When you are writing code, you should adapt the dark/light mode of the system.
16+
1517
## For React Native
1618

1719
- React 19

apps/mobile/app/_layout.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler'
99
import { adaptNavigationTheme, PaperProvider } from 'react-native-paper'
1010
import TrackPlayer from 'react-native-track-player'
1111
import { ThemedView } from '@/components/ui/ThemedView'
12+
import { ToastProvider } from '@/components/ui/Toast'
1213
import Themes from '@/constants/Themes'
1314
import { useInitLocalTracks } from '@/hooks/useInitLocalTracks'
1415
import { Player } from '@/modules/player'
@@ -47,11 +48,13 @@ export default function RootLayout() {
4748
<PaperProvider theme={paperTheme}>
4849
<ThemeProvider value={effectiveColorScheme === 'dark' ? DarkTheme : LightTheme}>
4950
<GestureHandlerRootView style={{ flex: 1 }}>
50-
<ThemedView style={{ flex: 1 }} testID="root-surface">
51-
<StatusBar style={statusBarStyle} />
52-
<Slot />
53-
<Player />
54-
</ThemedView>
51+
<ToastProvider>
52+
<ThemedView style={{ flex: 1 }} testID="root-surface">
53+
<StatusBar style={statusBarStyle} />
54+
<Slot />
55+
<Player />
56+
</ThemedView>
57+
</ToastProvider>
5558
</GestureHandlerRootView>
5659
</ThemeProvider>
5760
</PaperProvider>
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import type { ToastConfig } from '@/context/ToastContext'
2+
import { merge } from '@flow/core'
3+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
4+
import { Animated, Dimensions, StyleSheet, Text, View } from 'react-native'
5+
import { useTheme } from 'react-native-paper'
6+
import { useSafeAreaInsets } from 'react-native-safe-area-context'
7+
import { ToastContext } from '@/context/ToastContext'
8+
9+
const { width: screenWidth, height: screenHeight } = Dimensions.get('window')
10+
11+
export function ToastProvider({ children }: { children: React.ReactNode }) {
12+
const [visible, setVisible] = useState(false)
13+
const [config, setConfig] = useState<ToastConfig>({
14+
message: '',
15+
type: 'info',
16+
duration: 3000,
17+
position: 'bottom',
18+
})
19+
20+
const opacity = useRef(new Animated.Value(0)).current
21+
const translateY = useRef(new Animated.Value(50)).current
22+
const scale = useRef(new Animated.Value(0.9)).current
23+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
24+
25+
const insets = useSafeAreaInsets()
26+
const theme = useTheme()
27+
28+
const hideToast = useCallback(() => {
29+
// 执行退出动画
30+
Animated.parallel([
31+
Animated.timing(opacity, {
32+
toValue: 0,
33+
duration: 200,
34+
useNativeDriver: true,
35+
}),
36+
Animated.timing(translateY, {
37+
toValue: config.position === 'top' ? -50 : 50,
38+
duration: 200,
39+
useNativeDriver: true,
40+
}),
41+
Animated.timing(scale, {
42+
toValue: 0.9,
43+
duration: 200,
44+
useNativeDriver: true,
45+
}),
46+
]).start(() => {
47+
setVisible(false)
48+
})
49+
50+
if (timeoutRef.current) {
51+
clearTimeout(timeoutRef.current)
52+
}
53+
}, [opacity, translateY, scale, config.position])
54+
55+
const showToast = useCallback((newConfig: ToastConfig) => {
56+
// 清除之前的定时器
57+
if (timeoutRef.current) {
58+
clearTimeout(timeoutRef.current)
59+
}
60+
61+
const defaultConfig: ToastConfig = {
62+
duration: 3000,
63+
type: 'info',
64+
position: 'bottom',
65+
message: '',
66+
}
67+
68+
const finalConfig = merge(defaultConfig, newConfig)
69+
70+
setConfig(finalConfig)
71+
setVisible(true)
72+
73+
// 重置动画值
74+
opacity.setValue(0)
75+
translateY.setValue(finalConfig.position === 'top' ? -50 : 50)
76+
scale.setValue(0.9)
77+
78+
// 执行进入动画
79+
Animated.parallel([
80+
Animated.timing(opacity, {
81+
toValue: 1,
82+
duration: 300,
83+
useNativeDriver: true,
84+
}),
85+
Animated.timing(translateY, {
86+
toValue: 0,
87+
duration: 300,
88+
useNativeDriver: true,
89+
}),
90+
Animated.spring(scale, {
91+
toValue: 1,
92+
tension: 100,
93+
friction: 8,
94+
useNativeDriver: true,
95+
}),
96+
]).start()
97+
98+
// 设置自动隐藏
99+
if (finalConfig.duration && finalConfig.duration > 0) {
100+
timeoutRef.current = setTimeout(() => {
101+
hideToast()
102+
}, finalConfig.duration)
103+
}
104+
}, [opacity, translateY, scale, hideToast])
105+
106+
useEffect(() => {
107+
return () => {
108+
if (timeoutRef.current) {
109+
clearTimeout(timeoutRef.current)
110+
}
111+
}
112+
}, [])
113+
114+
const getToastStyle = () => {
115+
const baseStyle = {
116+
position: 'absolute' as const,
117+
left: 16,
118+
right: 16,
119+
zIndex: 9999,
120+
}
121+
122+
switch (config.position) {
123+
case 'top':
124+
return {
125+
...baseStyle,
126+
top: insets.top + 16,
127+
}
128+
case 'center':
129+
return {
130+
...baseStyle,
131+
top: (screenHeight - 56) / 2, // 56 是大概的 toast 高度
132+
}
133+
case 'bottom':
134+
default:
135+
return {
136+
...baseStyle,
137+
bottom: insets.bottom + 16,
138+
}
139+
}
140+
}
141+
142+
const getToastColors = () => {
143+
const { colors } = theme
144+
145+
switch (config.type) {
146+
case 'success':
147+
return {
148+
background: colors.primaryContainer,
149+
text: colors.onPrimaryContainer,
150+
}
151+
case 'error':
152+
return {
153+
background: colors.errorContainer,
154+
text: colors.onErrorContainer,
155+
}
156+
case 'warning':
157+
return {
158+
background: colors.secondaryContainer,
159+
text: colors.onSecondaryContainer,
160+
}
161+
case 'info':
162+
default:
163+
return {
164+
background: colors.tertiaryContainer,
165+
text: colors.onTertiaryContainer,
166+
}
167+
}
168+
}
169+
170+
const toastColors = getToastColors()
171+
const contextValue = useMemo(() => ({ showToast, hideToast }), [showToast, hideToast])
172+
173+
return (
174+
<ToastContext value={contextValue}>
175+
{children}
176+
{visible && (
177+
<Animated.View
178+
style={[
179+
getToastStyle(),
180+
{
181+
opacity,
182+
transform: [{ translateY }, { scale }],
183+
},
184+
]}
185+
pointerEvents="none"
186+
>
187+
<View
188+
style={[
189+
styles.toastContainer,
190+
{
191+
backgroundColor: toastColors.background,
192+
shadowColor: theme.colors.shadow,
193+
},
194+
]}
195+
>
196+
<Text
197+
style={[
198+
styles.toastText,
199+
{
200+
color: toastColors.text,
201+
},
202+
]}
203+
numberOfLines={2}
204+
>
205+
{config.message}
206+
</Text>
207+
</View>
208+
</Animated.View>
209+
)}
210+
</ToastContext>
211+
)
212+
}
213+
214+
const styles = StyleSheet.create({
215+
toastContainer: {
216+
paddingHorizontal: 16,
217+
paddingVertical: 12,
218+
borderRadius: 8,
219+
maxWidth: screenWidth - 32,
220+
alignSelf: 'center',
221+
// Material Design 阴影
222+
shadowOffset: {
223+
width: 0,
224+
height: 2,
225+
},
226+
shadowOpacity: 0.25,
227+
shadowRadius: 4,
228+
elevation: 5,
229+
},
230+
toastText: {
231+
fontSize: 14,
232+
fontWeight: '500',
233+
textAlign: 'center',
234+
lineHeight: 20,
235+
},
236+
})
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { createContext } from 'react'
2+
3+
export type ToastType = 'success' | 'error' | 'info' | 'warning'
4+
export type ToastPosition = 'top' | 'center' | 'bottom'
5+
6+
export interface ToastConfig {
7+
message: string
8+
type?: ToastType
9+
duration?: number
10+
position?: ToastPosition
11+
}
12+
13+
export interface ToastContextType {
14+
showToast: (config: ToastConfig) => void
15+
hideToast: () => void
16+
}
17+
18+
export const ToastContext = createContext<ToastContextType | null>(null)

apps/mobile/hooks/useToast.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import type { ToastConfig, ToastPosition } from '@/context/ToastContext'
2+
import { use, useCallback } from 'react'
3+
import { ToastContext } from '@/context/ToastContext'
4+
5+
interface Toast {
6+
success: (message: string, position?: ToastPosition, duration?: number) => void
7+
error: (message: string, position?: ToastPosition, duration?: number) => void
8+
warning: (message: string, position?: ToastPosition, duration?: number) => void
9+
info: (message: string, position?: ToastPosition, duration?: number) => void
10+
show: (config: ToastConfig) => void
11+
hide: () => void
12+
}
13+
14+
export function useToast(): Toast {
15+
const context = use(ToastContext)
16+
17+
if (!context) {
18+
throw new Error('useToast must be used within a ToastProvider')
19+
}
20+
21+
const showSuccess = useCallback((message: string, position?: ToastPosition, duration?: number) => {
22+
context.showToast({ message, type: 'success', position, duration })
23+
}, [context])
24+
25+
const showError = useCallback((message: string, position?: ToastPosition, duration?: number) => {
26+
context.showToast({ message, type: 'error', position, duration })
27+
}, [context])
28+
29+
const showWarning = useCallback((message: string, position?: ToastPosition, duration?: number) => {
30+
context.showToast({ message, type: 'warning', position, duration })
31+
}, [context])
32+
33+
const showInfo = useCallback((message: string, position?: ToastPosition, duration?: number) => {
34+
context.showToast({ message, type: 'info', position, duration })
35+
}, [context])
36+
37+
const toast: Toast = {
38+
success: showSuccess,
39+
error: showError,
40+
warning: showWarning,
41+
info: showInfo,
42+
hide: context.hideToast,
43+
show: context.showToast,
44+
}
45+
46+
return toast
47+
}

packages/core/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './debugger'
22
export * from './logger'
3+
export * from './merge'
34
export * from './time'

packages/core/src/utils/merge.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* 合并对象,只有当源对象的属性不是 undefined 时才覆盖目标对象的属性
3+
* @param target 目标对象(默认配置)
4+
* @param source 源对象(用户配置)
5+
* @returns 合并后的新对象
6+
*/
7+
export function merge<T extends Record<string, any>, U extends Partial<T>>(
8+
target: T,
9+
source: U,
10+
): T & U {
11+
const result = { ...target } as T & U
12+
13+
for (const key in source) {
14+
if (source[key] !== undefined) {
15+
;(result as any)[key] = source[key]
16+
}
17+
}
18+
19+
return result
20+
}
21+
22+
/**
23+
* 深度合并对象,只有当源对象的属性不是 undefined 时才覆盖目标对象的属性
24+
* @param target 目标对象(默认配置)
25+
* @param source 源对象(用户配置)
26+
* @returns 合并后的新对象
27+
*/
28+
export function deepMerge<T extends Record<string, any>, U extends Partial<T>>(
29+
target: T,
30+
source: U,
31+
): T & U {
32+
const result = { ...target } as T & U
33+
34+
for (const key in source) {
35+
if (source[key] !== undefined) {
36+
if (
37+
typeof source[key] === 'object'
38+
&& source[key] !== null
39+
&& !Array.isArray(source[key])
40+
&& typeof target[key] === 'object'
41+
&& target[key] !== null
42+
&& !Array.isArray(target[key])
43+
) {
44+
// 递归深度合并对象
45+
;(result as any)[key] = deepMerge(target[key], source[key])
46+
}
47+
else {
48+
;(result as any)[key] = source[key]
49+
}
50+
}
51+
}
52+
53+
return result
54+
}

0 commit comments

Comments
 (0)