From 01e9f54904a6d57757e00cb78b1a951af07c8e9a Mon Sep 17 00:00:00 2001 From: Nick DeBaise Date: Mon, 10 Mar 2025 11:12:59 -0400 Subject: [PATCH 1/4] fix: fix up code, add type export to fix strict JS compilers --- src/components/Toast.tsx | 27 ++++++++++++++------------- src/components/Toasts.tsx | 2 +- src/index.tsx | 2 +- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx index 8db6291..5cc36e2 100644 --- a/src/components/Toast.tsx +++ b/src/components/Toast.tsx @@ -74,20 +74,7 @@ export const Toast: FC = ({ }) => { const insets = useSafeAreaInsets(); const { width, height } = useWindowDimensions(); - - useVisibilityChange( - () => { - onToastShow?.(toast); - }, - () => { - onToastHide?.(toast); - }, - toast.visible - ); - const isSystemDarkMode = useColorScheme() === 'dark'; - const isDarkMode = - overrideDarkMode !== undefined ? overrideDarkMode : isSystemDarkMode; const [toastHeight, setToastHeight] = useState( toast?.height ? toast.height : DEFAULT_TOAST_HEIGHT @@ -96,6 +83,9 @@ export const Toast: FC = ({ toast?.width ? toast.width : width - 32 > 360 ? 360 : width - 32 ); + const isDarkMode = + overrideDarkMode !== undefined ? overrideDarkMode : isSystemDarkMode; + const startingY = useMemo( () => toast.position === ToastPosition.TOP @@ -109,6 +99,7 @@ export const Toast: FC = ({ const offsetY = useSharedValue(startingY); const onPress = () => onToastPress?.(toast); + const dismiss = useCallback((id: string) => { toasting.dismiss(id); }, []); @@ -213,6 +204,16 @@ export const Toast: FC = ({ toast.animationConfig, ]); + useVisibilityChange( + () => { + onToastShow?.(toast); + }, + () => { + onToastHide?.(toast); + }, + toast.visible + ); + useEffect(() => { setToastHeight(toast?.height ? toast.height : DEFAULT_TOAST_HEIGHT); }, [toast.height]); diff --git a/src/components/Toasts.tsx b/src/components/Toasts.tsx index 09e1bda..44a68cf 100644 --- a/src/components/Toasts.tsx +++ b/src/components/Toasts.tsx @@ -60,8 +60,8 @@ export const Toasts: FunctionComponent = ({ left: insets.left + (extraInsets?.left ?? 0), right: insets.right + (extraInsets?.right ?? 0), bottom: insets.bottom + (extraInsets?.bottom ?? 0) + 16, + pointerEvents: 'box-none', }} - pointerEvents={'box-none'} > {toasts.map((t) => ( Date: Wed, 23 Apr 2025 11:09:14 -0400 Subject: [PATCH 2/4] feat: add more positions --- README.md | 2 +- example/src/App.tsx | 13 ++-- src/components/Toast.tsx | 100 +++++++++++++++++++------ src/components/Toasts.tsx | 32 +++++++- src/core/types.ts | 4 + website/docs/api/toast.md | 32 ++++---- website/docs/features/customization.md | 4 + 7 files changed, 135 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index a154eb7..5ec0ed6 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ I know what you might be thinking (*jeez, another toast library?*). Trust me her - **Multiple toasts, multiple options**. Want a toast on the top, bottom, different colors, or different types at the same time? Got it. - **Keyboard handling** (both iOS and Android). Move those toasts out of the way and into view when the user opens the keyboard - **Swipe to dismiss** -- **Positional toasts** (top & bottom) +- **Positional toasts** (top, bottom, top-left, top-right, bottom-left, bottom-right) - **Customizable** (custom styles, dimensions, duration, and even create your own component to be used in the toast) - Add support for **promises** <-- Really! Call `toast.promise(my_promise)` and watch react-native-toast work its magic, automatically updating the toast with a custom message on success -- or an error message on reject. - Runs on **web** diff --git a/example/src/App.tsx b/example/src/App.tsx index 9a95c1b..5ebee94 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -13,6 +13,12 @@ import { View, } from 'react-native'; import Modal from 'react-native-modal'; +import { Toasts } from '../../src/components/Toasts'; +import { toast } from '../../src/headless'; +import { ToastPosition } from '../../src/core/types'; +import { colors } from '../../src/utils'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; const LoadingMessage = ({ msg }: { msg: string }) => { const isDarkMode = useColorScheme() === 'dark'; @@ -31,13 +37,6 @@ const LoadingMessage = ({ msg }: { msg: string }) => { ); }; -import { Toasts } from '../../src/components/Toasts'; -import { toast } from '../../src/headless'; -import { ToastPosition } from '../../src/core/types'; -import { colors } from '../../src/utils'; -import { GestureHandlerRootView } from 'react-native-gesture-handler'; -import { SafeAreaProvider } from 'react-native-safe-area-context'; - export default function App() { const { width: screenWidth } = useWindowDimensions(); const isSystemDarkMode = useColorScheme() === 'dark'; diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx index 5cc36e2..4fe4be3 100644 --- a/src/components/Toast.tsx +++ b/src/components/Toast.tsx @@ -86,17 +86,53 @@ export const Toast: FC = ({ const isDarkMode = overrideDarkMode !== undefined ? overrideDarkMode : isSystemDarkMode; - const startingY = useMemo( - () => - toast.position === ToastPosition.TOP - ? -(toast.height || DEFAULT_TOAST_HEIGHT) - insets.top - 50 - : height - insets.bottom - Platform.select({ ios: 0, default: 32 }), - [height, toast.position, insets.bottom, insets.top, toast.height] - ); + const getStartingPosition = useMemo(() => { + let leftPosition = (width - toastWidth) / 2; // Default to center + + if ( + toast.position === ToastPosition.TOP_LEFT || + toast.position === ToastPosition.BOTTOM_LEFT + ) { + leftPosition = insets.left + 16 + (extraInsets?.left ?? 0); + } + + if ( + toast.position === ToastPosition.TOP_RIGHT || + toast.position === ToastPosition.BOTTOM_RIGHT + ) { + leftPosition = + width - toastWidth - insets.right - 16 - (extraInsets?.right ?? 0); + } + + let startY = 0; + + if ( + toast.position === ToastPosition.TOP || + toast.position === ToastPosition.TOP_LEFT || + toast.position === ToastPosition.TOP_RIGHT + ) { + startY = -(toast.height || DEFAULT_TOAST_HEIGHT) - insets.top - 50; + } else { + startY = + height - insets.bottom - Platform.select({ ios: 16, default: 32 }); + } + + return { startY, leftPosition }; + }, [ + height, + width, + toastWidth, + toast.position, + toast.height, + insets, + extraInsets, + ]); + + const { startY, leftPosition } = getStartingPosition; const opacity = useSharedValue(0); - const position = useSharedValue(startingY); - const offsetY = useSharedValue(startingY); + const position = useSharedValue(startY); + const offsetY = useSharedValue(startY); const onPress = () => onToastPress?.(toast); @@ -104,6 +140,18 @@ export const Toast: FC = ({ toasting.dismiss(id); }, []); + const getSwipeDirection = useCallback(() => { + if ( + toast.position === ToastPosition.TOP || + toast.position === ToastPosition.TOP_LEFT || + toast.position === ToastPosition.TOP_RIGHT + ) { + return Directions.UP; + } else { + return Directions.DOWN; + } + }, [toast.position]); + const setPosition = useCallback(() => { let timingConfig: WithTimingConfig = { duration: 300 }; let springConfig: WithSpringConfig = { stiffness: 80 }; @@ -120,29 +168,35 @@ export const Toast: FC = ({ } const useSpringAnimation = toast.animationType === 'spring'; - const animation = useSpringAnimation ? withSpring : withTiming; - if (toast.position === ToastPosition.TOP) { + if ( + toast.position === ToastPosition.TOP || + toast.position === ToastPosition.TOP_LEFT || + toast.position === ToastPosition.TOP_RIGHT + ) { offsetY.value = animation( - toast.visible ? offset : startingY, + toast.visible ? offset : startY, useSpringAnimation ? springConfig : timingConfig ); position.value = animation( - toast.visible ? offset : startingY, + toast.visible ? offset : startY, useSpringAnimation ? springConfig : timingConfig ); } else { let kbHeight = keyboardVisible ? keyboardHeight : 0; const val = toast.visible - ? startingY - + ? startY - toastHeight - offset - kbHeight - insets.bottom - (extraInsets?.bottom ?? 0) - - 24 - : startingY; + Platform.select({ + ios: 32, + default: 24, + }) + : startY; offsetY.value = animation( val, @@ -161,7 +215,7 @@ export const Toast: FC = ({ toastHeight, insets.bottom, position, - startingY, + startY, toast.position, offsetY, extraInsets, @@ -179,11 +233,9 @@ export const Toast: FC = ({ }); const flingGesture = Gesture.Fling() - .direction( - toast.position === ToastPosition.TOP ? Directions.UP : Directions.DOWN - ) + .direction(getSwipeDirection()) .onEnd(() => { - offsetY.value = withTiming(startingY, { + offsetY.value = withTiming(startY, { duration: toast?.animationConfig?.flingPositionReturnDuration ?? 40, }); runOnJS(dismiss)(toast.id); @@ -194,14 +246,14 @@ export const Toast: FC = ({ : panGesture; }, [ offsetY, - startingY, + startY, position, setPosition, - toast.position, toast.id, dismiss, toast.isSwipeable, toast.animationConfig, + getSwipeDirection, ]); useVisibilityChange( @@ -271,7 +323,7 @@ export const Toast: FC = ({ : undefined, borderRadius: 8, position: 'absolute', - left: (width - toastWidth) / 2, + left: leftPosition, zIndex: toast.visible ? 9999 : undefined, alignItems: 'center', justifyContent: 'center', diff --git a/src/components/Toasts.tsx b/src/components/Toasts.tsx index 44a68cf..8e64440 100644 --- a/src/components/Toasts.tsx +++ b/src/components/Toasts.tsx @@ -1,9 +1,18 @@ import React, { FunctionComponent } from 'react'; -import { TextStyle, View, ViewStyle } from 'react-native'; +import { + Platform, + TextStyle, + View, + ViewStyle, + useWindowDimensions, +} from 'react-native'; import { Toast as T, useToaster } from '../headless'; import { Toast } from './Toast'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { + useSafeAreaInsets, + useSafeAreaFrame, +} from 'react-native-safe-area-context'; import { ExtraInsets, ToastAnimationConfig, @@ -28,6 +37,7 @@ type Props = { }; globalAnimationType?: ToastAnimationType; globalAnimationConfig?: ToastAnimationConfig; + fixAndroidInsets?: boolean; }; export const Toasts: FunctionComponent = ({ @@ -41,13 +51,24 @@ export const Toasts: FunctionComponent = ({ defaultStyle, globalAnimationType, globalAnimationConfig, + fixAndroidInsets = true, }) => { const { toasts, handlers } = useToaster({ providerKey }); const { startPause, endPause } = handlers; const insets = useSafeAreaInsets(); + const safeAreaFrame = useSafeAreaFrame(); + const dimensions = useWindowDimensions(); const isScreenReaderEnabled = useScreenReader(); const { keyboardShown: keyboardVisible, keyboardHeight } = useKeyboard(); + // Fix for Android bottom inset bug: https://github.com/facebook/react-native/issues/47080 + const bugFixDelta = + fixAndroidInsets && + Platform.OS === 'android' && + Math.abs(safeAreaFrame.height - dimensions.height) > 1 + ? insets.bottom + : 0; + if (isScreenReaderEnabled && !preventScreenReaderFromHiding) { return null; } @@ -59,7 +80,7 @@ export const Toasts: FunctionComponent = ({ top: insets.top + (extraInsets?.top ?? 0) + 16, left: insets.left + (extraInsets?.left ?? 0), right: insets.right + (extraInsets?.right ?? 0), - bottom: insets.bottom + (extraInsets?.bottom ?? 0) + 16, + bottom: insets.bottom + bugFixDelta + (extraInsets?.bottom ?? 0) + 16, pointerEvents: 'box-none', }} > @@ -82,7 +103,10 @@ export const Toasts: FunctionComponent = ({ onToastHide={onToastHide} onToastPress={onToastPress} onToastShow={onToastShow} - extraInsets={extraInsets} + extraInsets={{ + ...extraInsets, + bottom: (extraInsets?.bottom ?? 0) + bugFixDelta, + }} defaultStyle={defaultStyle} keyboardVisible={keyboardVisible} keyboardHeight={keyboardHeight} diff --git a/src/core/types.ts b/src/core/types.ts index 5b3ae5a..ca52731 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -5,6 +5,10 @@ export type ToastType = 'success' | 'error' | 'loading' | 'blank'; export enum ToastPosition { TOP = 1, BOTTOM = 2, + TOP_LEFT = 3, + TOP_RIGHT = 4, + BOTTOM_LEFT = 5, + BOTTOM_RIGHT = 6, } export type Element = JSX.Element | string | null; diff --git a/website/docs/api/toast.md b/website/docs/api/toast.md index 1713ea9..b2b6398 100644 --- a/website/docs/api/toast.md +++ b/website/docs/api/toast.md @@ -313,21 +313,21 @@ where `AutoWidthStyles` holds the actual styles for auto width. ## All toast() Options -| Name | Type | Default | Description | -|-----------------|----------|-----------|----------------------------------------------------------------------------------------------| -| `duration` | number | 3000 | Duration in milliseconds. Set to `Infinity` to keep the toast open until dismissed manually. | -| `position` | enum | 1 | Position of the toast. Can be ToastPosition.TOP or ToastPosition.BOTTOM. | -| `id` | string | | Unique id for the toast. | -| `icon` | Element | | Icon to display on the left of the toast. | -| `animationType` | string | 'timing' | Animation type. Can be 'timing' or 'spring'. | -| `animationConfig`| object | | Animation configuration. | -| `customToast` | function | | Custom toast component. | -| `width` | number | | Width of the toast. | -| `height` | number | | Height of the toast. | -| `disableShadow` | boolean | false | Disable shadow on the toast. | -| `isSwipeable` | boolean | true | Disable/Enable swipe to dismiss the toast. | -| `providerKey` | string | 'DEFAULT' | Provider key for the toast. | -| `accessibilityMessage`| string | | Accessibility message for screen readers. | -| `styles` | object | | Styles for the toast. | +| Name | Type | Default | Description | +|-----------------|----------|-----------|-------------------------------------------------------------------------------------------------------------| +| `duration` | number | 3000 | Duration in milliseconds. Set to `Infinity` to keep the toast open until dismissed manually. | +| `position` | enum | 1 | Position of the toast. Can be `ToastPosition.{TOP, BOTTOM, TOP_LEFT, BOTTOM_LEFT, TOP_RIGHT, BOTTOM_RIGHT}` | +| `id` | string | | Unique id for the toast. | +| `icon` | Element | | Icon to display on the left of the toast. | +| `animationType` | string | 'timing' | Animation type. Can be 'timing' or 'spring'. | +| `animationConfig`| object | | Animation configuration. | +| `customToast` | function | | Custom toast component. | +| `width` | number | | Width of the toast. | +| `height` | number | | Height of the toast. | +| `disableShadow` | boolean | false | Disable shadow on the toast. | +| `isSwipeable` | boolean | true | Disable/Enable swipe to dismiss the toast. | +| `providerKey` | string | 'DEFAULT' | Provider key for the toast. | +| `accessibilityMessage`| string | | Accessibility message for screen readers. | +| `styles` | object | | Styles for the toast. | diff --git a/website/docs/features/customization.md b/website/docs/features/customization.md index d693815..6100d40 100644 --- a/website/docs/features/customization.md +++ b/website/docs/features/customization.md @@ -22,6 +22,10 @@ toast('Bottom Position', { position: ToastPosition.BOTTOM, }); +toast('Bottom Right Position', { + position: ToastPosition.BOTTOM_RIGHT, +}); + ``` ### Extra Insets From 09dc23d5bae027acf625b81a1123caa3e1a104e6 Mon Sep 17 00:00:00 2001 From: Nick DeBaise Date: Wed, 23 Apr 2025 11:23:44 -0400 Subject: [PATCH 3/4] fix: add docs for fixAndroidInsets --- website/docs/components/toasts.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/website/docs/components/toasts.md b/website/docs/components/toasts.md index 9974c2e..5f9dd18 100644 --- a/website/docs/components/toasts.md +++ b/website/docs/components/toasts.md @@ -133,6 +133,12 @@ useEffect(() => { }, [isModalVisible]); ``` +### fixAndroidInsets +`boolean` *Defaults to true* + +Fix for Android bottom inset bug: https://github.com/facebook/react-native/issues/47080 + + ## Example From d474c72d6ca45b775979e0443f0014333801aa89 Mon Sep 17 00:00:00 2001 From: Nick DeBaise Date: Wed, 23 Apr 2025 11:28:00 -0400 Subject: [PATCH 4/4] fix: fix failing test by adding mock --- __tests__/src/components/Toasts.test.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/__tests__/src/components/Toasts.test.tsx b/__tests__/src/components/Toasts.test.tsx index 90959d2..90f6b96 100644 --- a/__tests__/src/components/Toasts.test.tsx +++ b/__tests__/src/components/Toasts.test.tsx @@ -14,6 +14,12 @@ jest.mock('react-native-reanimated', () => { jest.mock('react-native-safe-area-context', () => { return { useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), + useSafeAreaFrame: () => ({ + x: 0, + y: 0, + width: 350, + height: 650, + }), }; });