Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 36 additions & 20 deletions src/components/SwipeableRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,15 @@ export function SwipeableRow({
const claimed = useRef(false);
const widthRef = useRef(Dimensions.get('window').width);

const leftActionRef = useRef(leftAction);
leftActionRef.current = leftAction;
const rightActionRef = useRef(rightAction);
rightActionRef.current = rightAction;
const modeRef = useRef(mode);
modeRef.current = mode;
const onActionRef = useRef(onAction);
onActionRef.current = onAction;

// Reveal-mode-only state: which side (if any) is currently sitting open.
const openSideRef = useRef<'left' | 'right' | null>(null);
const [openSide, setOpenSide] = useState<'left' | 'right' | null>(null);
Expand Down Expand Up @@ -101,13 +110,13 @@ export function SwipeableRow({
duration: 180,
useNativeDriver: true,
}).start(() => {
onAction(action);
onActionRef.current(action);
// The row is about to be removed; reset translation in case it isn't
// (e.g. the action failed silently) so we don't leave it off-screen.
dx.setValue(0);
});
} else {
onAction(action);
onActionRef.current(action);
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
}
};
Expand All @@ -118,9 +127,9 @@ export function SwipeableRow({
if (exitsRow(action)) {
// Let the row collapse a frame, then fire so the parent's list update
// has a clean starting point.
requestAnimationFrame(() => onAction(action));
requestAnimationFrame(() => onActionRef.current(action));
} else {
onAction(action);
onActionRef.current(action);
}
};

Expand All @@ -130,22 +139,26 @@ export function SwipeableRow({
onMoveShouldSetPanResponder: (_, g) => {
if (Math.abs(g.dx) < MIN_DX_TO_CLAIM) return false;
if (Math.abs(g.dx) < Math.abs(g.dy) * DIRECTION_BIAS) return false;
const currentMode = modeRef.current;
const currentRightAction = rightActionRef.current;
const currentLeftAction = leftActionRef.current;
// Reveal mode: if a side is already open, allow the gesture so the
// user can drag it closed.
if (mode === 'reveal' && openSideRef.current === null) {
if (g.dx > 0 && rightAction === 'none') return false;
if (g.dx < 0 && leftAction === 'none') return false;
} else if (mode !== 'reveal') {
if (g.dx > 0 && rightAction === 'none') return false;
if (g.dx < 0 && leftAction === 'none') return false;
if (currentMode === 'reveal' && openSideRef.current === null) {
if (g.dx > 0 && currentRightAction === 'none') return false;
if (g.dx < 0 && currentLeftAction === 'none') return false;
} else if (currentMode !== 'reveal') {
if (g.dx > 0 && currentRightAction === 'none') return false;
if (g.dx < 0 && currentLeftAction === 'none') return false;
}
claimed.current = true;
return true;
},
onPanResponderMove: (_, g) => {
const overshoot = mode === 'reveal' ? MAX_DRAG_OVERSHOOT_REVEAL : MAX_DRAG_OVERSHOOT_INSTANT;
const currentMode = modeRef.current;
const overshoot = currentMode === 'reveal' ? MAX_DRAG_OVERSHOOT_REVEAL : MAX_DRAG_OVERSHOOT_INSTANT;
const base =
mode === 'reveal'
currentMode === 'reveal'
? openSideRef.current === 'right' ? REVEAL_WIDTH
: openSideRef.current === 'left' ? -REVEAL_WIDTH
: 0
Expand All @@ -156,32 +169,35 @@ export function SwipeableRow({
},
onPanResponderRelease: (_, g) => {
claimed.current = false;
if (mode === 'reveal') {
const currentMode = modeRef.current;
const currentRightAction = rightActionRef.current;
const currentLeftAction = leftActionRef.current;
if (currentMode === 'reveal') {
const base =
openSideRef.current === 'right' ? REVEAL_WIDTH
: openSideRef.current === 'left' ? -REVEAL_WIDTH
: 0;
const finalDx = base + g.dx;
if (finalDx > ACTIVATION_THRESHOLD && rightAction !== 'none') {
if (finalDx > ACTIVATION_THRESHOLD && currentRightAction !== 'none') {
openTo('right');
} else if (finalDx < -ACTIVATION_THRESHOLD && leftAction !== 'none') {
} else if (finalDx < -ACTIVATION_THRESHOLD && currentLeftAction !== 'none') {
openTo('left');
} else {
close();
}
return;
}
// Instant mode: fire on release-past-threshold, no second tap.
if ((g.dx >= COMMIT_THRESHOLD || (g.dx > 40 && g.vx >= 0.5)) && rightAction !== 'none') {
fly(widthRef.current || EXIT_DISTANCE, rightAction);
} else if ((g.dx <= -COMMIT_THRESHOLD || (g.dx < -40 && g.vx <= -0.5)) && leftAction !== 'none') {
fly(-(widthRef.current || EXIT_DISTANCE), leftAction);
if ((g.dx >= COMMIT_THRESHOLD || (g.dx > 40 && g.vx >= 0.5)) && currentRightAction !== 'none') {
fly(widthRef.current || EXIT_DISTANCE, currentRightAction);
} else if ((g.dx <= -COMMIT_THRESHOLD || (g.dx < -40 && g.vx <= -0.5)) && currentLeftAction !== 'none') {
fly(-(widthRef.current || EXIT_DISTANCE), currentLeftAction);
} else {
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
}
},
onPanResponderTerminate: () => {
if (mode === 'reveal') close();
if (modeRef.current === 'reveal') close();
else Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
claimed.current = false;
},
Expand Down
30 changes: 30 additions & 0 deletions src/components/__tests__/SwipeableRow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, it, expect, vi } from 'vitest';

describe('SwipeableRow logic', () => {
it('should use mutable refs so updated props are immediately reflected in gesture callbacks', () => {
let leftAction = 'read';
let rightAction = 'archive';
const onAction = vi.fn();

const leftActionRef = { current: leftAction };
const rightActionRef = { current: rightAction };
const onActionRef = { current: onAction };

const fireAction = (side: 'left' | 'right') => {
const action = side === 'left' ? leftActionRef.current : rightActionRef.current;
onActionRef.current(action);
};

// Initially left action is 'read'
fireAction('left');
expect(onAction).toHaveBeenCalledWith('read');

// Simulate prop change on re-render (e.g. user updated setting to 'delete')
leftAction = 'delete';
leftActionRef.current = leftAction;

// Call gesture release handler again without re-creating gesture instance
fireAction('left');
expect(onAction).toHaveBeenLastCalledWith('delete');
});
});