react-native-bread 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-bread",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "A delicious toast library for React Native with beautiful animations and gesture support",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
@@ -1,11 +1,10 @@
1
- import { type ReactNode, useEffect } from "react";
1
+ import { useEffect } from "react";
2
2
  import { StyleSheet, View } from "react-native";
3
3
  import { ToastContainer } from "./toast";
4
4
  import { toastStore } from "./toast-store";
5
5
  import type { ToastConfig } from "./types";
6
6
 
7
7
  interface BreadLoafProps {
8
- children: ReactNode;
9
8
  /**
10
9
  * Configuration for customizing toast behavior and appearance.
11
10
  * All properties are optional and will be merged with defaults.
@@ -23,17 +22,22 @@ interface BreadLoafProps {
23
22
  }
24
23
 
25
24
  /**
26
- * Toast provider component that enables toast notifications in your app.
27
- * Wrap your root component with `<BreadLoaf>` to start showing toasts.
25
+ * Toast component that enables toast notifications in your app.
26
+ * Add `<BreadLoaf />` to your root layout to start showing toasts.
28
27
  *
29
28
  * @example
30
29
  * ```tsx
31
30
  * import { BreadLoaf } from 'react-native-bread';
32
31
  *
33
- * // Basic usage
34
- * <BreadLoaf>
35
- * <App />
36
- * </BreadLoaf>
32
+ * // Basic usage - add to your root layout
33
+ * export default function RootLayout() {
34
+ * return (
35
+ * <>
36
+ * <Stack />
37
+ * <BreadLoaf />
38
+ * </>
39
+ * );
40
+ * }
37
41
  *
38
42
  * // With configuration
39
43
  * <BreadLoaf
@@ -47,34 +51,26 @@ interface BreadLoafProps {
47
51
  * },
48
52
  * toastStyle: { borderRadius: 12 },
49
53
  * }}
50
- * >
51
- * <App />
52
- * </BreadLoaf>
54
+ * />
53
55
  * ```
54
56
  */
55
- export function BreadLoaf({ children, config }: BreadLoafProps) {
57
+ export function BreadLoaf({ config }: BreadLoafProps) {
56
58
  useEffect(() => {
57
59
  toastStore.setConfig(config);
58
60
  return () => {
59
- // Reset to defaults when this provider unmounts
60
61
  toastStore.setConfig(undefined);
61
62
  };
62
63
  }, [config]);
64
+
63
65
  return (
64
- <View style={styles.root}>
65
- {children}
66
- <View style={styles.portalContainer} pointerEvents="box-none">
67
- <ToastContainer />
68
- </View>
66
+ <View style={styles.container} pointerEvents="box-none">
67
+ <ToastContainer />
69
68
  </View>
70
69
  );
71
70
  }
72
71
 
73
72
  const styles = StyleSheet.create({
74
- root: {
75
- flex: 1,
76
- },
77
- portalContainer: {
73
+ container: {
78
74
  ...StyleSheet.absoluteFillObject,
79
75
  zIndex: 9999,
80
76
  },
package/src/toast.tsx CHANGED
@@ -4,8 +4,8 @@ import { Gesture, GestureDetector } from "react-native-gesture-handler";
4
4
  import Animated, {
5
5
  Easing,
6
6
  interpolate,
7
- interpolateColor,
8
7
  useAnimatedStyle,
8
+ useDerivedValue,
9
9
  useSharedValue,
10
10
  withTiming,
11
11
  } from "react-native-reanimated";
@@ -17,21 +17,36 @@ import type { IconRenderFn, ToastPosition, ToastTheme } from "./types";
17
17
 
18
18
  const ICON_SIZE = 28;
19
19
 
20
- /** Default icon for each toast type */
21
- const DefaultIcon = ({ type, accentColor }: { type: ToastType; accentColor: string }) => {
20
+ /** Memoized default icons to prevent SVG re-renders */
21
+ const MemoizedGreenCheck = memo(({ fill }: { fill: string }) => <GreenCheck width={36} height={36} fill={fill} />);
22
+ const MemoizedRedX = memo(({ fill }: { fill: string }) => <RedX width={ICON_SIZE} height={ICON_SIZE} fill={fill} />);
23
+ const MemoizedInfoIcon = memo(({ fill }: { fill: string }) => (
24
+ <InfoIcon width={ICON_SIZE} height={ICON_SIZE} fill={fill} />
25
+ ));
26
+ const MemoizedCloseIcon = memo(() => <CloseIcon width={20} height={20} />);
27
+
28
+ /** Default icon for each toast type - memoized */
29
+ const DefaultIcon = memo(({ type, accentColor }: { type: ToastType; accentColor: string }) => {
22
30
  switch (type) {
23
31
  case "success":
24
- return <GreenCheck width={36} height={36} fill={accentColor} />;
32
+ return <MemoizedGreenCheck fill={accentColor} />;
25
33
  case "error":
26
- return <RedX width={ICON_SIZE} height={ICON_SIZE} fill={accentColor} />;
34
+ return <MemoizedRedX fill={accentColor} />;
27
35
  case "loading":
28
36
  return <ActivityIndicator size={ICON_SIZE} color={accentColor} />;
29
37
  case "info":
30
- return <InfoIcon width={ICON_SIZE} height={ICON_SIZE} fill={accentColor} />;
38
+ return <MemoizedInfoIcon fill={accentColor} />;
31
39
  default:
32
- return <GreenCheck width={36} height={36} fill={accentColor} />;
40
+ return <MemoizedGreenCheck fill={accentColor} />;
33
41
  }
34
- };
42
+ });
43
+
44
+ interface AnimatedIconProps {
45
+ type: ToastType;
46
+ accentColor: string;
47
+ customIcon?: ReactNode | IconRenderFn;
48
+ configIcon?: IconRenderFn;
49
+ }
35
50
 
36
51
  /** Resolves the icon to render - checks per-toast, then config, then default */
37
52
  const resolveIcon = (
@@ -40,31 +55,20 @@ const resolveIcon = (
40
55
  customIcon?: ReactNode | IconRenderFn,
41
56
  configIcon?: IconRenderFn
42
57
  ): ReactNode => {
43
- // Per-toast custom icon takes priority
44
58
  if (customIcon) {
45
59
  if (typeof customIcon === "function") {
46
60
  return customIcon({ color: accentColor, size: ICON_SIZE });
47
61
  }
48
62
  return customIcon;
49
63
  }
50
-
51
- // Config-level custom icon
52
64
  if (configIcon) {
53
65
  return configIcon({ color: accentColor, size: ICON_SIZE });
54
66
  }
55
-
56
- // Default icon
57
67
  return <DefaultIcon type={type} accentColor={accentColor} />;
58
68
  };
59
69
 
60
- interface AnimatedIconProps {
61
- type: ToastType;
62
- accentColor: string;
63
- customIcon?: ReactNode | IconRenderFn;
64
- configIcon?: IconRenderFn;
65
- }
66
-
67
- const AnimatedIcon = ({ type, accentColor, customIcon, configIcon }: AnimatedIconProps) => {
70
+ /** Animated icon wrapper with scale/fade animation */
71
+ const AnimatedIcon = memo(({ type, accentColor, customIcon, configIcon }: AnimatedIconProps) => {
68
72
  const progress = useSharedValue(0);
69
73
 
70
74
  useEffect(() => {
@@ -77,7 +81,69 @@ const AnimatedIcon = ({ type, accentColor, customIcon, configIcon }: AnimatedIco
77
81
  }));
78
82
 
79
83
  return <Animated.View style={style}>{resolveIcon(type, accentColor, customIcon, configIcon)}</Animated.View>;
80
- };
84
+ });
85
+
86
+ interface ToastContentProps {
87
+ type: ToastType;
88
+ title: string;
89
+ description?: string;
90
+ accentColor: string;
91
+ customIcon?: ReactNode | IconRenderFn;
92
+ configIcon?: IconRenderFn;
93
+ showCloseButton: boolean;
94
+ onDismiss: () => void;
95
+ titleStyle?: object;
96
+ descriptionStyle?: object;
97
+ optionsTitleStyle?: object;
98
+ optionsDescriptionStyle?: object;
99
+ }
100
+
101
+ /** Memoized toast content to prevent inline JSX recreation */
102
+ const ToastContent = memo(
103
+ ({
104
+ type,
105
+ title,
106
+ description,
107
+ accentColor,
108
+ customIcon,
109
+ configIcon,
110
+ showCloseButton,
111
+ onDismiss,
112
+ titleStyle,
113
+ descriptionStyle,
114
+ optionsTitleStyle,
115
+ optionsDescriptionStyle,
116
+ }: ToastContentProps) => (
117
+ <View style={styles.content}>
118
+ <View style={styles.icon}>
119
+ <AnimatedIcon key={type} type={type} accentColor={accentColor} customIcon={customIcon} configIcon={configIcon} />
120
+ </View>
121
+ <View style={styles.textContainer}>
122
+ <Text
123
+ maxFontSizeMultiplier={1.35}
124
+ allowFontScaling={false}
125
+ style={[styles.title, { color: accentColor }, titleStyle, optionsTitleStyle]}
126
+ >
127
+ {title}
128
+ </Text>
129
+ {description && (
130
+ <Text
131
+ allowFontScaling={false}
132
+ maxFontSizeMultiplier={1.35}
133
+ style={[styles.description, descriptionStyle, optionsDescriptionStyle]}
134
+ >
135
+ {description}
136
+ </Text>
137
+ )}
138
+ </View>
139
+ {showCloseButton && (
140
+ <Pressable style={styles.closeButton} onPress={onDismiss} hitSlop={12}>
141
+ <MemoizedCloseIcon />
142
+ </Pressable>
143
+ )}
144
+ </View>
145
+ )
146
+ );
81
147
 
82
148
  // singleton instance
83
149
  export const ToastContainer = () => {
@@ -157,17 +223,11 @@ const ToastItem = ({ toast, index, theme, position }: ToastItemProps) => {
157
223
  // Stack position animation
158
224
  const stackIndex = useSharedValue(index);
159
225
 
160
- // Title color animation on variant change
161
- const colorProgress = useSharedValue(1);
162
- const fromColor = useSharedValue(theme.colors[toast.type].accent);
163
- const toColor = useSharedValue(theme.colors[toast.type].accent);
164
-
165
226
  // Refs for tracking previous values to avoid unnecessary animations
166
227
  const lastHandledType = useRef(toast.type);
167
228
  const prevIndex = useRef(index);
168
229
  const hasEntered = useRef(false);
169
230
 
170
- // Combined animation effect for entry, exit, color transitions, and stack position
171
231
  useEffect(() => {
172
232
  // Entry animation (only once on mount)
173
233
  if (!hasEntered.current && !toast.isExiting) {
@@ -181,13 +241,9 @@ const ToastItem = ({ toast, index, theme, position }: ToastItemProps) => {
181
241
  translationY.value = withTiming(exitToY, { duration: ExitDuration, easing: EASING });
182
242
  }
183
243
 
184
- // Color transition when type changes
244
+ // Track type changes (for icon animation via key)
185
245
  if (toast.type !== lastHandledType.current) {
186
- fromColor.value = theme.colors[lastHandledType.current].accent;
187
- toColor.value = theme.colors[toast.type].accent;
188
246
  lastHandledType.current = toast.type;
189
- colorProgress.value = 0;
190
- colorProgress.value = withTiming(1, { duration: 300, easing: EASING });
191
247
  }
192
248
 
193
249
  // Stack position animation when index changes
@@ -195,80 +251,74 @@ const ToastItem = ({ toast, index, theme, position }: ToastItemProps) => {
195
251
  stackIndex.value = withTiming(index, { duration: 300, easing: EASING });
196
252
  prevIndex.current = index;
197
253
  }
198
- }, [
199
- toast.isExiting,
200
- toast.type,
201
- index,
202
- progress,
203
- translationY,
204
- fromColor,
205
- toColor,
206
- colorProgress,
207
- stackIndex,
208
- exitToY,
209
- theme.colors,
210
- ]);
211
-
212
- const titleColorStyle = useAnimatedStyle(() => ({
213
- color: interpolateColor(colorProgress.value, [0, 1], [fromColor.value, toColor.value]),
214
- }));
254
+ }, [toast.isExiting, toast.type, index, progress, translationY, stackIndex, exitToY]);
215
255
 
216
256
  const dismissToast = useCallback(() => {
217
257
  toastStore.hide(toast.id);
218
258
  }, [toast.id]);
219
259
 
220
- const panGesture = Gesture.Pan()
221
- .onStart(() => {
222
- "worklet";
223
- isBeingDragged.value = true;
224
- shouldDismiss.value = false;
225
- })
226
- .onUpdate(event => {
227
- "worklet";
228
- const rawY = event.translationY;
229
- // For top: negative Y = dismiss direction, positive Y = resistance
230
- // For bottom: positive Y = dismiss direction, negative Y = resistance
231
- const dismissDrag = isBottom ? rawY : -rawY;
232
- const resistDrag = isBottom ? -rawY : rawY;
233
-
234
- if (dismissDrag > 0) {
235
- // Moving toward dismiss direction
236
- const clampedY = isBottom ? Math.min(rawY, 180) : Math.max(rawY, -180);
237
- translationY.value = clampedY;
238
- if (dismissDrag > 40 || (isBottom ? event.velocityY > 300 : event.velocityY < -300)) {
239
- shouldDismiss.value = true;
240
- }
241
- } else {
242
- // Moving away from edge - apply resistance
243
- const exponentialDrag = MaxDragDown * (1 - Math.exp(-resistDrag / 250));
244
- translationY.value = isBottom
245
- ? -Math.min(exponentialDrag, MaxDragDown)
246
- : Math.min(exponentialDrag, MaxDragDown);
247
- shouldDismiss.value = false;
248
- }
249
- })
250
- .onEnd(() => {
251
- "worklet";
252
- isBeingDragged.value = false;
253
-
254
- if (shouldDismiss.value) {
255
- progress.value = withTiming(0, {
256
- duration: ExitDuration,
257
- easing: EASING,
258
- });
259
- const exitOffset = isBottom ? 200 : -200;
260
- translationY.value = withTiming(translationY.value + exitOffset, {
261
- duration: ExitDuration,
262
- easing: EASING,
263
- });
264
- scheduleOnRN(dismissToast);
265
- } else {
266
- translationY.value = withTiming(0, {
267
- duration: 650,
268
- easing: EASING,
269
- });
270
- }
271
- });
260
+ const panGesture = useMemo(
261
+ () =>
262
+ Gesture.Pan()
263
+ .onStart(() => {
264
+ "worklet";
265
+ isBeingDragged.value = true;
266
+ shouldDismiss.value = false;
267
+ })
268
+ .onUpdate(event => {
269
+ "worklet";
270
+ const rawY = event.translationY;
271
+ // For top: negative Y = dismiss direction, positive Y = resistance
272
+ // For bottom: positive Y = dismiss direction, negative Y = resistance
273
+ const dismissDrag = isBottom ? rawY : -rawY;
274
+ const resistDrag = isBottom ? -rawY : rawY;
275
+
276
+ if (dismissDrag > 0) {
277
+ // Moving toward dismiss direction
278
+ const clampedY = isBottom ? Math.min(rawY, 180) : Math.max(rawY, -180);
279
+ translationY.value = clampedY;
280
+ if (dismissDrag > 40 || (isBottom ? event.velocityY > 300 : event.velocityY < -300)) {
281
+ shouldDismiss.value = true;
282
+ }
283
+ } else {
284
+ // Moving away from edge - apply resistance
285
+ const exponentialDrag = MaxDragDown * (1 - Math.exp(-resistDrag / 250));
286
+ translationY.value = isBottom
287
+ ? -Math.min(exponentialDrag, MaxDragDown)
288
+ : Math.min(exponentialDrag, MaxDragDown);
289
+ shouldDismiss.value = false;
290
+ }
291
+ })
292
+ .onEnd(() => {
293
+ "worklet";
294
+ isBeingDragged.value = false;
295
+
296
+ if (shouldDismiss.value) {
297
+ progress.value = withTiming(0, {
298
+ duration: ExitDuration,
299
+ easing: EASING,
300
+ });
301
+ const exitOffset = isBottom ? 200 : -200;
302
+ translationY.value = withTiming(translationY.value + exitOffset, {
303
+ duration: ExitDuration,
304
+ easing: EASING,
305
+ });
306
+ scheduleOnRN(dismissToast);
307
+ } else {
308
+ translationY.value = withTiming(0, {
309
+ duration: 650,
310
+ easing: EASING,
311
+ });
312
+ }
313
+ }),
314
+ [isBottom, dismissToast, progress, translationY, shouldDismiss, isBeingDragged]
315
+ );
316
+
317
+ // Memoize disabled gesture to avoid recreation on every render
318
+ const disabledGesture = useMemo(() => Gesture.Pan().enabled(false), []);
319
+
320
+ // Derive zIndex separately - it's not animatable and shouldn't trigger worklet re-runs
321
+ const zIndex = useDerivedValue(() => 1000 - Math.round(stackIndex.value));
272
322
 
273
323
  const animatedStyle = useAnimatedStyle(() => {
274
324
  const baseTranslateY = interpolate(progress.value, [0, 1], [entryFromY, ToY]);
@@ -293,7 +343,7 @@ const ToastItem = ({ toast, index, theme, position }: ToastItemProps) => {
293
343
  return {
294
344
  transform: [{ translateY: finalTranslateY }, { scale }],
295
345
  opacity,
296
- zIndex: 1000 - stackIndex.value,
346
+ zIndex: zIndex.value,
297
347
  };
298
348
  });
299
349
 
@@ -311,53 +361,52 @@ const ToastItem = ({ toast, index, theme, position }: ToastItemProps) => {
311
361
  const shouldShowCloseButton = toast.type !== "loading" && (options?.showCloseButton ?? theme.showCloseButton);
312
362
 
313
363
  // Enable/disable gesture based on dismissible setting
314
- const gesture = isDismissible ? panGesture : Gesture.Pan().enabled(false);
364
+ const gesture = isDismissible ? panGesture : disabledGesture;
365
+
366
+ const animStyle = [
367
+ styles.toast,
368
+ verticalAnchor,
369
+ { backgroundColor },
370
+ theme.toastStyle,
371
+ options?.style,
372
+ animatedStyle,
373
+ ];
315
374
 
316
375
  return (
317
376
  <GestureDetector gesture={gesture}>
318
- <Animated.View
319
- style={[styles.toast, verticalAnchor, { backgroundColor }, theme.toastStyle, options?.style, animatedStyle]}
320
- >
321
- <View style={styles.content}>
322
- <View style={styles.icon}>
323
- <AnimatedIcon
324
- key={toast.type}
325
- type={toast.type}
326
- accentColor={accentColor}
327
- customIcon={customIcon}
328
- configIcon={configIcon}
329
- />
330
- </View>
331
- <View style={styles.textContainer}>
332
- <Animated.Text
333
- maxFontSizeMultiplier={1.35}
334
- allowFontScaling={false}
335
- style={[styles.title, theme.titleStyle, options?.titleStyle, titleColorStyle]}
336
- >
337
- {toast.title}
338
- </Animated.Text>
339
- {toast.description && (
340
- <Text
341
- allowFontScaling={false}
342
- maxFontSizeMultiplier={1.35}
343
- style={[styles.description, theme.descriptionStyle, options?.descriptionStyle]}
344
- >
345
- {toast.description}
346
- </Text>
347
- )}
348
- </View>
349
- {shouldShowCloseButton && (
350
- <Pressable style={styles.closeButton} onPress={dismissToast} hitSlop={12}>
351
- <CloseIcon width={20} height={20} />
352
- </Pressable>
353
- )}
354
- </View>
377
+ <Animated.View style={animStyle}>
378
+ <ToastContent
379
+ type={toast.type}
380
+ title={toast.title}
381
+ description={toast.description}
382
+ accentColor={accentColor}
383
+ customIcon={customIcon}
384
+ configIcon={configIcon}
385
+ showCloseButton={shouldShowCloseButton}
386
+ onDismiss={dismissToast}
387
+ titleStyle={theme.titleStyle}
388
+ descriptionStyle={theme.descriptionStyle}
389
+ optionsTitleStyle={options?.titleStyle}
390
+ optionsDescriptionStyle={options?.descriptionStyle}
391
+ />
355
392
  </Animated.View>
356
393
  </GestureDetector>
357
394
  );
358
395
  };
359
396
 
360
- const MemoizedToastItem = memo(ToastItem);
397
+ // Custom comparison to prevent re-renders when toast object reference changes but content is same
398
+ const MemoizedToastItem = memo(ToastItem, (prev, next) => {
399
+ return (
400
+ prev.toast.id === next.toast.id &&
401
+ prev.toast.type === next.toast.type &&
402
+ prev.toast.title === next.toast.title &&
403
+ prev.toast.description === next.toast.description &&
404
+ prev.toast.isExiting === next.toast.isExiting &&
405
+ prev.index === next.index &&
406
+ prev.position === next.position &&
407
+ prev.theme === next.theme
408
+ );
409
+ });
361
410
 
362
411
  const styles = StyleSheet.create({
363
412
  container: {