Skip to content

Commit c1a9e0f

Browse files
committed
fix(toast): close handle press
1 parent 81a3ef7 commit c1a9e0f

File tree

6 files changed

+356
-56
lines changed

6 files changed

+356
-56
lines changed

example/src/app/(home)/components/toast.tsx

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ import {
1010
LoadingToast,
1111
useLoadingState,
1212
} from '../../../components/toast/loading-toast';
13+
import {
14+
ProgressToast,
15+
useProgressState,
16+
} from '../../../components/toast/progress-toast';
1317

1418
const StyledFeather = withUniwind(Feather);
1519
const StyledOcticons = withUniwind(Octicons);
@@ -196,8 +200,10 @@ const DifferentContentSizesContent = () => {
196200

197201
const CustomToastsContent = () => {
198202
const { toast } = useToast();
199-
const TOAST_ID = 'loading-toast';
203+
const LOADING_TOAST_ID = 'loading-toast';
204+
const PROGRESS_TOAST_ID = 'progress-toast';
200205
const { isLoading, setIsLoading } = useLoadingState();
206+
const { progress, setProgress, resetProgress } = useProgressState();
201207

202208
/**
203209
* Simulates loading data (e.g., API call, file upload, etc.)
@@ -210,17 +216,36 @@ const CustomToastsContent = () => {
210216
await new Promise((resolve) => setTimeout(resolve, 2000));
211217
};
212218

219+
/**
220+
* Simulates file upload with progress updates
221+
* In a real app, this would be an actual upload operation with progress callbacks
222+
*/
223+
const simulateUpload = async (): Promise<void> => {
224+
resetProgress();
225+
const totalSteps = 100;
226+
const stepDuration = 30; // milliseconds per step
227+
228+
for (let i = 0; i <= totalSteps; i++) {
229+
await new Promise((resolve) => setTimeout(resolve, stepDuration));
230+
setProgress(i);
231+
}
232+
};
233+
213234
const renderLoadingToast = useCallback((props: ToastComponentProps) => {
214235
return <LoadingToast {...props} />;
215236
}, []);
216237

238+
const renderProgressToast = useCallback((props: ToastComponentProps) => {
239+
return <ProgressToast {...props} />;
240+
}, []);
241+
217242
const handleShowLoadingToast = async () => {
218243
/**
219244
* Set loading to true and show toast
220245
*/
221246
setIsLoading(true);
222247
toast.show({
223-
id: TOAST_ID,
248+
id: LOADING_TOAST_ID,
224249
duration: 'persistent',
225250
component: renderLoadingToast,
226251
});
@@ -243,14 +268,48 @@ const CustomToastsContent = () => {
243268
}
244269
};
245270

271+
const handleShowProgressToast = async () => {
272+
/**
273+
* Reset progress and show toast
274+
*/
275+
resetProgress();
276+
toast.show({
277+
id: PROGRESS_TOAST_ID,
278+
duration: 'persistent',
279+
component: renderProgressToast,
280+
});
281+
282+
try {
283+
/**
284+
* Simulate the upload operation with progress updates
285+
*/
286+
await simulateUpload();
287+
} catch (error) {
288+
/**
289+
* Handle errors if needed
290+
*/
291+
console.error('Failed to upload:', error);
292+
}
293+
};
294+
295+
const isDisabled = isLoading || (progress > 0 && progress < 100);
296+
246297
return (
247298
<View className="flex-1 items-center justify-center px-5 gap-5">
248299
<Button
249300
variant="secondary"
250301
onPress={handleShowLoadingToast}
251-
isDisabled={isLoading}
302+
isDisabled={isDisabled}
303+
>
304+
Load data
305+
</Button>
306+
307+
<Button
308+
variant="secondary"
309+
onPress={handleShowProgressToast}
310+
isDisabled={isDisabled}
252311
>
253-
{isLoading ? 'Loading...' : 'Load data'}
312+
Start upload
254313
</Button>
255314

256315
<Button onPress={() => toast.hide('all')} variant="destructive-soft">

example/src/components/toast/loading-toast.tsx

Lines changed: 8 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,15 @@ import {
66
useThemeColor,
77
type ToastComponentProps,
88
} from 'heroui-native';
9-
import { useCallback, useEffect, useState } from 'react';
9+
import { useEffect } from 'react';
1010
import { View } from 'react-native';
1111
import { LinearTransition } from 'react-native-reanimated';
1212
import { withUniwind } from 'uniwind';
13+
import { useSharedState } from './use-shared-state';
1314

1415
const StyledFeather = withUniwind(Feather);
1516

16-
/**
17-
* Shared loading state management
18-
*
19-
* Why we need this:
20-
* - The toast component is rendered via a memoized callback that doesn't depend on isLoading
21-
* - When parent updates loading state, the toast component needs to be notified to re-render
22-
* - We use sharedLoadingState to track current value and listeners to notify components
23-
*/
24-
let sharedLoadingState = false;
25-
const loadingStateListeners = new Set<(loading: boolean) => void>();
17+
const LOADING_STATE_KEY = 'loading-toast-state';
2618

2719
/**
2820
* Hook to access and update shared loading state
@@ -32,45 +24,10 @@ const loadingStateListeners = new Set<(loading: boolean) => void>();
3224
* with the new loading state, even if they're memoized or rendered separately
3325
*/
3426
export const useLoadingState = () => {
35-
/**
36-
* Initialize state from shared value (important if component mounts after loading starts)
37-
*/
38-
const [isLoading, setIsLoadingState] = useState(() => sharedLoadingState);
39-
40-
useEffect(() => {
41-
/**
42-
* Subscribe to loading state changes
43-
* When setIsLoading is called elsewhere, this component will update
44-
*/
45-
const updateState = (loading: boolean) => {
46-
setIsLoadingState(loading);
47-
};
48-
49-
/**
50-
* Sync with current shared state immediately (important if component mounts after state was set)
51-
*/
52-
setIsLoadingState(sharedLoadingState);
53-
54-
/**
55-
* Add listener to receive future updates
56-
*/
57-
loadingStateListeners.add(updateState);
58-
59-
/**
60-
* Cleanup listener on unmount
61-
*/
62-
return () => {
63-
loadingStateListeners.delete(updateState);
64-
};
65-
}, []);
66-
67-
/**
68-
* Set loading state and notify all listeners (all components using this hook)
69-
*/
70-
const setIsLoading = useCallback((loading: boolean) => {
71-
sharedLoadingState = loading;
72-
loadingStateListeners.forEach((listener) => listener(loading));
73-
}, []);
27+
const { state: isLoading, setState: setIsLoading } = useSharedState<boolean>(
28+
LOADING_STATE_KEY,
29+
false
30+
);
7431

7532
return { isLoading, setIsLoading };
7633
};
@@ -102,6 +59,7 @@ export const LoadingToast = (props: ToastComponentProps) => {
10259
'mx-auto flex-row items-center gap-3 rounded-full p-1',
10360
isLoading ? 'w-[115px]' : 'w-[185px]'
10461
)}
62+
isSwipeable={false}
10563
{...props}
10664
>
10765
<View className="flex-1 flex-row items-center gap-2">
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { Toast, type ToastComponentProps } from 'heroui-native';
2+
import { useCallback, useEffect } from 'react';
3+
import { View } from 'react-native';
4+
import Animated, {
5+
LinearTransition,
6+
useAnimatedStyle,
7+
useSharedValue,
8+
withTiming,
9+
} from 'react-native-reanimated';
10+
import { useSharedState } from './use-shared-state';
11+
12+
const PROGRESS_STATE_KEY = 'progress-toast-state';
13+
14+
/**
15+
* Hook to access and update shared progress state
16+
* Can be used in both parent component and toast component
17+
*
18+
* When setProgress is called, all components using this hook will re-render
19+
* with the new progress value, even if they're memoized or rendered separately
20+
*
21+
* @param initialProgress - Initial progress value (0-100)
22+
*/
23+
export const useProgressState = (initialProgress = 0) => {
24+
const {
25+
state: progress,
26+
setState: setProgressState,
27+
resetState,
28+
} = useSharedState<number>(PROGRESS_STATE_KEY, initialProgress);
29+
30+
/**
31+
* Set progress state with clamping between 0 and 100
32+
*
33+
* @param newProgress - Progress value (0-100)
34+
*/
35+
const setProgress = useCallback(
36+
(newProgress: number) => {
37+
/**
38+
* Clamp progress between 0 and 100
39+
*/
40+
const clampedProgress = Math.max(0, Math.min(100, newProgress));
41+
setProgressState(clampedProgress);
42+
},
43+
[setProgressState]
44+
);
45+
46+
/**
47+
* Reset progress to initial value (0)
48+
*/
49+
const resetProgress = useCallback(() => {
50+
resetState();
51+
}, [resetState]);
52+
53+
return { progress, setProgress, resetProgress };
54+
};
55+
56+
/**
57+
* Progress toast component that shows a progress bar at the bottom
58+
* Progress value ranges from 0 to 100
59+
*/
60+
export const ProgressToast = (props: ToastComponentProps) => {
61+
const { id, hide } = props;
62+
const { progress } = useProgressState();
63+
64+
/**
65+
* Animated progress value for smooth transitions
66+
*/
67+
const animatedProgress = useSharedValue(progress);
68+
69+
/**
70+
* Update animated value when progress changes
71+
*/
72+
useEffect(() => {
73+
animatedProgress.value = withTiming(progress, {
74+
duration: 300,
75+
});
76+
}, [progress, animatedProgress]);
77+
78+
/**
79+
* Auto-hide toast when progress reaches 100%
80+
*/
81+
useEffect(() => {
82+
if (progress >= 100) {
83+
const timeoutId = setTimeout(() => {
84+
hide(id);
85+
}, 500);
86+
87+
return () => {
88+
clearTimeout(timeoutId);
89+
};
90+
}
91+
return undefined;
92+
}, [progress, hide, id]);
93+
94+
/**
95+
* Animated style for progress bar
96+
*/
97+
const progressBarStyle = useAnimatedStyle(() => {
98+
return {
99+
width: `${animatedProgress.value}%`,
100+
};
101+
});
102+
103+
return (
104+
<Toast
105+
// @ts-ignore
106+
layout={LinearTransition.springify().mass(2)}
107+
className="w-full overflow-hidden"
108+
isSwipeable={false}
109+
{...props}
110+
>
111+
<View className="flex-col gap-2 px-4 py-3">
112+
<Toast.Label className="text-sm mb-4">
113+
{progress < 100
114+
? `Uploading... ${Math.round(progress)}%`
115+
: 'Upload complete!'}
116+
</Toast.Label>
117+
<View className="h-1 w-full overflow-hidden rounded-full bg-muted/20">
118+
<Animated.View
119+
className="h-full bg-accent rounded-full"
120+
style={progressBarStyle}
121+
/>
122+
</View>
123+
<Toast.Close className="absolute right-0 top-1.5" />
124+
</View>
125+
</Toast>
126+
);
127+
};

0 commit comments

Comments
 (0)