Skip to content

Commit 142d433

Browse files
mkantautasm-bert
andauthored
Undo a cancelled ReanimatedDrawerLayout pan (#4635)
## Description `ReanimatedDrawerLayout` passes its pan's `onDeactivate` to `handleRelease`, which does not read `event.canceled`. When the pan is cancelled or fails after it activated (on web, for example, when the browser sends `pointercancel`), the drawer settles from the last translation and velocity as if the finger had lifted. A closed drawer dragged past half of its width opens and calls `onDrawerOpen`, and an open drawer dragged past half of its width closes and calls `onDrawerClose`. `handleRelease` now settles a cancelled release at the drawer's current open or closed state instead of projecting the drag. This is the cancellation handling #4618 added to `ReanimatedSwipeable`. The restore goes through `animateDrawer`, so `onDrawerStateChanged` still ends in `IDLE` and the callback for the resting position fires, as it does after a release that does not cross half of the width. ## Test plan - New Jest tests in `reanimatedDrawerLayoutRelease.test.tsx` drive the pan through `createGestureController`: a pan that ends past half of the width opens the drawer, and a cancelled or failed pan past half keeps a closed drawer closed and an open drawer open. The four cancel and fail cases fail on `main`. - Web, `apps/expo-example` "Reanimated Drawer Layout" screen in Chromium with touch input sent over CDP (`Input.dispatchTouchEvent`): | Touch sequence | `main` | This PR | | --- | --- | --- | | Drag from the edge past half, `touchCancel` | opens | stays closed | | Open the drawer, drag it past half towards closed, `touchCancel` | closes | stays open | | Drag from the edge past half, `touchEnd` | opens | opens | | Drag from the edge below half, `touchCancel` | stays closed | stays closed | - `yarn test` (24 suites, 238 tests), `yarn ts-check`, `yarn lint-js` and `yarn circular-dependency-check` pass in `packages/react-native-gesture-handler`. - Not run on Android or iOS. Co-authored-by: Michał Bert <63123542+m-bert@users.noreply.github.com>
1 parent f4038e9 commit 142d433

2 files changed

Lines changed: 215 additions & 1 deletion

File tree

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { act, render } from '@testing-library/react-native';
2+
import React from 'react';
3+
import { Text } from 'react-native';
4+
import type { SharedValue } from 'react-native-reanimated';
5+
6+
import GestureHandlerRootView from '../components/GestureHandlerRootView';
7+
import type { DrawerLayoutMethods } from '../components/ReanimatedDrawerLayout';
8+
import ReanimatedDrawerLayout, {
9+
DrawerState,
10+
} from '../components/ReanimatedDrawerLayout';
11+
import { createGestureController } from '../jestUtils';
12+
import type * as Gestures from '../v3/hooks/gestures';
13+
14+
let mockLastPanGesture: Gestures.PanGesture | undefined;
15+
16+
jest.mock('../v3/hooks/gestures', () => {
17+
const actual = jest.requireActual<typeof Gestures>('../v3/hooks/gestures');
18+
19+
return {
20+
...actual,
21+
usePanGesture: jest.fn(
22+
(...args: Parameters<typeof actual.usePanGesture>) => {
23+
mockLastPanGesture = actual.usePanGesture(...args);
24+
return mockLastPanGesture;
25+
}
26+
),
27+
};
28+
});
29+
30+
jest.mock('react-native-reanimated', () => {
31+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
32+
const ReactNative = jest.requireActual('react-native');
33+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
34+
const ReactActual = jest.requireActual('react');
35+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
36+
const AnimatedView: unknown = ReactNative.View;
37+
38+
// Piecewise linear, clamped at both ends.
39+
const interpolate = (value: number, input: number[], output: number[]) => {
40+
if (value <= input[0]) {
41+
return output[0];
42+
}
43+
44+
for (let i = 1; i < input.length; i++) {
45+
if (value <= input[i]) {
46+
const progress = (value - input[i - 1]) / (input[i] - input[i - 1]);
47+
return output[i - 1] + progress * (output[i] - output[i - 1]);
48+
}
49+
}
50+
51+
return output[output.length - 1];
52+
};
53+
54+
const runUpdater = (updater: () => unknown) => updater();
55+
const noEventHandler = () => undefined;
56+
57+
return {
58+
__esModule: true,
59+
default: {
60+
View: AnimatedView,
61+
createAnimatedComponent: (component: unknown) => component,
62+
},
63+
View: AnimatedView,
64+
createAnimatedComponent: (component: unknown) => component,
65+
Extrapolation: { CLAMP: 'clamp' },
66+
interpolate,
67+
isSharedValue: () => false,
68+
useAnimatedProps: runUpdater,
69+
useAnimatedStyle: runUpdater,
70+
useComposedEventHandler: noEventHandler,
71+
useDerivedValue: runUpdater,
72+
useSharedValue: (init: unknown) => {
73+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
74+
const ref = ReactActual.useRef({ value: init });
75+
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access
76+
return ref.current;
77+
},
78+
withSpring: jest.fn(
79+
(
80+
to: unknown,
81+
_config: unknown,
82+
callback?: (finished: boolean) => void
83+
) => {
84+
callback?.(true);
85+
return to;
86+
}
87+
),
88+
};
89+
});
90+
91+
jest.mock('react-native-worklets', () =>
92+
jest.requireActual<Record<string, unknown>>('react-native-worklets/src/mock')
93+
);
94+
95+
async function flushScheduledCallbacks() {
96+
await act(async () => {
97+
await new Promise<void>((resolve) => {
98+
setTimeout(resolve, 0);
99+
});
100+
});
101+
}
102+
103+
const DRAWER_WIDTH = 200;
104+
105+
describe('ReanimatedDrawerLayout pan release', () => {
106+
const callbacks = {
107+
onDrawerOpen: jest.fn(),
108+
onDrawerClose: jest.fn(),
109+
onDrawerStateChanged: jest.fn(),
110+
};
111+
112+
function renderDrawer() {
113+
const drawerRef = React.createRef<DrawerLayoutMethods>();
114+
let progress: SharedValue<number> | undefined;
115+
116+
render(
117+
<GestureHandlerRootView>
118+
<ReanimatedDrawerLayout
119+
ref={drawerRef}
120+
drawerWidth={DRAWER_WIDTH}
121+
renderNavigationView={(value) => {
122+
progress = value;
123+
return <Text>Drawer</Text>;
124+
}}
125+
{...callbacks}>
126+
<Text>Content</Text>
127+
</ReanimatedDrawerLayout>
128+
</GestureHandlerRootView>
129+
);
130+
131+
if (!mockLastPanGesture || !progress) {
132+
throw new Error('ReanimatedDrawerLayout did not render');
133+
}
134+
135+
return {
136+
pan: createGestureController(mockLastPanGesture),
137+
drawerRef,
138+
progress: () => progress?.value,
139+
};
140+
}
141+
142+
function clearCallbacks() {
143+
Object.values(callbacks).forEach((callback) => callback.mockClear());
144+
}
145+
146+
beforeEach(() => {
147+
jest.clearAllMocks();
148+
});
149+
150+
test('opens the drawer when the pan ends past half of its width', async () => {
151+
const { pan, progress } = renderDrawer();
152+
153+
pan.begin({ x: 10 });
154+
pan.activate({ x: 13, translationX: 3 });
155+
pan.update({ x: 160, translationX: 150 });
156+
pan.end({ x: 160, translationX: 150, velocityX: 0 });
157+
await flushScheduledCallbacks();
158+
159+
expect(callbacks.onDrawerOpen).toHaveBeenCalledTimes(1);
160+
expect(callbacks.onDrawerClose).not.toHaveBeenCalled();
161+
expect(progress()).toBe(1);
162+
});
163+
164+
test.each(['cancel', 'fail'] as const)(
165+
'keeps a closed drawer closed when the pan is %sed past half of its width',
166+
async (action) => {
167+
const { pan, progress } = renderDrawer();
168+
169+
pan.begin({ x: 10 });
170+
pan.activate({ x: 13, translationX: 3 });
171+
pan.update({ x: 160, translationX: 150 });
172+
pan[action]({ x: 160, translationX: 150, velocityX: 0 });
173+
await flushScheduledCallbacks();
174+
175+
expect(callbacks.onDrawerOpen).not.toHaveBeenCalled();
176+
expect(callbacks.onDrawerStateChanged).toHaveBeenLastCalledWith(
177+
DrawerState.IDLE,
178+
false
179+
);
180+
expect(progress()).toBe(0);
181+
}
182+
);
183+
184+
test.each(['cancel', 'fail'] as const)(
185+
'keeps an open drawer open when the pan is %sed past half of its width',
186+
async (action) => {
187+
const { pan, drawerRef, progress } = renderDrawer();
188+
189+
drawerRef.current?.openDrawer();
190+
await flushScheduledCallbacks();
191+
expect(progress()).toBe(1);
192+
clearCallbacks();
193+
194+
pan.begin({ x: 190 });
195+
pan.activate({ x: 187, translationX: -3 });
196+
pan.update({ x: 40, translationX: -150 });
197+
pan[action]({ x: 40, translationX: -150, velocityX: 0 });
198+
await flushScheduledCallbacks();
199+
200+
expect(callbacks.onDrawerClose).not.toHaveBeenCalled();
201+
expect(callbacks.onDrawerStateChanged).toHaveBeenLastCalledWith(
202+
DrawerState.IDLE,
203+
true
204+
);
205+
expect(progress()).toBe(1);
206+
}
207+
);
208+
});

‎packages/react-native-gesture-handler/src/components/ReanimatedDrawerLayout.tsx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -454,8 +454,14 @@ const DrawerLayout = function DrawerLayout(
454454
);
455455

456456
const handleRelease = useCallback(
457-
(event: PanGestureActiveEvent) => {
457+
(event: PanGestureActiveEvent & { canceled: boolean }) => {
458458
'worklet';
459+
if (event.canceled) {
460+
// Settle the drawer where it rested before the gesture.
461+
animateDrawer(isDrawerOpen.value ? drawerWidth : 0, 0);
462+
return;
463+
}
464+
459465
let { translationX: dragX, velocityX, x: touchX } = event;
460466

461467
if (drawerPosition !== DrawerPosition.LEFT) {

0 commit comments

Comments
 (0)