authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-09-06 21:31:29+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 12:31:29-07:00
log98469159dd9e162a9c2f5cc4bbb2fbe89b3c4f67
tree2567eb7897ab547b71132c98e36e5929668fcc85
parent8e129a0dfb35ac2c03a7320245b5bfb976f5d3bf
signature Signed by PGP key B5690EEEBB952194

fix(mobile): slide settled threads out before collapsing (#10345)


5 files changed, 339 insertions(+), 103 deletions(-)

apps/mobile/src/features/home/HomeScreen.tsx+1-6
......@@ -490,12 +490,7 @@ export function HomeScreen(props: HomeScreenProps) {
490490 // Settled threads stay in the live shell stream (settled ≠ archived), so
491491 // the partition works directly off live shells — no snapshot merging or
492492 // optimistic holds.
493 const handleSettleThread = useCallback(
494 (thread: EnvironmentThreadShell) => {
495 void props.onSettleThread(thread);
496 },
497 [props.onSettleThread],
498 );
493 const handleSettleThread = props.onSettleThread;
499494 const handleSnoozeThread = useCallback(
500495 (thread: EnvironmentThreadShell, snoozedUntil: string) => {
501496 void props.onSnoozeThread(thread, snoozedUntil);
apps/mobile/src/features/home/thread-swipe-actions.tsx+223-80
......@@ -7,6 +7,7 @@ import {
77 use,
88 useCallback,
99 useEffect,
10 useLayoutEffect,
1011 useRef,
1112 useState,
1213 type ComponentProps,
......@@ -19,17 +20,23 @@ import type {
1920 StyleProp,
2021 ViewStyle,
2122} from "react-native";
22import { Pressable, View } from "react-native";
23import { Alert, Pressable, View } from "react-native";
2324import ReanimatedSwipeable, {
2425 type SwipeableMethods,
2526} from "react-native-gesture-handler/ReanimatedSwipeable";
2627import Animated, {
28 cancelAnimation,
29 Easing,
2730 Extrapolation,
31 ReduceMotion,
2832 interpolate,
2933 runOnJS,
34 runOnUI,
3035 type SharedValue,
3136 useAnimatedReaction,
3237 useAnimatedStyle,
38 useSharedValue,
39 withTiming,
3340} from "react-native-reanimated";
3441
3542import { AppText as Text } from "../../components/AppText";
......@@ -61,6 +68,13 @@ interface ThreadSwipeAction {
6168 readonly onPress: () => void;
6269}
6370
71/** Dismiss before committing; false restores the row, success changes its resetKey or removes it. */
72type ThreadSwipePrimaryAction = Omit<ThreadSwipeAction, "onPress"> &
73 (
74 | { readonly dismissOnPress: true; readonly onPress: () => Promise<boolean> }
75 | { readonly dismissOnPress?: false; readonly onPress: () => void }
76 );
77
6478interface ThreadSwipeSecondaryAction extends ThreadSwipeAction {
6579 readonly tone: "primary" | "secondary" | "danger";
6680}
......@@ -218,7 +232,7 @@ export function useSwipeableScrollGate(options?: {
218232 };
219233}
220234
221export function ThreadSwipeable(props: {
235interface ThreadSwipeableProps {
222236 readonly backgroundColor: ColorValue;
223237 readonly children: (close: () => void) => ReactNode;
224238 /** Uses action visuals that fit inside compact 44pt rows. The press target
......@@ -238,7 +252,7 @@ export function ThreadSwipeable(props: {
238252 readonly onDelete: () => void;
239253 readonly onSwipeableClose?: (methods: SwipeableMethods) => void;
240254 readonly onSwipeableWillOpen?: (methods: SwipeableMethods) => void;
241 readonly primaryAction: ThreadSwipeAction;
255 readonly primaryAction: ThreadSwipePrimaryAction;
242256 /**
243257 * Omitted keeps the v1 destructive Delete action. Explicit null opts out of
244258 * a secondary action entirely so a gated Snooze can never fall back to an
......@@ -255,7 +269,15 @@ export function ThreadSwipeable(props: {
255269 typeof ReanimatedSwipeable
256270 >["simultaneousWithExternalGesture"];
257271 readonly threadTitle: string;
258}) {
272}
273
274export function ThreadSwipeable(props: ThreadSwipeableProps) {
275 // Recycled content gets fresh native and animation state. Late callbacks
276 // from the previous row retain its action, never the replacement's action.
277 return <ThreadSwipeableRow key={props.resetKey} {...props} />;
278}
279
280function ThreadSwipeableRow(props: ThreadSwipeableProps) {
259281 const swipeableRef = useRef<SwipeableMethods | null>(null);
260282 const fullSwipeArmedRef = useRef(false);
261283 const hasSecondaryAction = props.secondaryAction !== null;
......@@ -265,14 +287,119 @@ export function ThreadSwipeable(props: {
265287 props.fullSwipeAction ?? (props.secondaryAction === undefined ? "delete" : "primary");
266288 const close = useCallback(() => swipeableRef.current?.close(), []);
267289 const gateEnabled = use(SwipeableScrollGateContext);
268 const resetKey = props.resetKey;
269 useEffect(() => {
270 if (resetKey === undefined) {
271 return;
290 const mountedRef = useRef(true);
291 const pendingDismissRef = useRef<(() => Promise<boolean>) | null>(null);
292 const activeTranslationRef = useRef<SharedValue<number> | null>(null);
293 const [isDismissing, setIsDismissing] = useState(false);
294 const dismissing = useSharedValue(false);
295 const rowHeight = useSharedValue(0);
296 const rowWidth = useSharedValue(props.fullSwipeWidth);
297 const collapse = useSharedValue(0);
298 const actionOpacity = useSharedValue(1);
299 const primaryAction = props.primaryAction;
300 const onSwipeableClose = props.onSwipeableClose;
301
302 const restoreRow = useCallback(() => {
303 swipeableRef.current?.close();
304 collapse.set(0);
305 actionOpacity.set(1);
306 dismissing.set(false);
307 setIsDismissing(false);
308 }, [actionOpacity, collapse, dismissing]);
309
310 const finishDismiss = useCallback(async () => {
311 const action = pendingDismissRef.current;
312 if (!action) return;
313 pendingDismissRef.current = null;
314 try {
315 const succeeded = await action();
316 if (!succeeded && mountedRef.current) restoreRow();
317 } catch (error) {
318 if (mountedRef.current) restoreRow();
319 Alert.alert(
320 "Could not settle thread",
321 error instanceof Error ? error.message : "The thread could not be settled.",
322 );
272323 }
273 fullSwipeArmedRef.current = false;
274 swipeableRef.current?.reset();
275 }, [resetKey]);
324 }, [restoreRow]);
325
326 useLayoutEffect(() => {
327 mountedRef.current = true;
328 return () => {
329 mountedRef.current = false;
330 cancelAnimation(collapse);
331 cancelAnimation(actionOpacity);
332 if (activeTranslationRef.current) cancelAnimation(activeTranslationRef.current);
333 // Scrolling a committed row out of the recycled list must still settle it.
334 void finishDismiss();
335 };
336 }, [actionOpacity, collapse, finishDismiss]);
337
338 const beginDismiss = useCallback(
339 (translation: SharedValue<number>) => {
340 if (!primaryAction.dismissOnPress) return;
341 pendingDismissRef.current = primaryAction.onPress;
342 activeTranslationRef.current = translation;
343 fullSwipeArmedRef.current = false;
344 if (!mountedRef.current) {
345 void finishDismiss();
346 return;
347 }
348 setIsDismissing(true);
349 if (swipeableRef.current) onSwipeableClose?.(swipeableRef.current);
350 },
351 [finishDismiss, primaryAction, onSwipeableClose],
352 );
353
354 const dismiss = useCallback(
355 (translation: SharedValue<number>) => {
356 "worklet";
357 if (dismissing.value) return;
358 dismissing.set(true);
359 runOnJS(beginDismiss)(translation);
360 const timing = {
361 duration: 220,
362 easing: Easing.out(Easing.cubic),
363 reduceMotion: ReduceMotion.System,
364 };
365 actionOpacity.set(withTiming(0, timing));
366 // Never reverse a swipe that already carried the row beyond its width.
367 translation.set(
368 withTiming(Math.min(translation.value, -rowWidth.value), timing, (finished) => {
369 if (!finished) return;
370 collapse.set(
371 withTiming(1, { ...timing, duration: 180 }, (collapsed) => {
372 if (collapsed) runOnJS(finishDismiss)();
373 }),
374 );
375 }),
376 );
377 },
378 [actionOpacity, beginDismiss, collapse, dismissing, finishDismiss, rowWidth],
379 );
380 const dismissStyle = useAnimatedStyle(() => ({
381 height: dismissing.value ? rowHeight.value * (1 - collapse.value) : undefined,
382 pointerEvents: dismissing.value ? "none" : "auto",
383 overflow: "hidden",
384 }));
385 const actionStyle = useAnimatedStyle(() => ({ opacity: actionOpacity.value, height: "100%" }));
386 const dismissOnPress = primaryAction.dismissOnPress === true;
387 const handleRelease = useCallback(
388 (translation: SharedValue<number>) => {
389 "worklet";
390 if (dismissing.value) return true;
391 if (
392 dismissOnPress &&
393 fullSwipeAction === "primary" &&
394 -translation.value >= fullSwipeThreshold
395 ) {
396 dismiss(translation);
397 return true;
398 }
399 return false;
400 },
401 [dismiss, dismissing, dismissOnPress, fullSwipeAction, fullSwipeThreshold],
402 );
276403 const handleFullSwipeArmedChange = useCallback((armed: boolean) => {
277404 if (armed && !fullSwipeArmedRef.current) {
278405 void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
......@@ -281,78 +408,94 @@ export function ThreadSwipeable(props: {
281408 }, []);
282409
283410 return (
284 <ReanimatedSwipeable
285 ref={swipeableRef}
286 animationOptions={THREAD_SWIPE_SPRING}
287 childrenContainerStyle={{ backgroundColor: props.backgroundColor }}
288 containerStyle={[{ backgroundColor: props.backgroundColor }, props.containerStyle]}
289 dragOffsetFromRightEdge={8}
290 enabled={props.enabled !== false && gateEnabled}
291 enableTrackpadTwoFingerGesture={props.enableTrackpadSwipe ?? true}
292 // Fail the swipe once the pan is vertically dominant (patched-in RNGH
293 // prop) — otherwise trackpad scrolls with ~8px of horizontal drift
294 // start opening rows because the swipe pan runs simultaneously with
295 // the list scroll gesture and never gets disqualified by Y movement.
296 failOffsetY={[-10, 10]}
297 friction={1}
298 onSwipeableClose={() => {
299 fullSwipeArmedRef.current = false;
300 if (swipeableRef.current) {
301 props.onSwipeableClose?.(swipeableRef.current);
302 }
303 }}
304 onSwipeableOpenStartDrag={() => {
305 if (swipeableRef.current) {
306 props.onSwipeableWillOpen?.(swipeableRef.current);
307 }
308 }}
309 onSwipeableWillOpen={() => {
310 const methods = swipeableRef.current;
311 if (!methods) {
312 return;
313 }
411 <Animated.View style={dismissStyle}>
412 <View
413 onLayout={({ nativeEvent: { layout } }) => {
414 rowHeight.set(layout.height);
415 rowWidth.set(layout.width);
416 }}
417 >
418 <ReanimatedSwipeable
419 ref={swipeableRef}
420 animationOptions={THREAD_SWIPE_SPRING}
421 childrenContainerStyle={{ backgroundColor: props.backgroundColor }}
422 containerStyle={[{ backgroundColor: props.backgroundColor }, props.containerStyle]}
423 dragOffsetFromRightEdge={8}
424 enabled={!isDismissing && props.enabled !== false && gateEnabled}
425 enableTrackpadTwoFingerGesture={props.enableTrackpadSwipe ?? true}
426 // Fail the swipe once the pan is vertically dominant (patched-in RNGH
427 // prop) — otherwise trackpad scrolls with ~8px of horizontal drift
428 // start opening rows because the swipe pan runs simultaneously with
429 // the list scroll gesture and never gets disqualified by Y movement.
430 failOffsetY={[-10, 10]}
431 friction={1}
432 onSwipeableClose={() => {
433 fullSwipeArmedRef.current = false;
434 if (swipeableRef.current) {
435 props.onSwipeableClose?.(swipeableRef.current);
436 }
437 }}
438 onSwipeableRelease={handleRelease}
439 onSwipeableOpenStartDrag={() => {
440 if (swipeableRef.current) {
441 props.onSwipeableWillOpen?.(swipeableRef.current);
442 }
443 }}
444 onSwipeableWillOpen={() => {
445 const methods = swipeableRef.current;
446 if (!methods) {
447 return;
448 }
314449
315 props.onSwipeableWillOpen?.(methods);
316 if (fullSwipeArmedRef.current) {
317 fullSwipeArmedRef.current = false;
318 methods.close();
319 if (fullSwipeAction === "primary") {
320 props.primaryAction.onPress();
321 } else {
322 props.onDelete();
323 }
324 }
325 }}
326 overshootFriction={1}
327 overshootRight
328 renderRightActions={(_progress, translation, methods) => (
329 <ThreadSwipeActions
330 backgroundColor={props.backgroundColor}
331 compact={props.compactActions === true}
332 fullSwipeAction={fullSwipeAction}
333 fullSwipeThreshold={fullSwipeThreshold}
334 onFullSwipeArmedChange={handleFullSwipeArmedChange}
335 primaryAction={{
336 ...props.primaryAction,
337 onPress: () => {
450 props.onSwipeableWillOpen?.(methods);
451 if (fullSwipeArmedRef.current && !(dismissOnPress && fullSwipeAction === "primary")) {
452 fullSwipeArmedRef.current = false;
338453 methods.close();
339 props.primaryAction.onPress();
340 },
454 if (fullSwipeAction === "primary") {
455 props.primaryAction.onPress();
456 } else {
457 props.onDelete();
458 }
459 }
341460 }}
342 secondaryAction={resolveSecondaryAction({
343 close: () => methods.close(),
344 onDelete: props.onDelete,
345 secondaryAction: props.secondaryAction,
346 threadTitle: props.threadTitle,
347 })}
348 translation={translation}
349 />
350 )}
351 rightThreshold={actionsWidth * 0.42}
352 simultaneousWithExternalGesture={props.simultaneousWithExternalGesture}
353 >
354 {props.children(close)}
355 </ReanimatedSwipeable>
461 overshootFriction={1}
462 overshootRight
463 renderRightActions={(_progress, translation, methods) => (
464 <Animated.View style={actionStyle}>
465 <ThreadSwipeActions
466 backgroundColor={props.backgroundColor}
467 compact={props.compactActions === true}
468 fullSwipeAction={fullSwipeAction}
469 fullSwipeThreshold={fullSwipeThreshold}
470 onFullSwipeArmedChange={handleFullSwipeArmedChange}
471 primaryAction={{
472 ...primaryAction,
473 onPress: () => {
474 if (primaryAction.dismissOnPress) {
475 runOnUI(dismiss)(translation);
476 } else {
477 methods.close();
478 primaryAction.onPress();
479 }
480 },
481 }}
482 secondaryAction={resolveSecondaryAction({
483 close: () => methods.close(),
484 onDelete: props.onDelete,
485 secondaryAction: props.secondaryAction,
486 threadTitle: props.threadTitle,
487 })}
488 translation={translation}
489 />
490 </Animated.View>
491 )}
492 rightThreshold={actionsWidth * 0.42}
493 simultaneousWithExternalGesture={props.simultaneousWithExternalGesture}
494 >
495 {props.children(close)}
496 </ReanimatedSwipeable>
497 </View>
498 </Animated.View>
356499 );
357500}
358501
apps/mobile/src/features/threads/thread-list-v2-items.tsx+3-2
......@@ -371,7 +371,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
371371 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
372372 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
373373 readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void;
374 readonly onSettleThread: (thread: EnvironmentThreadShell) => void;
374 readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
375375 readonly onSnoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => void;
376376 readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => void;
377377 readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;
......@@ -653,6 +653,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
653653 accessibilityLabel: `Settle ${thread.title}`,
654654 icon: "checkmark" as const,
655655 label: "Settle",
656 dismissOnPress: true as const,
656657 onPress: handleSettle,
657658 };
658659 }, [
......@@ -963,7 +964,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
963964 onSwipeableWillOpen={props.onSwipeableWillOpen}
964965 primaryAction={primaryAction}
965966 secondaryAction={secondaryAction}
966 resetKey={`${thread.environmentId}:${thread.id}`}
967 resetKey={`${thread.environmentId}:${thread.id}:${variant}:${snoozedRow}`}
967968 simultaneousWithExternalGesture={props.simultaneousSwipeGesture}
968969 threadTitle={thread.title}
969970 >
patches/react-native-gesture-handler@2.32.0.patch+109-12
......@@ -1,8 +1,8 @@
11diff --git a/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js b/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js
2index 551ab92bf58db0b79d428dcd2f2df898ef686493..f1c355b8e40cd4f583ef3a501b044fb6770f4a24 100644
2index 551ab92bf58db0b79d428dcd2f2df898ef686493..13db26d88bff975bc5d46bb6432cbf9fda3d489a 100644
33--- a/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js
44+++ b/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js
5@@ -34,6 +34,7 @@ const Swipeable = props => {
5@@ -34,11 +34,13 @@ const Swipeable = props => {
66 enableTrackpadTwoFingerGesture = DEFAULT_ENABLE_TRACKING_TWO_FINGER_GESTURE,
77 dragOffsetFromLeftEdge = DEFAULT_DRAG_OFFSET,
88 dragOffsetFromRightEdge = DEFAULT_DRAG_OFFSET,
......@@ -10,7 +10,28 @@ index 551ab92bf58db0b79d428dcd2f2df898ef686493..f1c355b8e40cd4f583ef3a501b044fb6
1010 friction = DEFAULT_FRICTION,
1111 overshootFriction = DEFAULT_OVERSHOOT_FRICTION,
1212 onSwipeableOpenStartDrag,
13@@ -285,11 +286,14 @@ const Swipeable = props => {
13 onSwipeableCloseStartDrag,
14 onSwipeableWillOpen,
15+ onSwipeableRelease,
16 onSwipeableWillClose,
17 onSwipeableOpen,
18 onSwipeableClose,
19@@ -248,8 +250,13 @@ const Swipeable = props => {
20 toValue = -rightWidth.value;
21 }
22 }
23+ // Let a UI-thread full-swipe commit take over before the return spring.
24+ if (onSwipeableRelease?.(appliedTranslation)) {
25+ return;
26+ }
27+
28 animateRow(toValue, velocityX / friction);
29- }, [animateRow, friction, leftThreshold, leftWidth, rightThreshold, rightWidth, rowState, userDrag]);
30+ }, [animateRow, friction, leftThreshold, leftWidth, rightThreshold, rightWidth, rowState, userDrag, onSwipeableRelease, appliedTranslation]);
31 const close = (0, _react.useCallback)(() => {
32 'worklet';
33
34@@ -285,11 +292,14 @@ const Swipeable = props => {
1435 }).onFinalize(() => {
1536 dragStarted.value = false;
1637 });
......@@ -27,10 +48,10 @@ index 551ab92bf58db0b79d428dcd2f2df898ef686493..f1c355b8e40cd4f583ef3a501b044fb6
2748 const animatedStyle = (0, _reactNativeReanimated.useAnimatedStyle)(() => ({
2849 transform: [{
2950diff --git a/lib/module/components/ReanimatedSwipeable/ReanimatedSwipeable.js b/lib/module/components/ReanimatedSwipeable/ReanimatedSwipeable.js
30index a2835d5416ffd5cf9a04e98774516b9e6569691e..b73c177227bf3a232737b0eb1c0e2bb830493c22 100644
51index a2835d5416ffd5cf9a04e98774516b9e6569691e..055afce410d6eb655532e7a09371dfc22eae5e9c 100644
3152--- a/lib/module/components/ReanimatedSwipeable/ReanimatedSwipeable.js
3253+++ b/lib/module/components/ReanimatedSwipeable/ReanimatedSwipeable.js
33@@ -29,6 +29,7 @@ const Swipeable = props => {
54@@ -29,11 +29,13 @@ const Swipeable = props => {
3455 enableTrackpadTwoFingerGesture = DEFAULT_ENABLE_TRACKING_TWO_FINGER_GESTURE,
3556 dragOffsetFromLeftEdge = DEFAULT_DRAG_OFFSET,
3657 dragOffsetFromRightEdge = DEFAULT_DRAG_OFFSET,
......@@ -38,7 +59,28 @@ index a2835d5416ffd5cf9a04e98774516b9e6569691e..b73c177227bf3a232737b0eb1c0e2bb8
3859 friction = DEFAULT_FRICTION,
3960 overshootFriction = DEFAULT_OVERSHOOT_FRICTION,
4061 onSwipeableOpenStartDrag,
41@@ -280,11 +281,14 @@ const Swipeable = props => {
62 onSwipeableCloseStartDrag,
63 onSwipeableWillOpen,
64+ onSwipeableRelease,
65 onSwipeableWillClose,
66 onSwipeableOpen,
67 onSwipeableClose,
68@@ -243,8 +245,13 @@ const Swipeable = props => {
69 toValue = -rightWidth.value;
70 }
71 }
72+ // Let a UI-thread full-swipe commit take over before the return spring.
73+ if (onSwipeableRelease?.(appliedTranslation)) {
74+ return;
75+ }
76+
77 animateRow(toValue, velocityX / friction);
78- }, [animateRow, friction, leftThreshold, leftWidth, rightThreshold, rightWidth, rowState, userDrag]);
79+ }, [animateRow, friction, leftThreshold, leftWidth, rightThreshold, rightWidth, rowState, userDrag, onSwipeableRelease, appliedTranslation]);
80 const close = useCallback(() => {
81 'worklet';
82
83@@ -280,11 +287,14 @@ const Swipeable = props => {
4284 }).onFinalize(() => {
4385 dragStarted.value = false;
4486 });
......@@ -55,7 +97,7 @@ index a2835d5416ffd5cf9a04e98774516b9e6569691e..b73c177227bf3a232737b0eb1c0e2bb8
5597 const animatedStyle = useAnimatedStyle(() => ({
5698 transform: [{
5799diff --git a/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts b/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts
58index ac8b76830d468edfbc29052b452a36221323c3de..589e329d2aa706ddfff0d1037df0d85a050edbb0 100644
100index ac8b76830d468edfbc29052b452a36221323c3de..6985d54d9d359e825a5a0e6078bb113204e2807b 100644
59101--- a/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts
60102+++ b/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts
61103@@ -64,6 +64,13 @@ export interface SwipeableProps {
......@@ -72,11 +114,25 @@ index ac8b76830d468edfbc29052b452a36221323c3de..589e329d2aa706ddfff0d1037df0d85a
72114 /**
73115 * Value indicating if the swipeable panel can be pulled further than the left
74116 * actions panel's width. It is set to true by default as long as the left
117@@ -90,6 +97,13 @@ export interface SwipeableProps {
118 * Called when action panel is closed.
119 */
120 onSwipeableClose?: (direction: SwipeDirection.LEFT | SwipeDirection.RIGHT) => void;
121+ /**
122+ * UI-thread worklet called on release before the built-in spring starts.
123+ * Return true to consume the release and take ownership of translation.
124+ * The caller must reset or remove the row after its custom animation.
125+ */
126+ onSwipeableRelease?: (translation: SharedValue<number>) => boolean;
127+
128 /**
129 * Called when action panel starts animating on open (either right or left).
130 */
75131diff --git a/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx b/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx
76index b6134c908624adc590ebd264e90e558b88e235d5..41535348e937ad7762bd531d5b63ba6e092ea997 100644
132index b6134c908624adc590ebd264e90e558b88e235d5..fa4b1c1749bd0e86cbf5f5546f295d8099e1a481 100644
77133--- a/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx
78134+++ b/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx
79@@ -58,6 +58,7 @@ const Swipeable = (props: SwipeableProps) => {
135@@ -58,11 +58,13 @@ const Swipeable = (props: SwipeableProps) => {
80136 enableTrackpadTwoFingerGesture = DEFAULT_ENABLE_TRACKING_TWO_FINGER_GESTURE,
81137 dragOffsetFromLeftEdge = DEFAULT_DRAG_OFFSET,
82138 dragOffsetFromRightEdge = DEFAULT_DRAG_OFFSET,
......@@ -84,7 +140,34 @@ index b6134c908624adc590ebd264e90e558b88e235d5..41535348e937ad7762bd531d5b63ba6e
84140 friction = DEFAULT_FRICTION,
85141 overshootFriction = DEFAULT_OVERSHOOT_FRICTION,
86142 onSwipeableOpenStartDrag,
87@@ -537,6 +538,10 @@ const Swipeable = (props: SwipeableProps) => {
143 onSwipeableCloseStartDrag,
144 onSwipeableWillOpen,
145+ onSwipeableRelease,
146 onSwipeableWillClose,
147 onSwipeableOpen,
148 onSwipeableClose,
149@@ -457,6 +459,11 @@ const Swipeable = (props: SwipeableProps) => {
150 }
151 }
152
153+ // Let a UI-thread full-swipe commit take over before the return spring.
154+ if (onSwipeableRelease?.(appliedTranslation)) {
155+ return;
156+ }
157+
158 animateRow(toValue, velocityX / friction);
159 },
160 [
161@@ -468,6 +475,8 @@ const Swipeable = (props: SwipeableProps) => {
162 rightWidth,
163 rowState,
164 userDrag,
165+ onSwipeableRelease,
166+ appliedTranslation,
167 ]
168 );
169
170@@ -537,6 +546,10 @@ const Swipeable = (props: SwipeableProps) => {
88171 dragStarted.value = false;
89172 });
90173
......@@ -95,7 +178,7 @@ index b6134c908624adc590ebd264e90e558b88e235d5..41535348e937ad7762bd531d5b63ba6e
95178 Object.entries(relationProps).forEach(([relationName, relation]) => {
96179 applyRelationProp(
97180 pan,
98@@ -552,6 +557,7 @@ const Swipeable = (props: SwipeableProps) => {
181@@ -552,6 +565,7 @@ const Swipeable = (props: SwipeableProps) => {
99182 enableTrackpadTwoFingerGesture,
100183 dragOffsetFromRightEdge,
101184 dragOffsetFromLeftEdge,
......@@ -104,7 +187,7 @@ index b6134c908624adc590ebd264e90e558b88e235d5..41535348e937ad7762bd531d5b63ba6e
104187 relationProps,
105188 userDrag,
106189diff --git a/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts b/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts
107index 0c0e517e0d340faf50ff78c3d48e7a2bbcf808ec..d4b6ff508077728c8be83762923d866dc95b144c 100644
190index 0c0e517e0d340faf50ff78c3d48e7a2bbcf808ec..3ab14d240da346b1927792e480f5b1e902b03bf8 100644
108191--- a/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts
109192+++ b/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts
110193@@ -77,6 +77,14 @@ export interface SwipeableProps {
......@@ -122,3 +205,17 @@ index 0c0e517e0d340faf50ff78c3d48e7a2bbcf808ec..d4b6ff508077728c8be83762923d866d
122205 /**
123206 * Value indicating if the swipeable panel can be pulled further than the left
124207 * actions panel's width. It is set to true by default as long as the left
208@@ -112,6 +120,13 @@ export interface SwipeableProps {
209 direction: SwipeDirection.LEFT | SwipeDirection.RIGHT
210 ) => void;
211
212+ /**
213+ * UI-thread worklet called on release before the built-in spring starts.
214+ * Return true to consume the release and take ownership of translation.
215+ * The caller must reset or remove the row after its custom animation.
216+ */
217+ onSwipeableRelease?: (translation: SharedValue<number>) => boolean;
218+
219 /**
220 * Called when action panel starts animating on open (either right or left).
221 */
pnpm-lock.yaml+3-3
......@@ -99,7 +99,7 @@ patchedDependencies:
9999 effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6
100100 expo-audio@57.0.4: fa9a3e0442ed395d4071bb406e08c3a471c9a84700bdfa0b9ad7ff144c96041a
101101 expo-sharing@57.0.17: 8d2e3b10eb3f52036a9a086800180ec6cebf3b75bccc5b1775117a7244d4ac45
102 react-native-gesture-handler@2.32.0: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3
102 react-native-gesture-handler@2.32.0: 96573c000f7fe56b5abfa13e2e5f0d065907e674cb8e2300155226d7c9874398
103103 react-native-keyboard-controller@1.21.13: 6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787
104104 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675
105105 react-native-screens@4.26.2: 8156dd0f3407822404793cfdaa95639a36b62102f4507c981b8be83600bb382d
......@@ -410,7 +410,7 @@ importers:
410410 version: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
411411 react-native-gesture-handler:
412412 specifier: ~2.32.0
413 version: 2.32.0(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
413 version: 2.32.0(patch_hash=96573c000f7fe56b5abfa13e2e5f0d065907e674cb8e2300155226d7c9874398)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
414414 react-native-image-viewing:
415415 specifier: ^0.2.2
416416 version: 0.2.2(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
......@@ -20045,7 +20045,7 @@ snapshots:
2004520045 transitivePeerDependencies:
2004620046 - supports-color
2004720047
20048 react-native-gesture-handler@2.32.0(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
20048 react-native-gesture-handler@2.32.0(patch_hash=96573c000f7fe56b5abfa13e2e5f0d065907e674cb8e2300155226d7c9874398)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
2004920049 dependencies:
2005020050 '@egjs/hammerjs': 2.0.17
2005120051 '@types/react-test-renderer': 19.1.0