Skip to content
Merged
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
17 changes: 17 additions & 0 deletions .changeset/dismiss-direction-interaction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@react-native-motion-kit/swipe-deck': minor
---

Add `interaction.dismissDirection` to `useDeckInteraction()` so UI-thread consumers can read the accepted dismiss side without waiting for JS swipe events or inferring from raw drag direction.

```tsx
function DeckDismissFeedback() {
const { dismissDirection, phase } = ProfileDeck.useDeckInteraction();

const rightStyle = useAnimatedStyle(() => ({
opacity: phase.get() === 'dismissing' && dismissDirection.get() === 'right' ? 1 : 0,
}));

return <Animated.View style={rightStyle} />;
}
```
7 changes: 6 additions & 1 deletion README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,8 +272,13 @@ function ProfileDeckScreen() {
`useDeckEvent` / `useDeckEventListener`를 사용하세요.
- `dismissing`은 dismiss가 accepted된 뒤 다음 item commit과 interaction reset이 끝날 때까지의
lifecycle을 포함합니다. 즉, 화면 밖으로 나가는 animation frame만 의미하지는 않습니다.
- `interaction.direction`은 raw live drag/dismiss signal입니다(`-1 | 0 | 1`).
외부 UI가 UI thread에서 accepted dismiss 방향(`left | right | null`)을 알아야 한다면
`interaction.dismissDirection`을 사용하세요. `dismissDirection`은 lifecycle state이지,
commit된 `swipe` event payload가 아닙니다.
- programmatic springboard action은 action이 받아들여지는 즉시 `dismissing`으로 들어갑니다.
anticipation 중에는 실제 dismiss phase가 시작되기 전까지 `interaction.direction`이 neutral일 수 있습니다.
anticipation 중에는 실제 dismiss phase가 시작되기 전까지 `interaction.direction`이 neutral일 수 있지만,
`interaction.dismissDirection`에는 이미 accepted action direction이 들어있습니다.
- `useDeckEvent(eventName, initialValue?, id?)`는 React 렌더링용 최신 commit event를 반환합니다.
첫 event 전과 deck이 detach된 뒤에는 `undefined` 또는 `initialValue`를 반환합니다.
- `useDeckEventListener(eventName, listener, id?)`는 앱 코드에서 별도 state를 만들지 않고 commit된 model event를 구독합니다.
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,8 +277,13 @@ function ProfileDeckScreen() {
- `dismissing` covers the accepted dismiss lifecycle until the deck has committed the next item
and reset interaction values. It is intentionally broader than only the offscreen animation
frames.
- `interaction.direction` is the raw live drag/dismiss signal (`-1 | 0 | 1`). Use
`interaction.dismissDirection` when external UI needs the accepted dismiss side
(`left | right | null`) on the UI thread. `dismissDirection` is lifecycle state, not the
committed `swipe` event payload.
- Programmatic springboard actions enter `dismissing` as soon as the action is accepted. During
anticipation, `interaction.direction` can still be neutral until the real dismiss phase starts.
anticipation, `interaction.direction` can still be neutral until the real dismiss phase starts,
while `interaction.dismissDirection` already contains the accepted action direction.
- `useDeckEvent(eventName, initialValue?, id?)` returns the latest committed deck event for
React-rendered UI. It returns `undefined` or `initialValue` before the first event and after the
deck detaches.
Expand Down
26 changes: 25 additions & 1 deletion example/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ function SwipeReactionOverlay() {
}

function DeckPhaseFeedback() {
const { phase } = ProfileDeck.useDeckInteraction();
const { dismissDirection, phase } = ProfileDeck.useDeckInteraction();

const idleStyle = useAnimatedStyle(() => {
return {
Expand All @@ -123,6 +123,18 @@ function DeckPhaseFeedback() {
};
});

const leftDismissStyle = useAnimatedStyle(() => {
return {
opacity: dismissDirection.get() === 'left' ? 1 : 0.24,
};
});

const rightDismissStyle = useAnimatedStyle(() => {
return {
opacity: dismissDirection.get() === 'right' ? 1 : 0.24,
};
});

return (
<View pointerEvents="none" style={styles.phaseFeedback}>
<Animated.View style={[styles.phasePill, styles.idlePhasePill, idleStyle]}>
Expand All @@ -137,6 +149,12 @@ function DeckPhaseFeedback() {
<Animated.View style={[styles.phasePill, styles.undoPhasePill, undoingStyle]}>
<Text style={styles.phaseText}>Undo</Text>
</Animated.View>
<Animated.View style={[styles.phasePill, styles.leftDismissPill, leftDismissStyle]}>
<Text style={styles.phaseText}>Left</Text>
</Animated.View>
<Animated.View style={[styles.phasePill, styles.rightDismissPill, rightDismissStyle]}>
<Text style={styles.phaseText}>Right</Text>
</Animated.View>
</View>
);
}
Expand Down Expand Up @@ -414,6 +432,12 @@ const styles = StyleSheet.create({
undoPhasePill: {
backgroundColor: 'rgba(251, 191, 36, 0.82)',
},
leftDismissPill: {
backgroundColor: 'rgba(248, 113, 113, 0.82)',
},
rightDismissPill: {
backgroundColor: 'rgba(74, 222, 128, 0.82)',
},
phaseText: {
color: '#09090b',
fontSize: 10,
Expand Down
52 changes: 34 additions & 18 deletions src/__tests__/SwipeDeck.integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ describe('SwipeDeck factory hooks', () => {
function DeckControls() {
const state = ProfileDeck.useDeckState();
const actions = ProfileDeck.useDeckActions();
const interaction = ProfileDeck.useDeckInteraction();
const [lastActionResult, setLastActionResult] = useState('none');

return (
Expand All @@ -53,7 +54,13 @@ describe('SwipeDeck factory hooks', () => {
<Pressable
accessibilityLabel="Force swipe right"
accessibilityRole="button"
onPress={() => setLastActionResult(String(actions.swipeRight()))}
onPress={() => {
const accepted = actions.swipeRight();

setLastActionResult(
`${String(accepted)}:${interaction.dismissDirection.get() ?? 'null'}`,
);
}}
>
<Text>Force swipe right</Text>
</Pressable>
Expand Down Expand Up @@ -94,7 +101,7 @@ describe('SwipeDeck factory hooks', () => {

await user.press(screen.getByRole('button', { name: 'Force swipe right' }));

expect(screen.getByText('action:false')).toBeOnTheScreen();
expect(screen.getByText('action:false:null')).toBeOnTheScreen();

await measureDeckFromVisibleCard('Ada');

Expand All @@ -103,7 +110,7 @@ describe('SwipeDeck factory hooks', () => {

await user.press(screen.getByRole('button', { name: 'Force swipe right' }));

expect(await screen.findByText('action:true')).toBeOnTheScreen();
expect(await screen.findByText('action:true:right')).toBeOnTheScreen();
expect(await screen.findByText('state:1:2:true:false')).toBeOnTheScreen();
expect(screen.getByText('Grace')).toBeOnTheScreen();
expect(onSwipe).toHaveBeenCalledWith({
Expand Down Expand Up @@ -256,11 +263,11 @@ describe('SwipeDeck factory hooks', () => {
const actions = ProfileDeck.useDeckActions();
const interaction = ProfileDeck.useDeckInteraction();
const [lastActionResult, setLastActionResult] = useState('none');
const [interactionText, setInteractionText] = useState('0:0:0:idle');
const [interactionText, setInteractionText] = useState('0:0:0:null:idle');

useEffect(() => {
setInteractionText(
`${interaction.progress.get()}:${interaction.signedProgress.get()}:${interaction.direction.get()}:${interaction.phase.get()}`,
`${interaction.progress.get()}:${interaction.signedProgress.get()}:${interaction.direction.get()}:${interaction.dismissDirection.get() ?? 'null'}:${interaction.phase.get()}`,
);
}, [interaction, state.activeIndex, state.canSwipe, state.isCompleted]);

Expand Down Expand Up @@ -306,13 +313,13 @@ describe('SwipeDeck factory hooks', () => {
await measureDeckFromVisibleCard('Ada');

expect(await screen.findByText('state:0:2:true:false')).toBeOnTheScreen();
expect(screen.getByText('interaction:0:0:0:idle')).toBeOnTheScreen();
expect(screen.getByText('interaction:0:0:0:null:idle')).toBeOnTheScreen();

await user.press(screen.getByRole('button', { name: 'Force swipe right' }));

expect(await screen.findByText('action:true')).toBeOnTheScreen();
expect(await screen.findByText('state:1:2:true:false')).toBeOnTheScreen();
expect(screen.getByText('interaction:0:0:0:idle')).toBeOnTheScreen();
expect(screen.getByText('interaction:0:0:0:null:idle')).toBeOnTheScreen();
expect(screen.getByText('Grace')).toBeOnTheScreen();
expect(onSwipe).toHaveBeenCalledTimes(1);
expect(onSwipe).toHaveBeenCalledWith({
Expand Down Expand Up @@ -493,7 +500,7 @@ describe('SwipeDeck factory hooks', () => {
const accepted = actions.swipeRight();

setProbe(
`${String(accepted)}:${interaction.direction.get()}:${interaction.phase.get()}`,
`${String(accepted)}:${interaction.direction.get()}:${interaction.dismissDirection.get() ?? 'null'}:${interaction.phase.get()}`,
);
}}
>
Expand Down Expand Up @@ -531,7 +538,7 @@ describe('SwipeDeck factory hooks', () => {

await user.press(screen.getByRole('button', { name: 'Probe swipe right' }));

expect(await screen.findByText('probe:true:1:dismissing')).toBeOnTheScreen();
expect(await screen.findByText('probe:true:1:right:dismissing')).toBeOnTheScreen();
expect(screen.getByText('Grace')).toBeOnTheScreen();

await renderResult.rerender(
Expand All @@ -540,7 +547,7 @@ describe('SwipeDeck factory hooks', () => {

await user.press(screen.getByRole('button', { name: 'Probe swipe right' }));

expect(await screen.findByText('probe:true:0:dismissing')).toBeOnTheScreen();
expect(await screen.findByText('probe:true:0:right:dismissing')).toBeOnTheScreen();
expect(screen.getByText('Linus')).toBeOnTheScreen();
});

Expand All @@ -560,11 +567,11 @@ describe('SwipeDeck factory hooks', () => {
const actions = ProfileDeck.useDeckActions();
const interaction = ProfileDeck.useDeckInteraction();
const [lastActionResult, setLastActionResult] = useState('none');
const [interactionText, setInteractionText] = useState('0:0:0:idle');
const [interactionText, setInteractionText] = useState('0:0:0:null:idle');

useEffect(() => {
setInteractionText(
`${interaction.progress.get()}:${interaction.signedProgress.get()}:${interaction.direction.get()}:${interaction.phase.get()}`,
`${interaction.progress.get()}:${interaction.signedProgress.get()}:${interaction.direction.get()}:${interaction.dismissDirection.get() ?? 'null'}:${interaction.phase.get()}`,
);
}, [interaction, state.activeIndex, state.canUndo]);

Expand Down Expand Up @@ -648,7 +655,7 @@ describe('SwipeDeck factory hooks', () => {

expect(await screen.findByText('state:0:true:false:false')).toBeOnTheScreen();
expect(screen.getByText('Ada')).toBeOnTheScreen();
expect(screen.getByText('interaction:0:0:0:idle')).toBeOnTheScreen();
expect(screen.getByText('interaction:0:0:0:null:idle')).toBeOnTheScreen();
expect(onSwipe).toHaveBeenCalledTimes(1);
expect(onUndo).toHaveBeenCalledWith({
direction: 'right',
Expand All @@ -669,10 +676,16 @@ describe('SwipeDeck factory hooks', () => {
it('tracks undo history from committed pan gestures when undo is enabled', async () => {
const ProfileDeck = createSwipeDeck<Profile>();
const user = userEvent.setup();
const latestInteraction: {
current: ReturnType<typeof ProfileDeck.useDeckInteraction> | null;
} = { current: null };

function DeckControls() {
const state = ProfileDeck.useDeckState();
const actions = ProfileDeck.useDeckActions();
const interaction = ProfileDeck.useDeckInteraction();

latestInteraction.current = interaction;

return (
<View>
Expand Down Expand Up @@ -707,6 +720,7 @@ describe('SwipeDeck factory hooks', () => {
{ state: 5, translationX: 180, translationY: 0, velocityX: 0, y: 250 },
]);

expect(latestInteraction.current?.dismissDirection.get()).toBe('right');
expect(await screen.findByText('state:1:true:true:false')).toBeOnTheScreen();
expect(screen.getByText('Grace')).toBeOnTheScreen();

Expand Down Expand Up @@ -750,7 +764,9 @@ describe('SwipeDeck factory hooks', () => {
}),
);

setProbe(`${String(accepted)}:${interaction.phase.get()}`);
setProbe(
`${String(accepted)}:${interaction.dismissDirection.get() ?? 'null'}:${interaction.phase.get()}`,
);
}}
>
<Text>Probe undo</Text>
Expand Down Expand Up @@ -779,7 +795,7 @@ describe('SwipeDeck factory hooks', () => {

await user.press(screen.getByRole('button', { name: 'Probe undo' }));

expect(await screen.findByText('probe:true:undoing')).toBeOnTheScreen();
expect(await screen.findByText('probe:true:null:undoing')).toBeOnTheScreen();
expect(await screen.findByText('state:0:true:false:false')).toBeOnTheScreen();
});

Expand All @@ -790,11 +806,11 @@ describe('SwipeDeck factory hooks', () => {
function DeckControls() {
const state = ProfileDeck.useDeckState();
const interaction = ProfileDeck.useDeckInteraction();
const [interactionText, setInteractionText] = useState('0:0:0:idle');
const [interactionText, setInteractionText] = useState('0:0:0:null:idle');

useEffect(() => {
setInteractionText(
`${interaction.progress.get()}:${interaction.signedProgress.get()}:${interaction.direction.get()}:${interaction.phase.get()}`,
`${interaction.progress.get()}:${interaction.signedProgress.get()}:${interaction.direction.get()}:${interaction.dismissDirection.get() ?? 'null'}:${interaction.phase.get()}`,
);
}, [interaction, state.activeIndex, state.canSwipe, state.canUndo]);

Expand Down Expand Up @@ -837,7 +853,7 @@ describe('SwipeDeck factory hooks', () => {
]);

expect(await screen.findByText('state:0:true:false:false')).toBeOnTheScreen();
expect(screen.getByText('interaction:0:0:0:idle')).toBeOnTheScreen();
expect(screen.getByText('interaction:0:0:0:null:idle')).toBeOnTheScreen();
expect(screen.getByText('Ada')).toBeOnTheScreen();
expect(onSwipe).not.toHaveBeenCalled();
});
Expand Down
3 changes: 3 additions & 0 deletions src/__tests__/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ describe('createSwipeDeckRegistry', () => {
expect(registry.getStore('profiles').actions).toBe(store.actions);
expect(registry.getStore('profiles').interaction).toBe(store.interaction);
expect(store.interaction.phase.get()).toBe('idle');
expect(store.interaction.dismissDirection.get()).toBeNull();
});

it('notifies state subscribers only when the snapshot changes', () => {
Expand Down Expand Up @@ -270,6 +271,7 @@ describe('createSwipeDeckRegistry', () => {
store.interaction.progress.set(1);
store.interaction.signedProgress.set(-1);
store.interaction.direction.set(-1);
store.interaction.dismissDirection.set('left');
store.interaction.translationX.set(-120);
store.interaction.translationY.set(24);
store.interaction.isDragging.set(true);
Expand All @@ -280,6 +282,7 @@ describe('createSwipeDeckRegistry', () => {
expect(store.interaction.progress.get()).toBe(0);
expect(store.interaction.signedProgress.get()).toBe(0);
expect(store.interaction.direction.get()).toBe(0);
expect(store.interaction.dismissDirection.get()).toBeNull();
expect(store.interaction.translationX.get()).toBe(0);
expect(store.interaction.translationY.get()).toBe(0);
expect(store.interaction.isDragging.get()).toBe(false);
Expand Down
4 changes: 4 additions & 0 deletions src/components/SwipeDeck.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ function Root<T>({
const swipeProgress = interaction.progress;
const signedSwipeProgress = interaction.signedProgress;
const swipeDirectionSignal = interaction.direction;
const dismissDirection = interaction.dismissDirection;
const activeTranslateX = interaction.translationX;
const activeTranslateY = interaction.translationY;
const isDragging = interaction.isDragging;
Expand Down Expand Up @@ -231,6 +232,7 @@ function Root<T>({
data,
disabledRef,
dismissRuntimeRef,
dismissDirection,
dragItemIndex,
endReachedRef,
gestureStartYRatio,
Expand Down Expand Up @@ -268,6 +270,7 @@ function Root<T>({
dataRef,
disabledRef,
dismissRuntimeRef,
dismissDirection,
dragItemIndex,
endReachedRef,
gestureStartYRatio,
Expand Down Expand Up @@ -299,6 +302,7 @@ function Root<T>({
dismissMaxDuration,
dismissMinDuration,
dismissOffscreenMultiplier,
dismissDirection,
dragItemIndex,
gestureStartYRatio,
hasActiveCard,
Expand Down
23 changes: 23 additions & 0 deletions src/core/swipeDeckRuntime.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { SharedValue } from 'react-native-reanimated';

import type {
SwipeDeckActionMotionRecipe,
SwipeDeckLayout,
Expand Down Expand Up @@ -42,6 +44,13 @@ type ResolveSwipeDeckProgrammaticUndoMotionArgs = {
undoMotion?: SwipeDeckUndoMotionRecipe;
};

type ResetSwipeDeckInteractionSignalsArgs = {
dismissDirection: SharedValue<SwipeDirection | null>;
signedSwipeProgress: SharedValue<number>;
swipeDirectionSignal: SharedValue<-1 | 0 | 1>;
swipeProgress: SharedValue<number>;
};

export function resolveProgressDirection(translationX: number): -1 | 0 | 1 {
'worklet';

Expand All @@ -64,6 +73,20 @@ export function resolveSignedSwipeProgress(translationX: number, distance: numbe
return direction * Math.min(Math.abs(translationX) / Math.max(distance, 1), 1);
}

export function resetSwipeDeckInteractionSignals({
dismissDirection,
signedSwipeProgress,
swipeDirectionSignal,
swipeProgress,
}: ResetSwipeDeckInteractionSignalsArgs) {
'worklet';

swipeProgress.set(0);
signedSwipeProgress.set(0);
swipeDirectionSignal.set(0);
dismissDirection.set(null);
}

export function getActiveRenderItemId(dataLength: number, activeIndex: number): number {
if (activeIndex < 0 || activeIndex >= dataLength) {
return -1;
Expand Down
Loading