diff --git a/.gitignore b/.gitignore index 453d0e1d0..2a4cd9736 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ node_modules .idea coverage lib +.yarn +.yarnrc.yml diff --git a/src/Toast.tsx b/src/Toast.tsx index a151a550d..38fa98c2e 100644 --- a/src/Toast.tsx +++ b/src/Toast.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { LoggerProvider } from './contexts'; +import { LoggerProvider, GestureProvider } from './contexts'; import { ToastUI } from './ToastUI'; import { ToastHideParams, @@ -88,7 +88,9 @@ export function Toast(props: ToastProps) { return ( - + + + ); } diff --git a/src/__helpers__/PanResponder.ts b/src/__helpers__/PanResponder.ts index fcb6b68d1..a9b7d918b 100644 --- a/src/__helpers__/PanResponder.ts +++ b/src/__helpers__/PanResponder.ts @@ -24,12 +24,16 @@ export function mockPanResponder() { .spyOn(PanResponder, 'create') .mockImplementation( ({ + onStartShouldSetPanResponder, + onPanResponderGrant, onMoveShouldSetPanResponder, onMoveShouldSetPanResponderCapture, onPanResponderMove, onPanResponderRelease }: PanResponderCallbacks) => ({ panHandlers: { + onStartShouldSetResponder: onStartShouldSetPanResponder, + onResponderGrant: onPanResponderGrant, onMoveShouldSetResponder: onMoveShouldSetPanResponder, onMoveShouldSetResponderCapture: onMoveShouldSetPanResponderCapture, onResponderMove: onPanResponderMove, diff --git a/src/__tests__/Toast.test.tsx b/src/__tests__/Toast.test.tsx index f91b1dd62..9113a105d 100644 --- a/src/__tests__/Toast.test.tsx +++ b/src/__tests__/Toast.test.tsx @@ -7,14 +7,14 @@ import { Button, Modal, Text } from 'react-native'; import { Toast } from '../Toast'; /* - The Modal component is automatically mocked by RN and apparently contains a bug which makes the Modal + The Modal component is automatically mocked by RN and apparently contains a bug which makes the Modal (and its children) to always be visible in the test tree. This fixes the issue: */ jest.mock('react-native/Libraries/Modal/Modal', () => { const ActualModal = jest.requireActual('react-native/Libraries/Modal/Modal'); - return (props) => ; + return (props: any) => ; }); jest.mock('react-native/Libraries/LogBox/LogBox'); @@ -84,7 +84,7 @@ describe('test Toast component', () => { // Show the Modal const showModalButton = utils.queryByText('Show modal'); expect(showModalButton).toBeTruthy(); - fireEvent.press(showModalButton); + fireEvent.press(showModalButton as any); await waitFor(() => { expect(utils.queryByText('Inside modal')).toBeTruthy(); }); @@ -104,7 +104,7 @@ describe('test Toast component', () => { // Hide modal const hideModalButton = utils.queryByText('Hide modal'); expect(hideModalButton).toBeTruthy(); - fireEvent.press(hideModalButton); + fireEvent.press(hideModalButton as any); await waitFor(() => { expect(utils.queryByText('Inside modal')).toBeFalsy(); }); diff --git a/src/__tests__/useToast.test.ts b/src/__tests__/useToast.test.tsx similarity index 81% rename from src/__tests__/useToast.test.ts rename to src/__tests__/useToast.test.tsx index 4227189b3..5f47270af 100644 --- a/src/__tests__/useToast.test.ts +++ b/src/__tests__/useToast.test.tsx @@ -1,21 +1,30 @@ /* eslint-env jest */ import { act, renderHook } from '@testing-library/react-hooks'; +import React from 'react'; import { ToastOptions } from '../types'; import { DEFAULT_DATA, DEFAULT_OPTIONS, useToast } from '../useToast'; +import { GestureProvider } from '../contexts'; -const setup = () => { +const setupGestureWrapper = (panning: boolean) => { + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +}; + +const setup = (panning = false) => { + const wrapper = setupGestureWrapper(panning) const utils = renderHook(() => - useToast({ - defaultOptions: DEFAULT_OPTIONS - }) + useToast({ defaultOptions: DEFAULT_OPTIONS }), + { wrapper } ); return { ...utils }; }; + describe('test useToast hook', () => { it('returns defaults', () => { const { result } = setup(); @@ -167,6 +176,29 @@ describe('test useToast hook', () => { expect(onHide).toHaveBeenCalled(); }); + it('does not hide when autoHide is true but user is panning', () => { + jest.useFakeTimers(); + const { result } = setup(true); + const onHide = jest.fn(); + act(() => { + result.current.show({ + text1: 'test', + autoHide: true, + onHide + }); + }); + + expect(result.current.isVisible).toBe(true); + + act(() => { + jest.runAllTimers(); + }); + + expect(result.current.isVisible).toBe(true); + expect(onHide).not.toHaveBeenCalled(); + }); + + it('shows using only text2', () => { const { result } = setup(); diff --git a/src/components/AnimatedContainer.tsx b/src/components/AnimatedContainer.tsx index e12f2c415..3f7bac4a0 100644 --- a/src/components/AnimatedContainer.tsx +++ b/src/components/AnimatedContainer.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Animated, Dimensions, PanResponderGestureState } from 'react-native'; -import { useLogger } from '../contexts'; +import { useLogger, useGesture } from '../contexts'; import { usePanResponder, useSlideAnimation, @@ -81,6 +81,7 @@ export function AnimatedContainer({ swipeable }: AnimatedContainerProps) { const { log } = useLogger(); + const { panning } = useGesture(); const { computeViewDimensions, height } = useViewDimensions(); @@ -93,6 +94,18 @@ export function AnimatedContainer({ avoidKeyboard }); + const disable = !swipeable || !isVisible; + + const onStart = React.useCallback(() => { + log('Swipe, pan start'); + panning.current = true; + }, [log, panning]); + + const onEnd = React.useCallback(() => { + log('Swipe, pan end'); + panning.current = false; + }, [log, panning]); + const onDismiss = React.useCallback(() => { log('Swipe, dismissing'); animate(0); @@ -118,7 +131,9 @@ export function AnimatedContainer({ computeNewAnimatedValueForGesture, onDismiss, onRestore, - disable: !swipeable + onStart, + onEnd, + disable, }); React.useLayoutEffect(() => { @@ -133,7 +148,7 @@ export function AnimatedContainer({ style={[styles.base, styles[position], animationStyles]} // This container View is never the target of touch events but its subviews can be. // By doing this, tapping buttons behind the Toast is allowed - pointerEvents={isVisible ? 'box-none' : 'none'} + pointerEvents='box-none' {...panResponder.panHandlers}> {children} diff --git a/src/components/__tests__/AnimatedContainer.test.tsx b/src/components/__tests__/AnimatedContainer.test.tsx index 5bca48dc1..e956682ee 100644 --- a/src/components/__tests__/AnimatedContainer.test.tsx +++ b/src/components/__tests__/AnimatedContainer.test.tsx @@ -30,6 +30,7 @@ const setup = (props?: Omit, 'children'>) => { topOffset: 40, bottomOffset: 40, keyboardOffset: 10, + avoidKeyboard: true, onHide }; @@ -95,6 +96,7 @@ describe('test AnimatedContainer component', () => { moveY: 100, dy: 10 }; + panHandler?.props.onResponderGrant(); panHandler?.props.onResponderMove(undefined, gesture); panHandler?.props.onResponderRelease(undefined, gesture); expect(onRestorePosition).toHaveBeenCalled(); @@ -119,6 +121,7 @@ describe('test AnimatedContainer component', () => { moveY: 5, dy: -78 }; + panHandler?.props.onResponderGrant(); panHandler?.props.onResponderMove(undefined, gesture); panHandler?.props.onResponderRelease(undefined, gesture); expect(onHide).toHaveBeenCalled(); diff --git a/src/contexts/GestureContext.tsx b/src/contexts/GestureContext.tsx new file mode 100644 index 000000000..0172dad81 --- /dev/null +++ b/src/contexts/GestureContext.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +import { ReactChildren } from '../types'; + +export type GestureContextType = { + panning: React.MutableRefObject; +}; + +export type GestureProviderProps = { + children: ReactChildren; + panning?: boolean; +}; + +const GestureContext = React.createContext({ + panning: { current: false } +}); + +function GestureProvider({ children, panning = false }: GestureProviderProps) { + const panningRef = React.useRef(panning); + const value = { panning: panningRef }; + return ( + {children} + ); +} + +function useGesture() { + const ctx = React.useContext(GestureContext); + return ctx; +} + +export { GestureProvider, useGesture }; diff --git a/src/contexts/__tests__/GestureContext.test.tsx b/src/contexts/__tests__/GestureContext.test.tsx new file mode 100644 index 000000000..5d9630749 --- /dev/null +++ b/src/contexts/__tests__/GestureContext.test.tsx @@ -0,0 +1,32 @@ +/* eslint-env jest */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import React from 'react'; + +import { ReactChildren } from '../../types'; +import { GestureProvider, useGesture } from '../GestureContext'; +import { GestureProviderProps } from '..'; + +const setup = (props?: Omit) => { + const wrapper = ({ children }: { children: ReactChildren }) => ( + {children} + ); + const utils = renderHook(() => useGesture(), { wrapper }); + return { ...utils }; +}; + +describe('GestureContext', () => { + it('provides a panning ref with current defaulting to false', () => { + const { result } = setup(); + expect(result.current.panning).toBeDefined(); + expect(result.current.panning.current).toBe(false); + }); + + it('allows updating the panning ref value', () => { + const { result } = setup(); + act(() => { + result.current.panning.current = true + }); + expect(result.current.panning.current).toBe(true); + }); +}); diff --git a/src/contexts/__tests__/LoggerContext.test.tsx b/src/contexts/__tests__/LoggerContext.test.tsx index 5170f9c65..4cef88b71 100644 --- a/src/contexts/__tests__/LoggerContext.test.tsx +++ b/src/contexts/__tests__/LoggerContext.test.tsx @@ -11,12 +11,8 @@ const setup = (props?: Omit) => { const wrapper = ({ children }: { children: ReactChildren }) => ( {children} ); - const utils = renderHook(useLogger, { - wrapper - }); - return { - ...utils - }; + const utils = renderHook(useLogger, { wrapper }); + return { ...utils }; }; describe('test Logger context', () => { diff --git a/src/contexts/index.ts b/src/contexts/index.ts index 0b66bf77c..115567318 100644 --- a/src/contexts/index.ts +++ b/src/contexts/index.ts @@ -1 +1,2 @@ export * from './LoggerContext'; +export * from './GestureContext'; diff --git a/src/hooks/__tests__/usePanResponder.test.ts b/src/hooks/__tests__/usePanResponder.test.ts index 6ed6ddddc..169651d46 100644 --- a/src/hooks/__tests__/usePanResponder.test.ts +++ b/src/hooks/__tests__/usePanResponder.test.ts @@ -5,7 +5,7 @@ import { Animated, GestureResponderEvent } from 'react-native'; import { mockGestureValues } from '../../__helpers__/PanResponder'; import { usePanResponder } from '../usePanResponder'; -import { shouldSetPanResponder } from '..'; +import { moveShouldSetPanResponder, startShouldSetPanResponder } from '..'; const setup = ({ newAnimatedValueForGesture = 0, disable = false } = {}) => { const animatedValue = { @@ -16,6 +16,8 @@ const setup = ({ newAnimatedValueForGesture = 0, disable = false } = {}) => { ); const onDismiss = jest.fn(); const onRestore = jest.fn(); + const onStart = jest.fn(); + const onEnd = jest.fn(); const utils = renderHook(() => usePanResponder({ @@ -23,6 +25,8 @@ const setup = ({ newAnimatedValueForGesture = 0, disable = false } = {}) => { computeNewAnimatedValueForGesture, onDismiss, onRestore, + onStart, + onEnd, disable }) ); @@ -39,6 +43,7 @@ describe('test usePanResponder hook', () => { it('returns defaults', () => { const { result } = setup(); expect(result.current.panResponder.panHandlers).toBeDefined(); + expect(result.current.onGrant).toBeDefined(); expect(result.current.onMove).toBeDefined(); expect(result.current.onRelease).toBeDefined(); }); @@ -47,6 +52,7 @@ describe('test usePanResponder hook', () => { const { result, computeNewAnimatedValueForGesture } = setup({ newAnimatedValueForGesture: 1 }); + result.current.onGrant(); result.current.onMove({} as GestureResponderEvent, mockGestureValues); expect(computeNewAnimatedValueForGesture).toBeCalledWith(mockGestureValues); }); @@ -65,6 +71,7 @@ describe('test usePanResponder hook', () => { disable: true }); + result.current.onGrant(); result.current.onMove({} as GestureResponderEvent, mockGestureValues); expect(computeNewAnimatedValueForGesture).not.toBeCalledWith( mockGestureValues @@ -110,13 +117,17 @@ describe('test usePanResponder hook', () => { }); describe('test shouldSetPanResponder function', () => { + it('is set pan start always true', () => { + expect(startShouldSetPanResponder()).toBe(true); + }); + it('is set when dx > offset', () => { const gesture = { ...mockGestureValues, dx: 2.1, dy: 0 }; - expect(shouldSetPanResponder({} as GestureResponderEvent, gesture)).toBe( + expect(moveShouldSetPanResponder({} as GestureResponderEvent, gesture)).toBe( true ); }); @@ -127,7 +138,7 @@ describe('test shouldSetPanResponder function', () => { dx: 0, dy: 2.1 }; - expect(shouldSetPanResponder({} as GestureResponderEvent, gesture)).toBe( + expect(moveShouldSetPanResponder({} as GestureResponderEvent, gesture)).toBe( true ); }); @@ -138,7 +149,7 @@ describe('test shouldSetPanResponder function', () => { dx: 2, dy: 0 }; - expect(shouldSetPanResponder({} as GestureResponderEvent, gesture)).toBe( + expect(moveShouldSetPanResponder({} as GestureResponderEvent, gesture)).toBe( false ); }); @@ -149,7 +160,7 @@ describe('test shouldSetPanResponder function', () => { dx: 0, dy: 2 }; - expect(shouldSetPanResponder({} as GestureResponderEvent, gesture)).toBe( + expect(moveShouldSetPanResponder({} as GestureResponderEvent, gesture)).toBe( false ); }); diff --git a/src/hooks/__tests__/useViewDimensions.test.ts b/src/hooks/__tests__/useViewDimensions.test.ts index b08ba3afb..953164ce8 100644 --- a/src/hooks/__tests__/useViewDimensions.test.ts +++ b/src/hooks/__tests__/useViewDimensions.test.ts @@ -7,7 +7,7 @@ import { act } from 'react-test-renderer'; import { useViewDimensions } from '../useViewDimensions'; import { UseViewDimensionsParams } from '..'; -const setup = (offsets: UseViewDimensionsParams) => { +const setup = (offsets?: UseViewDimensionsParams) => { const layoutChangeEventMock = { nativeEvent: { layout: { diff --git a/src/hooks/usePanResponder.ts b/src/hooks/usePanResponder.ts index 3e0260a14..7fdc11838 100644 --- a/src/hooks/usePanResponder.ts +++ b/src/hooks/usePanResponder.ts @@ -6,7 +6,11 @@ import { PanResponderGestureState } from 'react-native'; -export function shouldSetPanResponder( +export function startShouldSetPanResponder() { + return true; +} + +export function moveShouldSetPanResponder( _event: GestureResponderEvent, gesture: PanResponderGestureState ) { @@ -36,6 +40,8 @@ export type UsePanResponderParams = { ) => number; onDismiss: () => void; onRestore: () => void; + onStart: () => void; + onEnd: () => void; disable?: boolean; }; @@ -44,15 +50,23 @@ export function usePanResponder({ computeNewAnimatedValueForGesture, onDismiss, onRestore, + onStart, + onEnd, disable }: UsePanResponderParams) { + const onGrant = React.useCallback(() => { + if (disable) return; + onStart(); + }, + [onStart, disable] + ); + const onMove = React.useCallback( (_event: GestureResponderEvent, gesture: PanResponderGestureState) => { - if (disable) { - return; - } + if (disable) return; const newAnimatedValue = computeNewAnimatedValueForGesture(gesture); + animatedValue.current?.setValue(newAnimatedValue); }, [animatedValue, computeNewAnimatedValueForGesture, disable] @@ -60,34 +74,36 @@ export function usePanResponder({ const onRelease = React.useCallback( (_event: GestureResponderEvent, gesture: PanResponderGestureState) => { - if (disable) { - return; - } + if (disable) return; const newAnimatedValue = computeNewAnimatedValueForGesture(gesture); + onEnd(); if (shouldDismissView(newAnimatedValue, gesture)) { onDismiss(); } else { onRestore(); } }, - [computeNewAnimatedValueForGesture, onDismiss, onRestore, disable] + [computeNewAnimatedValueForGesture, onEnd, onDismiss, onRestore, disable] ); const panResponder = React.useMemo( () => PanResponder.create({ - onMoveShouldSetPanResponder: shouldSetPanResponder, - onMoveShouldSetPanResponderCapture: shouldSetPanResponder, + onStartShouldSetPanResponder: startShouldSetPanResponder, + onPanResponderGrant: onGrant, + onMoveShouldSetPanResponder: moveShouldSetPanResponder, + onMoveShouldSetPanResponderCapture: moveShouldSetPanResponder, onPanResponderMove: onMove, onPanResponderRelease: onRelease }), - [onMove, onRelease] + [onMove, onRelease, onGrant] ); return { panResponder, + onGrant, onMove, - onRelease + onRelease, }; } diff --git a/src/useToast.ts b/src/useToast.ts index f738a33c8..99a239f68 100644 --- a/src/useToast.ts +++ b/src/useToast.ts @@ -1,6 +1,6 @@ import React from 'react'; -import { useLogger } from './contexts'; +import { useLogger, useGesture } from './contexts'; import { useTimeout } from './hooks'; import { ToastData, ToastOptions, ToastProps, ToastShowParams } from './types'; import { noop } from './utils/func'; @@ -35,6 +35,7 @@ export type UseToastParams = { export function useToast({ defaultOptions }: UseToastParams) { const { log } = useLogger(); + const { panning } = useGesture(); const [isVisible, setIsVisible] = React.useState(false); const [data, setData] = React.useState(DEFAULT_DATA); @@ -47,10 +48,14 @@ export function useToast({ defaultOptions }: UseToastParams) { React.useState>(initialOptions); const onAutoHide = React.useCallback(() => { - log('Auto hiding'); - setIsVisible(false); - options.onHide(); - }, [log, options]); + if (panning.current) { + log('Auto hiding was blocked due to panning'); + } else { + log('Auto hiding'); + setIsVisible(false); + options.onHide(); + } + }, [log, options, panning]); const { startTimer, clearTimer } = useTimeout( onAutoHide, options.visibilityTime