Skip to content

Commit 40ac206

Browse files
authored
Undo a cancelled ReanimatedSwipeable pan (#4618)
## Description A `ReanimatedSwipeable` row still opens and fires `onSwipeableOpen` when its pan is cancelled or fails past the threshold. `handleRelease` now springs a cancelled row back to its pre-gesture position without new callbacks. Review focus: the restore calls `animateRow` with a new `silent` flag. It keeps `rowState` and tap-to-close as they were, and it still dispatches the end events of an animation it interrupts (`pendingEndFromState`). Closes #4616 ## Test plan New Jest tests in `reanimatedSwipeableCallbacks.test.tsx` drive the pan through `createGestureController`. On Android (Pixel 8 Pro), I cancelled the pan by toggling `enabled` while I held the row past the threshold. I did this from a closed row and from an open row. The screenshots and the screencast are in [this comment](#4618 (comment)).
1 parent 0c12342 commit 40ac206

2 files changed

Lines changed: 213 additions & 11 deletions

File tree

‎packages/react-native-gesture-handler/src/__tests__/reanimatedSwipeableCallbacks.test.tsx‎

Lines changed: 176 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
import { render } from '@testing-library/react-native';
1+
import { fireEvent, render, screen } from '@testing-library/react-native';
22
import React from 'react';
3-
import { Text } from 'react-native';
3+
import type { StyleProp, ViewStyle } from 'react-native';
4+
import { StyleSheet, Text } from 'react-native';
5+
import { withSpring } from 'react-native-reanimated';
46
import { scheduleOnRN } from 'react-native-worklets';
57

68
import GestureHandlerRootView from '../components/GestureHandlerRootView';
@@ -9,7 +11,25 @@ import type {
911
SwipeableProps,
1012
} from '../components/ReanimatedSwipeable';
1113
import ReanimatedSwipeable from '../components/ReanimatedSwipeable';
14+
import { createGestureController } from '../jestUtils';
1215
import RNGestureHandlerModule from '../RNGestureHandlerModule';
16+
import type * as Gestures from '../v3/hooks/gestures';
17+
18+
let mockLastPanGesture: Gestures.PanGesture | undefined;
19+
20+
jest.mock('../v3/hooks/gestures', () => {
21+
const actual = jest.requireActual<typeof Gestures>('../v3/hooks/gestures');
22+
23+
return {
24+
...actual,
25+
usePanGesture: jest.fn(
26+
(...args: Parameters<typeof actual.usePanGesture>) => {
27+
mockLastPanGesture = actual.usePanGesture(...args);
28+
return mockLastPanGesture;
29+
}
30+
),
31+
};
32+
});
1333

1434
jest.mock('react-native-reanimated', () => {
1535
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
@@ -29,18 +49,27 @@ jest.mock('react-native-reanimated', () => {
2949
createAnimatedComponent: (component: unknown) => component,
3050
interpolate: (value: number) => value,
3151
isSharedValue: () => false,
32-
measure: () => null,
52+
measure: () => ({ pageX: 0 }),
3353
ReduceMotion: { System: 'system' },
3454
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
3555
useAnimatedRef: () => ReactActual.useRef(null),
36-
useAnimatedStyle: () => ({}),
56+
useAnimatedStyle: (updater: () => object) => updater(),
3757
useSharedValue: (init: unknown) => {
3858
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
3959
const ref = ReactActual.useRef({ value: init });
4060
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access
4161
return ref.current;
4262
},
43-
withSpring: (to: unknown) => to,
63+
withSpring: jest.fn(
64+
(
65+
to: unknown,
66+
_config: unknown,
67+
callback?: (finished: boolean) => void
68+
) => {
69+
callback?.(true);
70+
return to;
71+
}
72+
),
4473
};
4574
});
4675

@@ -193,3 +222,145 @@ describe('ReanimatedSwipeable callback identity', () => {
193222
expect(scheduleOnRN).not.toHaveBeenCalled();
194223
});
195224
});
225+
226+
const ROW_WIDTH = 300;
227+
228+
function getHostStyle(text: string) {
229+
let node = screen.getByText(text).parent;
230+
while (node && typeof node.type !== 'string') {
231+
node = node.parent;
232+
}
233+
return StyleSheet.flatten(node?.props.style as StyleProp<ViewStyle>) ?? {};
234+
}
235+
236+
describe('ReanimatedSwipeable release and action panes', () => {
237+
const callbacks = {
238+
onSwipeableOpen: jest.fn(),
239+
onSwipeableWillOpen: jest.fn(),
240+
onSwipeableClose: jest.fn(),
241+
onSwipeableWillClose: jest.fn(),
242+
};
243+
244+
function expectNoSwipeableCallbacks() {
245+
for (const callback of Object.values(callbacks)) {
246+
expect(callback).not.toHaveBeenCalled();
247+
}
248+
}
249+
250+
function renderRow() {
251+
const swipeableRef = React.createRef<SwipeableMethods>();
252+
const row = () => (
253+
<GestureHandlerRootView>
254+
<ReanimatedSwipeable
255+
ref={swipeableRef}
256+
renderLeftActions={() => <Text>Archive</Text>}
257+
renderRightActions={() => <Text>Delete</Text>}
258+
{...callbacks}>
259+
<Text style={{ backgroundColor: 'rgba(255, 255, 255, 0.35)' }}>
260+
Row
261+
</Text>
262+
</ReanimatedSwipeable>
263+
</GestureHandlerRootView>
264+
);
265+
const { rerender } = render(row());
266+
let container = screen.getByText('Row').parent;
267+
while (container && !container.props.onLayout) {
268+
container = container.parent;
269+
}
270+
if (!container || !mockLastPanGesture) {
271+
throw new Error('ReanimatedSwipeable did not render');
272+
}
273+
fireEvent(container, 'layout', {
274+
nativeEvent: { layout: { width: ROW_WIDTH } },
275+
});
276+
277+
return {
278+
pan: createGestureController(mockLastPanGesture),
279+
refreshStyles: () => rerender(row()),
280+
swipeableRef,
281+
};
282+
}
283+
284+
beforeEach(() => {
285+
jest.clearAllMocks();
286+
});
287+
288+
test('opens the row when the pan ends past the threshold', async () => {
289+
const { pan, refreshStyles } = renderRow();
290+
291+
pan.begin();
292+
pan.activate({ translationX: -20 });
293+
pan.update({ translationX: -200 });
294+
pan.end({ translationX: -200 });
295+
await flushNativeOps();
296+
refreshStyles();
297+
298+
expect(callbacks.onSwipeableOpen).toHaveBeenCalledTimes(1);
299+
expect(getHostStyle('Row').transform).toEqual([{ translateX: -ROW_WIDTH }]);
300+
});
301+
302+
test.each(['cancel', 'fail'] as const)(
303+
'keeps a closed row closed without callbacks when the pan is %sed past the threshold',
304+
async (action) => {
305+
const { pan, refreshStyles } = renderRow();
306+
307+
pan.begin();
308+
pan.activate({ translationX: -20 });
309+
pan.update({ translationX: -200 });
310+
pan[action]({ translationX: -200 });
311+
await flushNativeOps();
312+
refreshStyles();
313+
314+
expectNoSwipeableCallbacks();
315+
expect(getHostStyle('Row').transform).toEqual([{ translateX: 0 }]);
316+
}
317+
);
318+
319+
test.each(['cancel', 'fail'] as const)(
320+
'keeps an open row open without callbacks when the pan is %sed past the threshold',
321+
async (action) => {
322+
const { pan, refreshStyles } = renderRow();
323+
324+
pan.begin();
325+
pan.activate({ translationX: -20 });
326+
pan.update({ translationX: -200 });
327+
pan.end({ translationX: -200 });
328+
await flushNativeOps();
329+
Object.values(callbacks).forEach((callback) => callback.mockClear());
330+
331+
pan.begin();
332+
pan.activate({ translationX: 20 });
333+
pan.update({ translationX: 200 });
334+
pan[action]({ translationX: 200 });
335+
await flushNativeOps();
336+
refreshStyles();
337+
338+
expectNoSwipeableCallbacks();
339+
expect(getHostStyle('Row').transform).toEqual([
340+
{ translateX: -ROW_WIDTH },
341+
]);
342+
}
343+
);
344+
345+
test('finishes an open animation that a cancelled pan interrupted', async () => {
346+
const { pan, swipeableRef } = renderRow();
347+
348+
// The open animation is still running when the pan starts.
349+
jest.mocked(withSpring).mockImplementationOnce((to) => to);
350+
swipeableRef.current?.openRight();
351+
await flushNativeOps();
352+
expect(callbacks.onSwipeableWillOpen).toHaveBeenCalledTimes(1);
353+
expect(callbacks.onSwipeableOpen).not.toHaveBeenCalled();
354+
355+
pan.begin();
356+
pan.activate({ translationX: 20 });
357+
pan.update({ translationX: 50 });
358+
pan.cancel({ translationX: 50 });
359+
await flushNativeOps();
360+
361+
expect(callbacks.onSwipeableWillOpen).toHaveBeenCalledTimes(1);
362+
expect(callbacks.onSwipeableOpen).toHaveBeenCalledTimes(1);
363+
expect(callbacks.onSwipeableWillClose).not.toHaveBeenCalled();
364+
expect(callbacks.onSwipeableClose).not.toHaveBeenCalled();
365+
});
366+
});

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

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,10 @@ const Swipeable = (props: SwipeableProps) => {
153153
const showLeftProgress = useSharedValue<number>(0);
154154
const showRightProgress = useSharedValue<number>(0);
155155

156+
// Row state at the start of the animation whose end events are not yet
157+
// dispatched, or `null` when there is none.
158+
const pendingEndFromState = useSharedValue<number | null>(null);
159+
156160
const updateAnimatedEvent = useCallback(() => {
157161
'worklet';
158162

@@ -257,8 +261,12 @@ const Swipeable = (props: SwipeableProps) => {
257261
[onSwipeableClose, onSwipeableOpen]
258262
);
259263

260-
const animateRow: (toValue: number, velocityX?: number) => void = useCallback(
261-
(toValue: number, velocityX = 0) => {
264+
const animateRow: (
265+
toValue: number,
266+
velocityX?: number,
267+
silent?: boolean
268+
) => void = useCallback(
269+
(toValue: number, velocityX = 0, silent = false) => {
262270
'worklet';
263271

264272
const translationSpringConfig = {
@@ -292,12 +300,18 @@ const Swipeable = (props: SwipeableProps) => {
292300

293301
const frozenRowState = rowState.value;
294302

303+
// A silent animation dispatches only the end events of an earlier
304+
// animation that it interrupted.
305+
const endFromState = silent ? pendingEndFromState.value : frozenRowState;
306+
pendingEndFromState.value = endFromState;
307+
295308
appliedTranslation.value = withSpring(
296309
toValue,
297310
translationSpringConfig,
298311
(isFinished) => {
299-
if (isFinished) {
300-
dispatchEndEvents(frozenRowState, toValue);
312+
if (isFinished && endFromState !== null) {
313+
pendingEndFromState.value = null;
314+
dispatchEndEvents(endFromState, toValue);
301315
}
302316
}
303317
);
@@ -314,7 +328,9 @@ const Swipeable = (props: SwipeableProps) => {
314328
progressSpringConfig
315329
);
316330

317-
dispatchImmediateEvents(frozenRowState, toValue);
331+
if (!silent) {
332+
dispatchImmediateEvents(frozenRowState, toValue);
333+
}
318334

319335
rowState.value = Math.sign(toValue);
320336

@@ -330,6 +346,7 @@ const Swipeable = (props: SwipeableProps) => {
330346
rightWidth,
331347
dispatchImmediateEvents,
332348
dispatchEndEvents,
349+
pendingEndFromState,
333350
]
334351
);
335352

@@ -400,6 +417,7 @@ const Swipeable = (props: SwipeableProps) => {
400417
showLeftProgress.value = 0;
401418
appliedTranslation.value = 0;
402419
rowState.value = 0;
420+
pendingEndFromState.value = null;
403421
},
404422
}),
405423
[
@@ -411,6 +429,7 @@ const Swipeable = (props: SwipeableProps) => {
411429
showLeftProgress,
412430
appliedTranslation,
413431
rowState,
432+
pendingEndFromState,
414433
]
415434
);
416435

@@ -488,8 +507,20 @@ const Swipeable = (props: SwipeableProps) => {
488507
);
489508

490509
const handleRelease = useCallback(
491-
(event: PanGestureActiveEvent) => {
510+
(event: PanGestureActiveEvent & { canceled: boolean }) => {
492511
'worklet';
512+
if (event.canceled) {
513+
// Restore the row to where it rested before the gesture.
514+
const restingValue =
515+
rowState.value === 1
516+
? leftWidth.value
517+
: rowState.value === -1
518+
? -rightWidth.value
519+
: 0;
520+
animateRow(restingValue, 0, true);
521+
return;
522+
}
523+
493524
const { velocityX } = event;
494525
userDrag.value = event.translationX;
495526

0 commit comments

Comments
 (0)