From c5dc59e431f2c8296f7a879ac2c701a7b540aa4a Mon Sep 17 00:00:00 2001 From: harang Date: Tue, 16 Jun 2026 12:18:19 +0900 Subject: [PATCH] feat: expose dismiss direction interaction Add a UI-thread dismissDirection shared value. This separates accepted dismiss side from raw drag direction. Constraint: Preserve gesture, action, undo, and event semantics. Rejected: Reuse interaction.direction | live raw signal. Rejected: Add committedDirection | implies JS commit timing. Confidence: high Scope-risk: moderate Directive: Do not clear dismissDirection inside completeSwipeDismiss. Tested: yarn lint Tested: yarn typecheck Tested: yarn test --runInBand --watchman=false Tested: yarn format:check Tested: git diff --check --- .changeset/dismiss-direction-interaction.md | 17 +++++++ README.ko.md | 7 ++- README.md | 7 ++- example/App.tsx | 26 +++++++++- src/__tests__/SwipeDeck.integration.test.tsx | 52 +++++++++++++------- src/__tests__/registry.test.ts | 3 ++ src/components/SwipeDeck.tsx | 4 ++ src/core/swipeDeckRuntime.ts | 23 +++++++++ src/hooks/useSwipeDeckDismissRuntime.ts | 19 +++++-- src/hooks/useSwipeDeckGestureRuntime.ts | 6 +++ src/hooks/useSwipeDeckUndoRuntime.ts | 52 ++++++++++++++------ src/registry/registry.ts | 2 + src/types.ts | 7 +++ 13 files changed, 185 insertions(+), 40 deletions(-) create mode 100644 .changeset/dismiss-direction-interaction.md diff --git a/.changeset/dismiss-direction-interaction.md b/.changeset/dismiss-direction-interaction.md new file mode 100644 index 0000000..51c417e --- /dev/null +++ b/.changeset/dismiss-direction-interaction.md @@ -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 ; +} +``` diff --git a/README.ko.md b/README.ko.md index 53103be..8d6c698 100644 --- a/README.ko.md +++ b/README.ko.md @@ -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를 구독합니다. diff --git a/README.md b/README.md index acbc4d4..21554ff 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/example/App.tsx b/example/App.tsx index 43a167a..c60eb10 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -97,7 +97,7 @@ function SwipeReactionOverlay() { } function DeckPhaseFeedback() { - const { phase } = ProfileDeck.useDeckInteraction(); + const { dismissDirection, phase } = ProfileDeck.useDeckInteraction(); const idleStyle = useAnimatedStyle(() => { return { @@ -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 ( @@ -137,6 +149,12 @@ function DeckPhaseFeedback() { Undo + + Left + + + Right + ); } @@ -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, diff --git a/src/__tests__/SwipeDeck.integration.test.tsx b/src/__tests__/SwipeDeck.integration.test.tsx index f594c1e..0d61519 100644 --- a/src/__tests__/SwipeDeck.integration.test.tsx +++ b/src/__tests__/SwipeDeck.integration.test.tsx @@ -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 ( @@ -53,7 +54,13 @@ describe('SwipeDeck factory hooks', () => { setLastActionResult(String(actions.swipeRight()))} + onPress={() => { + const accepted = actions.swipeRight(); + + setLastActionResult( + `${String(accepted)}:${interaction.dismissDirection.get() ?? 'null'}`, + ); + }} > Force swipe right @@ -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'); @@ -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({ @@ -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]); @@ -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({ @@ -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()}`, ); }} > @@ -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( @@ -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(); }); @@ -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]); @@ -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', @@ -669,10 +676,16 @@ describe('SwipeDeck factory hooks', () => { it('tracks undo history from committed pan gestures when undo is enabled', async () => { const ProfileDeck = createSwipeDeck(); const user = userEvent.setup(); + const latestInteraction: { + current: ReturnType | null; + } = { current: null }; function DeckControls() { const state = ProfileDeck.useDeckState(); const actions = ProfileDeck.useDeckActions(); + const interaction = ProfileDeck.useDeckInteraction(); + + latestInteraction.current = interaction; return ( @@ -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(); @@ -750,7 +764,9 @@ describe('SwipeDeck factory hooks', () => { }), ); - setProbe(`${String(accepted)}:${interaction.phase.get()}`); + setProbe( + `${String(accepted)}:${interaction.dismissDirection.get() ?? 'null'}:${interaction.phase.get()}`, + ); }} > Probe undo @@ -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(); }); @@ -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]); @@ -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(); }); diff --git a/src/__tests__/registry.test.ts b/src/__tests__/registry.test.ts index 44b72ca..a3cb26c 100644 --- a/src/__tests__/registry.test.ts +++ b/src/__tests__/registry.test.ts @@ -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', () => { @@ -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); @@ -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); diff --git a/src/components/SwipeDeck.tsx b/src/components/SwipeDeck.tsx index 60d3cf6..a7aacf8 100644 --- a/src/components/SwipeDeck.tsx +++ b/src/components/SwipeDeck.tsx @@ -87,6 +87,7 @@ function Root({ 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; @@ -231,6 +232,7 @@ function Root({ data, disabledRef, dismissRuntimeRef, + dismissDirection, dragItemIndex, endReachedRef, gestureStartYRatio, @@ -268,6 +270,7 @@ function Root({ dataRef, disabledRef, dismissRuntimeRef, + dismissDirection, dragItemIndex, endReachedRef, gestureStartYRatio, @@ -299,6 +302,7 @@ function Root({ dismissMaxDuration, dismissMinDuration, dismissOffscreenMultiplier, + dismissDirection, dragItemIndex, gestureStartYRatio, hasActiveCard, diff --git a/src/core/swipeDeckRuntime.ts b/src/core/swipeDeckRuntime.ts index ee1ccde..69317d9 100644 --- a/src/core/swipeDeckRuntime.ts +++ b/src/core/swipeDeckRuntime.ts @@ -1,3 +1,5 @@ +import type { SharedValue } from 'react-native-reanimated'; + import type { SwipeDeckActionMotionRecipe, SwipeDeckLayout, @@ -42,6 +44,13 @@ type ResolveSwipeDeckProgrammaticUndoMotionArgs = { undoMotion?: SwipeDeckUndoMotionRecipe; }; +type ResetSwipeDeckInteractionSignalsArgs = { + dismissDirection: SharedValue; + signedSwipeProgress: SharedValue; + swipeDirectionSignal: SharedValue<-1 | 0 | 1>; + swipeProgress: SharedValue; +}; + export function resolveProgressDirection(translationX: number): -1 | 0 | 1 { 'worklet'; @@ -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; diff --git a/src/hooks/useSwipeDeckDismissRuntime.ts b/src/hooks/useSwipeDeckDismissRuntime.ts index 9ca8f59..91ff460 100644 --- a/src/hooks/useSwipeDeckDismissRuntime.ts +++ b/src/hooks/useSwipeDeckDismissRuntime.ts @@ -16,7 +16,10 @@ import type { } from '../types'; import { getSwipeCommit, shouldDeferActiveItemSync } from '../core/state'; -import { resolveSwipeDeckProgrammaticActionMotion } from '../core/swipeDeckRuntime'; +import { + resetSwipeDeckInteractionSignals, + resolveSwipeDeckProgrammaticActionMotion, +} from '../core/swipeDeckRuntime'; import { resolveSwipeDeckDismissDestinationDistance, resolveSwipeDeckDismissDuration, @@ -55,6 +58,7 @@ type UseSwipeDeckDismissRuntimeArgs = { dataRef: RefObject; disabledRef: RefObject; dismissRuntimeRef: RefObject; + dismissDirection: SharedValue; dragItemIndex: SharedValue; endReachedRef: RefObject; emitDeckEvent: >( @@ -111,6 +115,7 @@ export function useSwipeDeckDismissRuntime({ dataRef, disabledRef, dismissRuntimeRef, + dismissDirection, dragItemIndex, endReachedRef, emitDeckEvent, @@ -182,9 +187,12 @@ export function useSwipeDeckDismissRuntime({ cancelActiveInteractionAnimations(); activeTranslateX.set(0); activeTranslateY.set(0); - swipeProgress.set(0); - signedSwipeProgress.set(0); - swipeDirectionSignal.set(0); + resetSwipeDeckInteractionSignals({ + dismissDirection, + signedSwipeProgress, + swipeDirectionSignal, + swipeProgress, + }); isDragging.set(false); interactionPhase.set('idle'); gestureStartYRatio.set(0.5); @@ -193,6 +201,7 @@ export function useSwipeDeckDismissRuntime({ activeTranslateX, activeTranslateY, cancelActiveInteractionAnimations, + dismissDirection, dragItemIndex, gestureStartYRatio, isDragging, @@ -273,6 +282,7 @@ export function useSwipeDeckDismissRuntime({ isAnimating.set(true); isDragging.set(true); interactionPhase.set('dismissing'); + dismissDirection.set(direction); applyImmediateRuntimeState(true, true); gestureStartYRatio.set(0.5); dragItemIndex.set(activeItemIndex.get()); @@ -367,6 +377,7 @@ export function useSwipeDeckDismissRuntime({ dataRef, disabledRef, dismissRuntimeRef, + dismissDirection, dragItemIndex, gestureStartYRatio, isAnimating, diff --git a/src/hooks/useSwipeDeckGestureRuntime.ts b/src/hooks/useSwipeDeckGestureRuntime.ts index 5a6a73d..813ca13 100644 --- a/src/hooks/useSwipeDeckGestureRuntime.ts +++ b/src/hooks/useSwipeDeckGestureRuntime.ts @@ -48,6 +48,7 @@ type UseSwipeDeckGestureRuntimeArgs = { dismissMaxDuration: number; dismissMinDuration: number; dismissOffscreenMultiplier: number; + dismissDirection: SharedValue; dragItemIndex: SharedValue; gestureStartYRatio: SharedValue; hasActiveCard: boolean; @@ -85,6 +86,7 @@ export function useSwipeDeckGestureRuntime({ dismissMaxDuration, dismissMinDuration, dismissOffscreenMultiplier, + dismissDirection, dragItemIndex, gestureStartYRatio, hasActiveCard, @@ -122,6 +124,7 @@ export function useSwipeDeckGestureRuntime({ runtimeEventId.set(nextRuntimeEventId); isDragging.set(true); interactionPhase.set('dragging'); + dismissDirection.set(null); swipeDirectionSignal.set(0); signedSwipeProgress.set(0); scheduleOnRN(applyScheduledRuntimeState, nextRuntimeEventId, false, true); @@ -187,6 +190,7 @@ export function useSwipeDeckGestureRuntime({ isAnimating.set(false); isDragging.set(false); interactionPhase.set('idle'); + dismissDirection.set(null); swipeDirectionSignal.set(0); const nextRuntimeEventId = runtimeEventId.get() + 1; @@ -205,6 +209,7 @@ export function useSwipeDeckGestureRuntime({ isAnimating.set(true); isDragging.set(true); interactionPhase.set('dismissing'); + dismissDirection.set(direction); const currentAttachmentGeneration = attachmentGeneration.get(); scheduleOnRN(applyScheduledRuntimeState, runtimeEventId.get(), true, true); @@ -285,6 +290,7 @@ export function useSwipeDeckGestureRuntime({ dismissMaxDuration, dismissMinDuration, dismissOffscreenMultiplier, + dismissDirection, dragItemIndex, gestureStartYRatio, hasActiveCard, diff --git a/src/hooks/useSwipeDeckUndoRuntime.ts b/src/hooks/useSwipeDeckUndoRuntime.ts index 9d5aad5..0a3760d 100644 --- a/src/hooks/useSwipeDeckUndoRuntime.ts +++ b/src/hooks/useSwipeDeckUndoRuntime.ts @@ -17,6 +17,7 @@ import type { import { getActiveRenderItemId, + resetSwipeDeckInteractionSignals, resolveSwipeDeckProgrammaticUndoMotion, } from '../core/swipeDeckRuntime'; import { type ResolvedSwipeDeckUndoMotion } from '../motion/undoMotion'; @@ -77,6 +78,7 @@ type UseSwipeDeckUndoRuntimeArgs = { data: readonly T[]; disabledRef: RefObject; dismissRuntimeRef: RefObject; + dismissDirection: SharedValue; dragItemIndex: SharedValue; endReachedRef: RefObject; emitDeckEvent: >( @@ -126,6 +128,7 @@ export function useSwipeDeckUndoRuntime({ data, disabledRef, dismissRuntimeRef, + dismissDirection, dragItemIndex, endReachedRef, emitDeckEvent, @@ -185,9 +188,12 @@ export function useSwipeDeckUndoRuntime({ setUndoTransition(null); cancelActiveInteractionAnimations(); cancelAnimation(undoProgress); - swipeProgress.set(0); - signedSwipeProgress.set(0); - swipeDirectionSignal.set(0); + resetSwipeDeckInteractionSignals({ + dismissDirection, + signedSwipeProgress, + swipeDirectionSignal, + swipeProgress, + }); activeTranslateX.set(0); activeTranslateY.set(0); activeItemIndex.set(getActiveRenderItemId(dataRef.current.length, activeIndexRef.current)); @@ -206,6 +212,7 @@ export function useSwipeDeckUndoRuntime({ activeTranslateY, applyImmediateRuntimeState, cancelActiveInteractionAnimations, + dismissDirection, dragItemIndex, gestureStartYRatio, isAnimating, @@ -289,9 +296,12 @@ export function useSwipeDeckUndoRuntime({ activeItemIndex.set(getActiveRenderItemId(currentData.length, activeIndexRef.current)); activeTranslateX.set(0); activeTranslateY.set(0); - swipeProgress.set(0); - signedSwipeProgress.set(0); - swipeDirectionSignal.set(0); + resetSwipeDeckInteractionSignals({ + dismissDirection, + signedSwipeProgress, + swipeDirectionSignal, + swipeProgress, + }); dragItemIndex.set(-1); undoProgress.set(0); undoFromTranslateX.set(0); @@ -322,9 +332,12 @@ export function useSwipeDeckUndoRuntime({ setEndReached(false); activeIndexRef.current = restoredIndex; activeItemIndex.set(restoredIndex); - swipeProgress.set(0); - signedSwipeProgress.set(0); - swipeDirectionSignal.set(0); + resetSwipeDeckInteractionSignals({ + dismissDirection, + signedSwipeProgress, + swipeDirectionSignal, + swipeProgress, + }); activeTranslateX.set(0); activeTranslateY.set(0); dragItemIndex.set(-1); @@ -352,6 +365,7 @@ export function useSwipeDeckUndoRuntime({ applyImmediateRuntimeState, attachmentGenerationRef, cancelPendingUndoRestore, + dismissDirection, dragItemIndex, endReachedRef, gestureStartYRatio, @@ -422,9 +436,12 @@ export function useSwipeDeckUndoRuntime({ isAnimating.set(true); interactionPhase.set('undoing'); applyImmediateRuntimeState(true, false); - swipeProgress.set(0); - signedSwipeProgress.set(0); - swipeDirectionSignal.set(0); + resetSwipeDeckInteractionSignals({ + dismissDirection, + signedSwipeProgress, + swipeDirectionSignal, + swipeProgress, + }); activeTranslateX.set(0); activeTranslateY.set(0); isDragging.set(false); @@ -448,6 +465,7 @@ export function useSwipeDeckUndoRuntime({ cancelActiveInteractionAnimations, disabledRef, dismissRuntimeRef, + dismissDirection, dragItemIndex, gestureStartYRatio, isAnimating, @@ -483,9 +501,12 @@ export function useSwipeDeckUndoRuntime({ cancelAnimation(undoProgress); dragItemIndex.set(-1); gestureStartYRatio.set(0.5); - swipeProgress.set(0); - signedSwipeProgress.set(0); - swipeDirectionSignal.set(0); + resetSwipeDeckInteractionSignals({ + dismissDirection, + signedSwipeProgress, + swipeDirectionSignal, + swipeProgress, + }); activeTranslateX.set(0); activeTranslateY.set(0); undoProgress.set(1); @@ -520,6 +541,7 @@ export function useSwipeDeckUndoRuntime({ cancelActiveInteractionAnimations, completeUndoRestoreIfCurrent, dragItemIndex, + dismissDirection, gestureStartYRatio, signedSwipeProgress, swipeDirectionSignal, diff --git a/src/registry/registry.ts b/src/registry/registry.ts index 7a52851..a21c379 100644 --- a/src/registry/registry.ts +++ b/src/registry/registry.ts @@ -70,6 +70,7 @@ function createInteraction(): SwipeDeckInteraction { progress: makeMutable(0), signedProgress: makeMutable(0), direction: makeMutable<-1 | 0 | 1>(0), + dismissDirection: makeMutable(null), translationX: makeMutable(0), translationY: makeMutable(0), isDragging: makeMutable(false), @@ -81,6 +82,7 @@ function resetInteraction(interaction: SwipeDeckInteraction) { interaction.progress.set(0); interaction.signedProgress.set(0); interaction.direction.set(0); + interaction.dismissDirection.set(null); interaction.translationX.set(0); interaction.translationY.set(0); interaction.isDragging.set(false); diff --git a/src/types.ts b/src/types.ts index bd75e99..a1b7194 100644 --- a/src/types.ts +++ b/src/types.ts @@ -534,6 +534,13 @@ export type SwipeDeckInteraction = { signedProgress: SharedValue; /** Current swipe direction signal; left is `-1`, idle is `0`, right is `1`. */ direction: SharedValue<-1 | 0 | 1>; + /** + * Accepted dismiss direction for lifecycle-driven visual feedback. + * + * This is not the committed swipe event payload. It becomes non-null only after a dismiss is + * accepted and resets after the deck commits and clears interaction values. + */ + dismissDirection: SharedValue; /** Active card horizontal translation. */ translationX: SharedValue; /** Active card vertical translation. */