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) {...@@ -490,12 +490,7 @@ export function HomeScreen(props: HomeScreenProps) {
490 // Settled threads stay in the live shell stream (settled ≠ archived), so490 // Settled threads stay in the live shell stream (settled ≠ archived), so
491 // the partition works directly off live shells — no snapshot merging or491 // the partition works directly off live shells — no snapshot merging or
492 // optimistic holds.492 // optimistic holds.
493 const handleSettleThread = useCallback(493 const handleSettleThread = props.onSettleThread;
494 (thread: EnvironmentThreadShell) => {
495 void props.onSettleThread(thread);
496 },
497 [props.onSettleThread],
498 );
499 const handleSnoozeThread = useCallback(494 const handleSnoozeThread = useCallback(
500 (thread: EnvironmentThreadShell, snoozedUntil: string) => {495 (thread: EnvironmentThreadShell, snoozedUntil: string) => {
501 void props.onSnoozeThread(thread, snoozedUntil);496 void props.onSnoozeThread(thread, snoozedUntil);
apps/mobile/src/features/home/thread-swipe-actions.tsx+223-80
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7 use,7 use,
8 useCallback,8 useCallback,
9 useEffect,9 useEffect,
10 useLayoutEffect,
10 useRef,11 useRef,
11 useState,12 useState,
12 type ComponentProps,13 type ComponentProps,
...@@ -19,17 +20,23 @@ import type {...@@ -19,17 +20,23 @@ import type {
19 StyleProp,20 StyleProp,
20 ViewStyle,21 ViewStyle,
21} from "react-native";22} from "react-native";
22import { Pressable, View } from "react-native";23import { Alert, Pressable, View } from "react-native";
23import ReanimatedSwipeable, {24import ReanimatedSwipeable, {
24 type SwipeableMethods,25 type SwipeableMethods,
25} from "react-native-gesture-handler/ReanimatedSwipeable";26} from "react-native-gesture-handler/ReanimatedSwipeable";
26import Animated, {27import Animated, {
28 cancelAnimation,
29 Easing,
27 Extrapolation,30 Extrapolation,
31 ReduceMotion,
28 interpolate,32 interpolate,
29 runOnJS,33 runOnJS,
34 runOnUI,
30 type SharedValue,35 type SharedValue,
31 useAnimatedReaction,36 useAnimatedReaction,
32 useAnimatedStyle,37 useAnimatedStyle,
38 useSharedValue,
39 withTiming,
33} from "react-native-reanimated";40} from "react-native-reanimated";
3441
35import { AppText as Text } from "../../components/AppText";42import { AppText as Text } from "../../components/AppText";
...@@ -61,6 +68,13 @@ interface ThreadSwipeAction {...@@ -61,6 +68,13 @@ interface ThreadSwipeAction {
61 readonly onPress: () => void;68 readonly onPress: () => void;
62}69}
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
64interface ThreadSwipeSecondaryAction extends ThreadSwipeAction {78interface ThreadSwipeSecondaryAction extends ThreadSwipeAction {
65 readonly tone: "primary" | "secondary" | "danger";79 readonly tone: "primary" | "secondary" | "danger";
66}80}
...@@ -218,7 +232,7 @@ export function useSwipeableScrollGate(options?: {...@@ -218,7 +232,7 @@ export function useSwipeableScrollGate(options?: {
218 };232 };
219}233}
220234
221export function ThreadSwipeable(props: {235interface ThreadSwipeableProps {
222 readonly backgroundColor: ColorValue;236 readonly backgroundColor: ColorValue;
223 readonly children: (close: () => void) => ReactNode;237 readonly children: (close: () => void) => ReactNode;
224 /** Uses action visuals that fit inside compact 44pt rows. The press target238 /** Uses action visuals that fit inside compact 44pt rows. The press target
...@@ -238,7 +252,7 @@ export function ThreadSwipeable(props: {...@@ -238,7 +252,7 @@ export function ThreadSwipeable(props: {
238 readonly onDelete: () => void;252 readonly onDelete: () => void;
239 readonly onSwipeableClose?: (methods: SwipeableMethods) => void;253 readonly onSwipeableClose?: (methods: SwipeableMethods) => void;
240 readonly onSwipeableWillOpen?: (methods: SwipeableMethods) => void;254 readonly onSwipeableWillOpen?: (methods: SwipeableMethods) => void;
241 readonly primaryAction: ThreadSwipeAction;255 readonly primaryAction: ThreadSwipePrimaryAction;
242 /**256 /**
243 * Omitted keeps the v1 destructive Delete action. Explicit null opts out of257 * Omitted keeps the v1 destructive Delete action. Explicit null opts out of
244 * a secondary action entirely so a gated Snooze can never fall back to an258 * a secondary action entirely so a gated Snooze can never fall back to an
...@@ -255,7 +269,15 @@ export function ThreadSwipeable(props: {...@@ -255,7 +269,15 @@ export function ThreadSwipeable(props: {
255 typeof ReanimatedSwipeable269 typeof ReanimatedSwipeable
256 >["simultaneousWithExternalGesture"];270 >["simultaneousWithExternalGesture"];
257 readonly threadTitle: string;271 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) {
259 const swipeableRef = useRef<SwipeableMethods | null>(null);281 const swipeableRef = useRef<SwipeableMethods | null>(null);
260 const fullSwipeArmedRef = useRef(false);282 const fullSwipeArmedRef = useRef(false);
261 const hasSecondaryAction = props.secondaryAction !== null;283 const hasSecondaryAction = props.secondaryAction !== null;
...@@ -265,14 +287,119 @@ export function ThreadSwipeable(props: {...@@ -265,14 +287,119 @@ export function ThreadSwipeable(props: {
265 props.fullSwipeAction ?? (props.secondaryAction === undefined ? "delete" : "primary");287 props.fullSwipeAction ?? (props.secondaryAction === undefined ? "delete" : "primary");
266 const close = useCallback(() => swipeableRef.current?.close(), []);288 const close = useCallback(() => swipeableRef.current?.close(), []);
267 const gateEnabled = use(SwipeableScrollGateContext);289 const gateEnabled = use(SwipeableScrollGateContext);
268 const resetKey = props.resetKey;290 const mountedRef = useRef(true);
269 useEffect(() => {291 const pendingDismissRef = useRef<(() => Promise<boolean>) | null>(null);
270 if (resetKey === undefined) {292 const activeTranslationRef = useRef<SharedValue<number> | null>(null);
271 return;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 );
272 }323 }
273 fullSwipeArmedRef.current = false;324 }, [restoreRow]);
274 swipeableRef.current?.reset();325
275 }, [resetKey]);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 );
276 const handleFullSwipeArmedChange = useCallback((armed: boolean) => {403 const handleFullSwipeArmedChange = useCallback((armed: boolean) => {
277 if (armed && !fullSwipeArmedRef.current) {404 if (armed && !fullSwipeArmedRef.current) {
278 void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);405 void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
...@@ -281,78 +408,94 @@ export function ThreadSwipeable(props: {...@@ -281,78 +408,94 @@ export function ThreadSwipeable(props: {
281 }, []);408 }, []);
282409
283 return (410 return (
284 <ReanimatedSwipeable411 <Animated.View style={dismissStyle}>
285 ref={swipeableRef}412 <View
286 animationOptions={THREAD_SWIPE_SPRING}413 onLayout={({ nativeEvent: { layout } }) => {
287 childrenContainerStyle={{ backgroundColor: props.backgroundColor }}414 rowHeight.set(layout.height);
288 containerStyle={[{ backgroundColor: props.backgroundColor }, props.containerStyle]}415 rowWidth.set(layout.width);
289 dragOffsetFromRightEdge={8}416 }}
290 enabled={props.enabled !== false && gateEnabled}417 >
291 enableTrackpadTwoFingerGesture={props.enableTrackpadSwipe ?? true}418 <ReanimatedSwipeable
292 // Fail the swipe once the pan is vertically dominant (patched-in RNGH419 ref={swipeableRef}
293 // prop) — otherwise trackpad scrolls with ~8px of horizontal drift420 animationOptions={THREAD_SWIPE_SPRING}
294 // start opening rows because the swipe pan runs simultaneously with421 childrenContainerStyle={{ backgroundColor: props.backgroundColor }}
295 // the list scroll gesture and never gets disqualified by Y movement.422 containerStyle={[{ backgroundColor: props.backgroundColor }, props.containerStyle]}
296 failOffsetY={[-10, 10]}423 dragOffsetFromRightEdge={8}
297 friction={1}424 enabled={!isDismissing && props.enabled !== false && gateEnabled}
298 onSwipeableClose={() => {425 enableTrackpadTwoFingerGesture={props.enableTrackpadSwipe ?? true}
299 fullSwipeArmedRef.current = false;426 // Fail the swipe once the pan is vertically dominant (patched-in RNGH
300 if (swipeableRef.current) {427 // prop) — otherwise trackpad scrolls with ~8px of horizontal drift
301 props.onSwipeableClose?.(swipeableRef.current);428 // start opening rows because the swipe pan runs simultaneously with
302 }429 // the list scroll gesture and never gets disqualified by Y movement.
303 }}430 failOffsetY={[-10, 10]}
304 onSwipeableOpenStartDrag={() => {431 friction={1}
305 if (swipeableRef.current) {432 onSwipeableClose={() => {
306 props.onSwipeableWillOpen?.(swipeableRef.current);433 fullSwipeArmedRef.current = false;
307 }434 if (swipeableRef.current) {
308 }}435 props.onSwipeableClose?.(swipeableRef.current);
309 onSwipeableWillOpen={() => {436 }
310 const methods = swipeableRef.current;437 }}
311 if (!methods) {438 onSwipeableRelease={handleRelease}
312 return;439 onSwipeableOpenStartDrag={() => {
313 }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);450 props.onSwipeableWillOpen?.(methods);
316 if (fullSwipeArmedRef.current) {451 if (fullSwipeArmedRef.current && !(dismissOnPress && fullSwipeAction === "primary")) {
317 fullSwipeArmedRef.current = false;452 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: () => {
338 methods.close();453 methods.close();
339 props.primaryAction.onPress();454 if (fullSwipeAction === "primary") {
340 },455 props.primaryAction.onPress();
456 } else {
457 props.onDelete();
458 }
459 }
341 }}460 }}
342 secondaryAction={resolveSecondaryAction({461 overshootFriction={1}
343 close: () => methods.close(),462 overshootRight
344 onDelete: props.onDelete,463 renderRightActions={(_progress, translation, methods) => (
345 secondaryAction: props.secondaryAction,464 <Animated.View style={actionStyle}>
346 threadTitle: props.threadTitle,465 <ThreadSwipeActions
347 })}466 backgroundColor={props.backgroundColor}
348 translation={translation}467 compact={props.compactActions === true}
349 />468 fullSwipeAction={fullSwipeAction}
350 )}469 fullSwipeThreshold={fullSwipeThreshold}
351 rightThreshold={actionsWidth * 0.42}470 onFullSwipeArmedChange={handleFullSwipeArmedChange}
352 simultaneousWithExternalGesture={props.simultaneousWithExternalGesture}471 primaryAction={{
353 >472 ...primaryAction,
354 {props.children(close)}473 onPress: () => {
355 </ReanimatedSwipeable>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>
356 );499 );
357}500}
358501
apps/mobile/src/features/threads/thread-list-v2-items.tsx+3-2
...@@ -371,7 +371,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -371,7 +371,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
371 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;371 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
372 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;372 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
373 readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void;373 readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void;
374 readonly onSettleThread: (thread: EnvironmentThreadShell) => void;374 readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
375 readonly onSnoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => void;375 readonly onSnoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => void;
376 readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => void;376 readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => void;
377 readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;377 readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;
...@@ -653,6 +653,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -653,6 +653,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
653 accessibilityLabel: `Settle ${thread.title}`,653 accessibilityLabel: `Settle ${thread.title}`,
654 icon: "checkmark" as const,654 icon: "checkmark" as const,
655 label: "Settle",655 label: "Settle",
656 dismissOnPress: true as const,
656 onPress: handleSettle,657 onPress: handleSettle,
657 };658 };
658 }, [659 }, [
...@@ -963,7 +964,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -963,7 +964,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
963 onSwipeableWillOpen={props.onSwipeableWillOpen}964 onSwipeableWillOpen={props.onSwipeableWillOpen}
964 primaryAction={primaryAction}965 primaryAction={primaryAction}
965 secondaryAction={secondaryAction}966 secondaryAction={secondaryAction}
966 resetKey={`${thread.environmentId}:${thread.id}`}967 resetKey={`${thread.environmentId}:${thread.id}:${variant}:${snoozedRow}`}
967 simultaneousWithExternalGesture={props.simultaneousSwipeGesture}968 simultaneousWithExternalGesture={props.simultaneousSwipeGesture}
968 threadTitle={thread.title}969 threadTitle={thread.title}
969 >970 >
patches/react-native-gesture-handler@2.32.0.patch+109-12
...@@ -1,8 +1,8 @@...@@ -1,8 +1,8 @@
1diff --git a/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js b/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js1diff --git a/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js b/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js
2index 551ab92bf58db0b79d428dcd2f2df898ef686493..f1c355b8e40cd4f583ef3a501b044fb6770f4a24 1006442index 551ab92bf58db0b79d428dcd2f2df898ef686493..13db26d88bff975bc5d46bb6432cbf9fda3d489a 100644
3--- a/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js3--- a/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js
4+++ b/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js4+++ b/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js
5@@ -34,6 +34,7 @@ const Swipeable = props => {5@@ -34,11 +34,13 @@ const Swipeable = props => {
6 enableTrackpadTwoFingerGesture = DEFAULT_ENABLE_TRACKING_TWO_FINGER_GESTURE,6 enableTrackpadTwoFingerGesture = DEFAULT_ENABLE_TRACKING_TWO_FINGER_GESTURE,
7 dragOffsetFromLeftEdge = DEFAULT_DRAG_OFFSET,7 dragOffsetFromLeftEdge = DEFAULT_DRAG_OFFSET,
8 dragOffsetFromRightEdge = DEFAULT_DRAG_OFFSET,8 dragOffsetFromRightEdge = DEFAULT_DRAG_OFFSET,
...@@ -10,7 +10,28 @@ index 551ab92bf58db0b79d428dcd2f2df898ef686493..f1c355b8e40cd4f583ef3a501b044fb6...@@ -10,7 +10,28 @@ index 551ab92bf58db0b79d428dcd2f2df898ef686493..f1c355b8e40cd4f583ef3a501b044fb6
10 friction = DEFAULT_FRICTION,10 friction = DEFAULT_FRICTION,
11 overshootFriction = DEFAULT_OVERSHOOT_FRICTION,11 overshootFriction = DEFAULT_OVERSHOOT_FRICTION,
12 onSwipeableOpenStartDrag,12 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 => {
14 }).onFinalize(() => {35 }).onFinalize(() => {
15 dragStarted.value = false;36 dragStarted.value = false;
16 });37 });
...@@ -27,10 +48,10 @@ index 551ab92bf58db0b79d428dcd2f2df898ef686493..f1c355b8e40cd4f583ef3a501b044fb6...@@ -27,10 +48,10 @@ index 551ab92bf58db0b79d428dcd2f2df898ef686493..f1c355b8e40cd4f583ef3a501b044fb6
27 const animatedStyle = (0, _reactNativeReanimated.useAnimatedStyle)(() => ({48 const animatedStyle = (0, _reactNativeReanimated.useAnimatedStyle)(() => ({
28 transform: [{49 transform: [{
29diff --git a/lib/module/components/ReanimatedSwipeable/ReanimatedSwipeable.js b/lib/module/components/ReanimatedSwipeable/ReanimatedSwipeable.js50diff --git a/lib/module/components/ReanimatedSwipeable/ReanimatedSwipeable.js b/lib/module/components/ReanimatedSwipeable/ReanimatedSwipeable.js
30index a2835d5416ffd5cf9a04e98774516b9e6569691e..b73c177227bf3a232737b0eb1c0e2bb830493c22 10064451index a2835d5416ffd5cf9a04e98774516b9e6569691e..055afce410d6eb655532e7a09371dfc22eae5e9c 100644
31--- a/lib/module/components/ReanimatedSwipeable/ReanimatedSwipeable.js52--- a/lib/module/components/ReanimatedSwipeable/ReanimatedSwipeable.js
32+++ b/lib/module/components/ReanimatedSwipeable/ReanimatedSwipeable.js53+++ b/lib/module/components/ReanimatedSwipeable/ReanimatedSwipeable.js
33@@ -29,6 +29,7 @@ const Swipeable = props => {54@@ -29,11 +29,13 @@ const Swipeable = props => {
34 enableTrackpadTwoFingerGesture = DEFAULT_ENABLE_TRACKING_TWO_FINGER_GESTURE,55 enableTrackpadTwoFingerGesture = DEFAULT_ENABLE_TRACKING_TWO_FINGER_GESTURE,
35 dragOffsetFromLeftEdge = DEFAULT_DRAG_OFFSET,56 dragOffsetFromLeftEdge = DEFAULT_DRAG_OFFSET,
36 dragOffsetFromRightEdge = DEFAULT_DRAG_OFFSET,57 dragOffsetFromRightEdge = DEFAULT_DRAG_OFFSET,
...@@ -38,7 +59,28 @@ index a2835d5416ffd5cf9a04e98774516b9e6569691e..b73c177227bf3a232737b0eb1c0e2bb8...@@ -38,7 +59,28 @@ index a2835d5416ffd5cf9a04e98774516b9e6569691e..b73c177227bf3a232737b0eb1c0e2bb8
38 friction = DEFAULT_FRICTION,59 friction = DEFAULT_FRICTION,
39 overshootFriction = DEFAULT_OVERSHOOT_FRICTION,60 overshootFriction = DEFAULT_OVERSHOOT_FRICTION,
40 onSwipeableOpenStartDrag,61 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 => {
42 }).onFinalize(() => {84 }).onFinalize(() => {
43 dragStarted.value = false;85 dragStarted.value = false;
44 });86 });
...@@ -55,7 +97,7 @@ index a2835d5416ffd5cf9a04e98774516b9e6569691e..b73c177227bf3a232737b0eb1c0e2bb8...@@ -55,7 +97,7 @@ index a2835d5416ffd5cf9a04e98774516b9e6569691e..b73c177227bf3a232737b0eb1c0e2bb8
55 const animatedStyle = useAnimatedStyle(() => ({97 const animatedStyle = useAnimatedStyle(() => ({
56 transform: [{98 transform: [{
57diff --git a/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts b/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts99diff --git a/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts b/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts
58index ac8b76830d468edfbc29052b452a36221323c3de..589e329d2aa706ddfff0d1037df0d85a050edbb0 100644100index ac8b76830d468edfbc29052b452a36221323c3de..6985d54d9d359e825a5a0e6078bb113204e2807b 100644
59--- a/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts101--- a/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts
60+++ b/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts102+++ b/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts
61@@ -64,6 +64,13 @@ export interface SwipeableProps {103@@ -64,6 +64,13 @@ export interface SwipeableProps {
...@@ -72,11 +114,25 @@ index ac8b76830d468edfbc29052b452a36221323c3de..589e329d2aa706ddfff0d1037df0d85a...@@ -72,11 +114,25 @@ index ac8b76830d468edfbc29052b452a36221323c3de..589e329d2aa706ddfff0d1037df0d85a
72 /**114 /**
73 * Value indicating if the swipeable panel can be pulled further than the left115 * Value indicating if the swipeable panel can be pulled further than the left
74 * actions panel's width. It is set to true by default as long as the left116 * 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 */
75diff --git a/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx b/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx131diff --git a/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx b/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx
76index b6134c908624adc590ebd264e90e558b88e235d5..41535348e937ad7762bd531d5b63ba6e092ea997 100644132index b6134c908624adc590ebd264e90e558b88e235d5..fa4b1c1749bd0e86cbf5f5546f295d8099e1a481 100644
77--- a/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx133--- a/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx
78+++ b/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx134+++ b/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx
79@@ -58,6 +58,7 @@ const Swipeable = (props: SwipeableProps) => {135@@ -58,11 +58,13 @@ const Swipeable = (props: SwipeableProps) => {
80 enableTrackpadTwoFingerGesture = DEFAULT_ENABLE_TRACKING_TWO_FINGER_GESTURE,136 enableTrackpadTwoFingerGesture = DEFAULT_ENABLE_TRACKING_TWO_FINGER_GESTURE,
81 dragOffsetFromLeftEdge = DEFAULT_DRAG_OFFSET,137 dragOffsetFromLeftEdge = DEFAULT_DRAG_OFFSET,
82 dragOffsetFromRightEdge = DEFAULT_DRAG_OFFSET,138 dragOffsetFromRightEdge = DEFAULT_DRAG_OFFSET,
...@@ -84,7 +140,34 @@ index b6134c908624adc590ebd264e90e558b88e235d5..41535348e937ad7762bd531d5b63ba6e...@@ -84,7 +140,34 @@ index b6134c908624adc590ebd264e90e558b88e235d5..41535348e937ad7762bd531d5b63ba6e
84 friction = DEFAULT_FRICTION,140 friction = DEFAULT_FRICTION,
85 overshootFriction = DEFAULT_OVERSHOOT_FRICTION,141 overshootFriction = DEFAULT_OVERSHOOT_FRICTION,
86 onSwipeableOpenStartDrag,142 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) => {
88 dragStarted.value = false;171 dragStarted.value = false;
89 });172 });
90 173
...@@ -95,7 +178,7 @@ index b6134c908624adc590ebd264e90e558b88e235d5..41535348e937ad7762bd531d5b63ba6e...@@ -95,7 +178,7 @@ index b6134c908624adc590ebd264e90e558b88e235d5..41535348e937ad7762bd531d5b63ba6e
95 Object.entries(relationProps).forEach(([relationName, relation]) => {178 Object.entries(relationProps).forEach(([relationName, relation]) => {
96 applyRelationProp(179 applyRelationProp(
97 pan,180 pan,
98@@ -552,6 +557,7 @@ const Swipeable = (props: SwipeableProps) => {181@@ -552,6 +565,7 @@ const Swipeable = (props: SwipeableProps) => {
99 enableTrackpadTwoFingerGesture,182 enableTrackpadTwoFingerGesture,
100 dragOffsetFromRightEdge,183 dragOffsetFromRightEdge,
101 dragOffsetFromLeftEdge,184 dragOffsetFromLeftEdge,
...@@ -104,7 +187,7 @@ index b6134c908624adc590ebd264e90e558b88e235d5..41535348e937ad7762bd531d5b63ba6e...@@ -104,7 +187,7 @@ index b6134c908624adc590ebd264e90e558b88e235d5..41535348e937ad7762bd531d5b63ba6e
104 relationProps,187 relationProps,
105 userDrag,188 userDrag,
106diff --git a/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts b/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts189diff --git a/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts b/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts
107index 0c0e517e0d340faf50ff78c3d48e7a2bbcf808ec..d4b6ff508077728c8be83762923d866dc95b144c 100644190index 0c0e517e0d340faf50ff78c3d48e7a2bbcf808ec..3ab14d240da346b1927792e480f5b1e902b03bf8 100644
108--- a/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts191--- a/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts
109+++ b/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts192+++ b/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts
110@@ -77,6 +77,14 @@ export interface SwipeableProps {193@@ -77,6 +77,14 @@ export interface SwipeableProps {
...@@ -122,3 +205,17 @@ index 0c0e517e0d340faf50ff78c3d48e7a2bbcf808ec..d4b6ff508077728c8be83762923d866d...@@ -122,3 +205,17 @@ index 0c0e517e0d340faf50ff78c3d48e7a2bbcf808ec..d4b6ff508077728c8be83762923d866d
122 /**205 /**
123 * Value indicating if the swipeable panel can be pulled further than the left206 * Value indicating if the swipeable panel can be pulled further than the left
124 * actions panel's width. It is set to true by default as long as the left207 * 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:...@@ -99,7 +99,7 @@ patchedDependencies:
99 effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb699 effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6
100 expo-audio@57.0.4: fa9a3e0442ed395d4071bb406e08c3a471c9a84700bdfa0b9ad7ff144c96041a100 expo-audio@57.0.4: fa9a3e0442ed395d4071bb406e08c3a471c9a84700bdfa0b9ad7ff144c96041a
101 expo-sharing@57.0.17: 8d2e3b10eb3f52036a9a086800180ec6cebf3b75bccc5b1775117a7244d4ac45101 expo-sharing@57.0.17: 8d2e3b10eb3f52036a9a086800180ec6cebf3b75bccc5b1775117a7244d4ac45
102 react-native-gesture-handler@2.32.0: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3102 react-native-gesture-handler@2.32.0: 96573c000f7fe56b5abfa13e2e5f0d065907e674cb8e2300155226d7c9874398
103 react-native-keyboard-controller@1.21.13: 6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787103 react-native-keyboard-controller@1.21.13: 6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787
104 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675104 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675
105 react-native-screens@4.26.2: 8156dd0f3407822404793cfdaa95639a36b62102f4507c981b8be83600bb382d105 react-native-screens@4.26.2: 8156dd0f3407822404793cfdaa95639a36b62102f4507c981b8be83600bb382d
...@@ -410,7 +410,7 @@ importers:...@@ -410,7 +410,7 @@ importers:
410 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)410 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)
411 react-native-gesture-handler:411 react-native-gesture-handler:
412 specifier: ~2.32.0412 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)
414 react-native-image-viewing:414 react-native-image-viewing:
415 specifier: ^0.2.2415 specifier: ^0.2.2
416 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)416 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:...@@ -20045,7 +20045,7 @@ snapshots:
20045 transitivePeerDependencies:20045 transitivePeerDependencies:
20046 - supports-color20046 - 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):
20049 dependencies:20049 dependencies:
20050 '@egjs/hammerjs': 2.0.1720050 '@egjs/hammerjs': 2.0.17
20051 '@types/react-test-renderer': 19.1.020051 '@types/react-test-renderer': 19.1.0