From 7df9e1cff485293804e5941c682193019ea1e821 Mon Sep 17 00:00:00 2001 From: harang Date: Tue, 16 Jun 2026 10:16:40 +0900 Subject: [PATCH 1/2] feat: expose interaction phase for UI feedback The deck already exposes progress and direction as UI-thread interaction values. App-level feedback needs a stable lifecycle signal that does not depend on JS event timing. This adds an additive phase shared value for idle, dragging, dismissing, and undoing. Committed model events remain separate from frame-synchronous visual feedback. Constraint: Limit this PR to interaction.phase only. Constraint: Do not add committedDirection, progress splitting, or worklet callbacks. Constraint: Preserve existing gesture and animation behavior. Rejected: useDeckEvent phase event | phase is UI-thread feedback, not a committed JS event Rejected: Thin phase setter helper | direct shared-value writes are clearer for now Confidence: high Scope-risk: moderate Directive: Do not bridge phase to React state without a separate JS observer API Tested: yarn format:check; yarn lint; yarn typecheck --pretty false Tested: yarn tsc -p example/tsconfig.json --noEmit --pretty false Tested: yarn jest --runInBand; yarn build Not-tested: Manual device visual verification after the final example chip tweak --- .changeset/phase-interaction-signal.md | 17 ++++ README.ko.md | 9 ++ README.md | 10 +++ example/App.tsx | 80 +++++++++++++++++ src/__tests__/SwipeDeck.integration.test.tsx | 95 +++++++++++++++++--- src/__tests__/registry.test.ts | 3 + src/components/SwipeDeck.tsx | 4 + src/hooks/useSwipeDeckDismissRuntime.ts | 7 ++ src/hooks/useSwipeDeckGestureRuntime.ts | 14 ++- src/hooks/useSwipeDeckUndoRuntime.ts | 10 +++ src/index.tsx | 1 + src/registry/registry.ts | 3 + src/types.ts | 8 ++ 13 files changed, 247 insertions(+), 14 deletions(-) create mode 100644 .changeset/phase-interaction-signal.md diff --git a/.changeset/phase-interaction-signal.md b/.changeset/phase-interaction-signal.md new file mode 100644 index 0000000..0738942 --- /dev/null +++ b/.changeset/phase-interaction-signal.md @@ -0,0 +1,17 @@ +--- +'@react-native-motion-kit/swipe-deck': patch +--- + +Add `interaction.phase` to `useDeckInteraction()` so UI-thread consumers can distinguish idle, dragging, dismissing, and undoing deck lifecycles without inferring from progress or JS events. + +```tsx +function DeckPhaseFeedback() { + const { phase } = ProfileDeck.useDeckInteraction(); + + const dismissingStyle = useAnimatedStyle(() => ({ + opacity: phase.get() === 'dismissing' ? 1 : 0.32, + })); + + return ; +} +``` diff --git a/README.ko.md b/README.ko.md index 614643e..53103be 100644 --- a/README.ko.md +++ b/README.ko.md @@ -265,6 +265,15 @@ function ProfileDeckScreen() { `undo()`는 deck이 완료된 뒤에도 `canUndo`가 true라면 `true`를 반환합니다. - `useDeckInteraction(id?)`는 progress 기반 UI를 위한 Reanimated shared value를 반환합니다. gesture progress는 UI thread에 남고, 매 frame React rerender를 만들지 않습니다. + - `interaction.phase`는 UI thread lifecycle signal입니다: + `idle | dragging | dismissing | undoing`. + - deck이 drag 중인지, dismiss 중인지, restore 중인지 알아야 하는 visual feedback에는 + `phase`를 사용하세요. swipe, undo, index change처럼 commit된 model event는 + `useDeckEvent` / `useDeckEventListener`를 사용하세요. + - `dismissing`은 dismiss가 accepted된 뒤 다음 item commit과 interaction reset이 끝날 때까지의 + lifecycle을 포함합니다. 즉, 화면 밖으로 나가는 animation frame만 의미하지는 않습니다. + - programmatic springboard action은 action이 받아들여지는 즉시 `dismissing`으로 들어갑니다. + anticipation 중에는 실제 dismiss phase가 시작되기 전까지 `interaction.direction`이 neutral일 수 있습니다. - `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 cc7774b..acbc4d4 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,16 @@ function ProfileDeckScreen() { after the deck is completed. - `useDeckInteraction(id?)` returns Reanimated shared values for progress-driven UI. Gesture progress stays on the UI thread and does not rerender React every frame. + - `interaction.phase` is a UI-thread lifecycle signal: + `idle | dragging | dismissing | undoing`. + - Use `phase` for visual feedback that needs to know whether the deck is actively dragging, + dismissing, or restoring. Use `useDeckEvent` / `useDeckEventListener` for committed model + events such as swipe, undo, and index changes. + - `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. + - 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. - `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 0ec8b17..43a167a 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -96,6 +96,51 @@ function SwipeReactionOverlay() { ); } +function DeckPhaseFeedback() { + const { phase } = ProfileDeck.useDeckInteraction(); + + const idleStyle = useAnimatedStyle(() => { + return { + opacity: phase.get() === 'idle' ? 1 : 0.32, + }; + }); + + const draggingStyle = useAnimatedStyle(() => { + return { + opacity: phase.get() === 'dragging' ? 1 : 0.32, + }; + }); + + const dismissingStyle = useAnimatedStyle(() => { + return { + opacity: phase.get() === 'dismissing' ? 1 : 0.32, + }; + }); + + const undoingStyle = useAnimatedStyle(() => { + return { + opacity: phase.get() === 'undoing' ? 1 : 0.32, + }; + }); + + return ( + + + Idle + + + Drag + + + Dismiss + + + Undo + + + ); +} + type ProfileCardProps = { isActive: boolean; profile: Profile; @@ -202,6 +247,7 @@ export default function App() { )} + @@ -341,6 +387,40 @@ const styles = StyleSheet.create({ fontSize: 14, fontWeight: '800', }, + phaseFeedback: { + alignItems: 'center', + bottom: 108, + flexDirection: 'row', + gap: 6, + justifyContent: 'center', + left: 0, + position: 'absolute', + right: 0, + }, + phasePill: { + borderRadius: 999, + paddingHorizontal: 8, + paddingVertical: 4, + }, + idlePhasePill: { + backgroundColor: 'rgba(161, 161, 170, 0.72)', + }, + draggingPhasePill: { + backgroundColor: 'rgba(96, 165, 250, 0.82)', + }, + dismissPhasePill: { + backgroundColor: 'rgba(52, 211, 153, 0.82)', + }, + undoPhasePill: { + backgroundColor: 'rgba(251, 191, 36, 0.82)', + }, + phaseText: { + color: '#09090b', + fontSize: 10, + fontWeight: '900', + letterSpacing: 0.4, + textTransform: 'uppercase', + }, actions: { flexDirection: 'row', gap: 16, diff --git a/src/__tests__/SwipeDeck.integration.test.tsx b/src/__tests__/SwipeDeck.integration.test.tsx index b12da55..f594c1e 100644 --- a/src/__tests__/SwipeDeck.integration.test.tsx +++ b/src/__tests__/SwipeDeck.integration.test.tsx @@ -256,11 +256,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'); + const [interactionText, setInteractionText] = useState('0:0:0:idle'); useEffect(() => { setInteractionText( - `${interaction.progress.get()}:${interaction.signedProgress.get()}:${interaction.direction.get()}`, + `${interaction.progress.get()}:${interaction.signedProgress.get()}:${interaction.direction.get()}:${interaction.phase.get()}`, ); }, [interaction, state.activeIndex, state.canSwipe, state.isCompleted]); @@ -306,13 +306,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')).toBeOnTheScreen(); + expect(screen.getByText('interaction:0:0:0: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')).toBeOnTheScreen(); + expect(screen.getByText('interaction:0:0:0:idle')).toBeOnTheScreen(); expect(screen.getByText('Grace')).toBeOnTheScreen(); expect(onSwipe).toHaveBeenCalledTimes(1); expect(onSwipe).toHaveBeenCalledWith({ @@ -492,7 +492,9 @@ describe('SwipeDeck factory hooks', () => { onPress={() => { const accepted = actions.swipeRight(); - setProbe(`${String(accepted)}:${interaction.direction.get()}`); + setProbe( + `${String(accepted)}:${interaction.direction.get()}:${interaction.phase.get()}`, + ); }} > Probe swipe right @@ -529,7 +531,7 @@ describe('SwipeDeck factory hooks', () => { await user.press(screen.getByRole('button', { name: 'Probe swipe right' })); - expect(await screen.findByText('probe:true:1')).toBeOnTheScreen(); + expect(await screen.findByText('probe:true:1:dismissing')).toBeOnTheScreen(); expect(screen.getByText('Grace')).toBeOnTheScreen(); await renderResult.rerender( @@ -538,7 +540,7 @@ describe('SwipeDeck factory hooks', () => { await user.press(screen.getByRole('button', { name: 'Probe swipe right' })); - expect(await screen.findByText('probe:true:0')).toBeOnTheScreen(); + expect(await screen.findByText('probe:true:0:dismissing')).toBeOnTheScreen(); expect(screen.getByText('Linus')).toBeOnTheScreen(); }); @@ -558,11 +560,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'); + const [interactionText, setInteractionText] = useState('0:0:0:idle'); useEffect(() => { setInteractionText( - `${interaction.progress.get()}:${interaction.signedProgress.get()}:${interaction.direction.get()}`, + `${interaction.progress.get()}:${interaction.signedProgress.get()}:${interaction.direction.get()}:${interaction.phase.get()}`, ); }, [interaction, state.activeIndex, state.canUndo]); @@ -646,7 +648,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')).toBeOnTheScreen(); + expect(screen.getByText('interaction:0:0:0:idle')).toBeOnTheScreen(); expect(onSwipe).toHaveBeenCalledTimes(1); expect(onUndo).toHaveBeenCalledWith({ direction: 'right', @@ -714,6 +716,73 @@ describe('SwipeDeck factory hooks', () => { expect(screen.getByText('Ada')).toBeOnTheScreen(); }); + it('publishes undoing phase immediately when programmatic undo is accepted', async () => { + const ProfileDeck = createSwipeDeck(); + const user = userEvent.setup(); + + function DeckControls() { + const state = ProfileDeck.useDeckState(); + const actions = ProfileDeck.useDeckActions(); + const interaction = ProfileDeck.useDeckInteraction(); + const [probe, setProbe] = useState('none'); + + return ( + + + state:{state.activeIndex}:{String(state.canSwipe)}:{String(state.canUndo)}: + {String(state.isCompleted)} + + probe:{probe} + actions.swipeRight()} + > + Swipe right + + { + const accepted = actions.undo( + SwipeDeckUndoMotion.timing({ + duration: 120, + }), + ); + + setProbe(`${String(accepted)}:${interaction.phase.get()}`); + }} + > + Probe undo + + + ); + } + + function Example() { + return ( + <> + + + {({ item }) => {item.name}} + + + ); + } + + await render(); + await measureDeckFromVisibleCard('Ada'); + + await user.press(screen.getByRole('button', { name: 'Swipe right' })); + + expect(await screen.findByText('state:1:true:true:false')).toBeOnTheScreen(); + + await user.press(screen.getByRole('button', { name: 'Probe undo' })); + + expect(await screen.findByText('probe:true:undoing')).toBeOnTheScreen(); + expect(await screen.findByText('state:0:true:false:false')).toBeOnTheScreen(); + }); + it('resets interaction state without undo history after a canceled pan gesture', async () => { const ProfileDeck = createSwipeDeck(); const onSwipe = jest.fn(); @@ -721,11 +790,11 @@ describe('SwipeDeck factory hooks', () => { function DeckControls() { const state = ProfileDeck.useDeckState(); const interaction = ProfileDeck.useDeckInteraction(); - const [interactionText, setInteractionText] = useState('0:0:0'); + const [interactionText, setInteractionText] = useState('0:0:0:idle'); useEffect(() => { setInteractionText( - `${interaction.progress.get()}:${interaction.signedProgress.get()}:${interaction.direction.get()}`, + `${interaction.progress.get()}:${interaction.signedProgress.get()}:${interaction.direction.get()}:${interaction.phase.get()}`, ); }, [interaction, state.activeIndex, state.canSwipe, state.canUndo]); @@ -768,7 +837,7 @@ describe('SwipeDeck factory hooks', () => { ]); expect(await screen.findByText('state:0:true:false:false')).toBeOnTheScreen(); - expect(screen.getByText('interaction:0:0:0')).toBeOnTheScreen(); + expect(screen.getByText('interaction:0:0:0: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 c7767c1..44b72ca 100644 --- a/src/__tests__/registry.test.ts +++ b/src/__tests__/registry.test.ts @@ -52,6 +52,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'); }); it('notifies state subscribers only when the snapshot changes', () => { @@ -272,6 +273,7 @@ describe('createSwipeDeckRegistry', () => { store.interaction.translationX.set(-120); store.interaction.translationY.set(24); store.interaction.isDragging.set(true); + store.interaction.phase.set('dismissing'); detach(); @@ -281,5 +283,6 @@ describe('createSwipeDeckRegistry', () => { expect(store.interaction.translationX.get()).toBe(0); expect(store.interaction.translationY.get()).toBe(0); expect(store.interaction.isDragging.get()).toBe(false); + expect(store.interaction.phase.get()).toBe('idle'); }); }); diff --git a/src/components/SwipeDeck.tsx b/src/components/SwipeDeck.tsx index 6f5a5d6..60d3cf6 100644 --- a/src/components/SwipeDeck.tsx +++ b/src/components/SwipeDeck.tsx @@ -90,6 +90,7 @@ function Root({ const activeTranslateX = interaction.translationX; const activeTranslateY = interaction.translationY; const isDragging = interaction.isDragging; + const interactionPhase = interaction.phase; const dragItemIndex = useSharedValue(-1); const undoProgress = useSharedValue(0); const undoFromTranslateX = useSharedValue(0); @@ -237,6 +238,7 @@ function Root({ hasUndoHistoryRef, isAnimating, isDragging, + interactionPhase, layoutRef, emitDeckEvent: deckStore.emitEvent, publishDeckStateSnapshot, @@ -271,6 +273,7 @@ function Root({ gestureStartYRatio, isAnimating, isDragging, + interactionPhase, layoutRef, emitDeckEvent: deckStore.emitEvent, recordSwipeForUndo, @@ -301,6 +304,7 @@ function Root({ hasActiveCard, isAnimating, isDragging, + interactionPhase, layout, resolvedSwipeThreshold, resolvedVelocityThreshold, diff --git a/src/hooks/useSwipeDeckDismissRuntime.ts b/src/hooks/useSwipeDeckDismissRuntime.ts index a1ec8ff..9ca8f59 100644 --- a/src/hooks/useSwipeDeckDismissRuntime.ts +++ b/src/hooks/useSwipeDeckDismissRuntime.ts @@ -9,6 +9,7 @@ import type { SwipeDeckRenderedCardMotionConfig } from '../core/renderedCardMoti import type { SwipeDeckActionMotionRecipe, SwipeDeckEventMap, + SwipeDeckInteractionPhase, SwipeDeckLayout, SwipeDeckMotionEasing, SwipeDirection, @@ -63,6 +64,7 @@ type UseSwipeDeckDismissRuntimeArgs = { gestureStartYRatio: SharedValue; isAnimating: SharedValue; isDragging: SharedValue; + interactionPhase: SharedValue; layoutRef: RefObject; recordSwipeForUndo: RecordSwipeForUndo; setActiveIndex: Dispatch>; @@ -115,6 +117,7 @@ export function useSwipeDeckDismissRuntime({ gestureStartYRatio, isAnimating, isDragging, + interactionPhase, layoutRef, recordSwipeForUndo, setActiveIndex, @@ -183,6 +186,7 @@ export function useSwipeDeckDismissRuntime({ signedSwipeProgress.set(0); swipeDirectionSignal.set(0); isDragging.set(false); + interactionPhase.set('idle'); gestureStartYRatio.set(0.5); dragItemIndex.set(-1); }, [ @@ -192,6 +196,7 @@ export function useSwipeDeckDismissRuntime({ dragItemIndex, gestureStartYRatio, isDragging, + interactionPhase, signedSwipeProgress, swipeDirectionSignal, swipeProgress, @@ -267,6 +272,7 @@ export function useSwipeDeckDismissRuntime({ isAnimating.set(true); isDragging.set(true); + interactionPhase.set('dismissing'); applyImmediateRuntimeState(true, true); gestureStartYRatio.set(0.5); dragItemIndex.set(activeItemIndex.get()); @@ -365,6 +371,7 @@ export function useSwipeDeckDismissRuntime({ gestureStartYRatio, isAnimating, isDragging, + interactionPhase, layoutRef, signedSwipeProgress, swipeDirectionSignal, diff --git a/src/hooks/useSwipeDeckGestureRuntime.ts b/src/hooks/useSwipeDeckGestureRuntime.ts index e86f7ad..5a6a73d 100644 --- a/src/hooks/useSwipeDeckGestureRuntime.ts +++ b/src/hooks/useSwipeDeckGestureRuntime.ts @@ -6,7 +6,12 @@ import { useSharedValue, withSpring, withTiming } from 'react-native-reanimated' import { scheduleOnRN } from 'react-native-worklets'; import type { SwipeDeckRenderedCardMotionConfig } from '../core/renderedCardMotionTypes'; -import type { SwipeDeckLayout, SwipeDeckMotionEasing, SwipeDirection } from '../types'; +import type { + SwipeDeckInteractionPhase, + SwipeDeckLayout, + SwipeDeckMotionEasing, + SwipeDirection, +} from '../types'; import { resolveSwipeDirection } from '../core/directions'; import { resolveProgressDirection, resolveSignedSwipeProgress } from '../core/swipeDeckRuntime'; @@ -48,6 +53,7 @@ type UseSwipeDeckGestureRuntimeArgs = { hasActiveCard: boolean; isAnimating: SharedValue; isDragging: SharedValue; + interactionPhase: SharedValue; layout: SwipeDeckLayout; resolvedSwipeThreshold?: number; resolvedVelocityThreshold?: number; @@ -84,6 +90,7 @@ export function useSwipeDeckGestureRuntime({ hasActiveCard, isAnimating, isDragging, + interactionPhase, layout, resolvedSwipeThreshold, resolvedVelocityThreshold, @@ -114,6 +121,7 @@ export function useSwipeDeckGestureRuntime({ runtimeEventId.set(nextRuntimeEventId); isDragging.set(true); + interactionPhase.set('dragging'); swipeDirectionSignal.set(0); signedSwipeProgress.set(0); scheduleOnRN(applyScheduledRuntimeState, nextRuntimeEventId, false, true); @@ -178,6 +186,7 @@ export function useSwipeDeckGestureRuntime({ dragItemIndex.set(-1); isAnimating.set(false); isDragging.set(false); + interactionPhase.set('idle'); swipeDirectionSignal.set(0); const nextRuntimeEventId = runtimeEventId.get() + 1; @@ -195,6 +204,7 @@ export function useSwipeDeckGestureRuntime({ isAnimating.set(true); isDragging.set(true); + interactionPhase.set('dismissing'); const currentAttachmentGeneration = attachmentGeneration.get(); scheduleOnRN(applyScheduledRuntimeState, runtimeEventId.get(), true, true); @@ -249,6 +259,7 @@ export function useSwipeDeckGestureRuntime({ signedSwipeProgress.set(0); swipeDirectionSignal.set(0); isDragging.set(false); + interactionPhase.set('idle'); dragItemIndex.set(-1); const nextRuntimeEventId = runtimeEventId.get() + 1; @@ -280,6 +291,7 @@ export function useSwipeDeckGestureRuntime({ hasHandledGestureEnd, isAnimating, isDragging, + interactionPhase, layout, resolvedSwipeThreshold, resolvedVelocityThreshold, diff --git a/src/hooks/useSwipeDeckUndoRuntime.ts b/src/hooks/useSwipeDeckUndoRuntime.ts index 310e409..9d5aad5 100644 --- a/src/hooks/useSwipeDeckUndoRuntime.ts +++ b/src/hooks/useSwipeDeckUndoRuntime.ts @@ -8,6 +8,7 @@ import { scheduleOnRN } from 'react-native-worklets'; import type { SwipeDeckRenderedCardMotionConfig } from '../core/renderedCardMotionTypes'; import type { SwipeDeckEventMap, + SwipeDeckInteractionPhase, SwipeDeckLayout, SwipeDeckMotionEasing, SwipeDeckUndoMotionRecipe, @@ -87,6 +88,7 @@ type UseSwipeDeckUndoRuntimeArgs = { hasUndoHistoryRef: { current: () => boolean }; isAnimating: SharedValue; isDragging: SharedValue; + interactionPhase: SharedValue; layoutRef: RefObject; publishDeckStateSnapshot: () => void; setActiveIndex: Dispatch>; @@ -132,6 +134,7 @@ export function useSwipeDeckUndoRuntime({ hasUndoHistoryRef, isAnimating, isDragging, + interactionPhase, layoutRef, publishDeckStateSnapshot, setActiveIndex, @@ -192,6 +195,7 @@ export function useSwipeDeckUndoRuntime({ undoProgress.set(0); undoFromTranslateX.set(0); isDragging.set(false); + interactionPhase.set('idle'); gestureStartYRatio.set(0.5); isAnimating.set(false); applyImmediateRuntimeState(false, false); @@ -206,6 +210,7 @@ export function useSwipeDeckUndoRuntime({ gestureStartYRatio, isAnimating, isDragging, + interactionPhase, signedSwipeProgress, swipeDirectionSignal, swipeProgress, @@ -291,6 +296,7 @@ export function useSwipeDeckUndoRuntime({ undoProgress.set(0); undoFromTranslateX.set(0); isDragging.set(false); + interactionPhase.set('idle'); gestureStartYRatio.set(0.5); isAnimating.set(false); applyImmediateRuntimeState(false, false); @@ -325,6 +331,7 @@ export function useSwipeDeckUndoRuntime({ undoProgress.set(0); undoFromTranslateX.set(0); isDragging.set(false); + interactionPhase.set('idle'); gestureStartYRatio.set(0.5); setActiveIndex(restoredIndex); setUndoTransition(null); @@ -350,6 +357,7 @@ export function useSwipeDeckUndoRuntime({ gestureStartYRatio, isAnimating, isDragging, + interactionPhase, emitDeckEvent, setActiveIndex, setEndReached, @@ -412,6 +420,7 @@ export function useSwipeDeckUndoRuntime({ }; cancelActiveInteractionAnimations(); isAnimating.set(true); + interactionPhase.set('undoing'); applyImmediateRuntimeState(true, false); swipeProgress.set(0); signedSwipeProgress.set(0); @@ -443,6 +452,7 @@ export function useSwipeDeckUndoRuntime({ gestureStartYRatio, isAnimating, isDragging, + interactionPhase, layoutRef, pruneUndoHistoryForCurrentData, publishDeckStateSnapshot, diff --git a/src/index.tsx b/src/index.tsx index 1027f7f..c0bc1a6 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -18,6 +18,7 @@ export type { SwipeDeckEventName, SwipeDeckFactoryConfig, SwipeDeckInteraction, + SwipeDeckInteractionPhase, SwipeDeckInstance, SwipeDeckLayout, SwipeDeckUndoAction, diff --git a/src/registry/registry.ts b/src/registry/registry.ts index 4455bf6..7a52851 100644 --- a/src/registry/registry.ts +++ b/src/registry/registry.ts @@ -5,6 +5,7 @@ import type { SwipeDeckActions, SwipeDeckEventMap, SwipeDeckInteraction, + SwipeDeckInteractionPhase, SwipeDeckState, SwipeDirection, SwipeDeckUndoMotionRecipe, @@ -72,6 +73,7 @@ function createInteraction(): SwipeDeckInteraction { translationX: makeMutable(0), translationY: makeMutable(0), isDragging: makeMutable(false), + phase: makeMutable('idle'), }; } @@ -82,6 +84,7 @@ function resetInteraction(interaction: SwipeDeckInteraction) { interaction.translationX.set(0); interaction.translationY.set(0); interaction.isDragging.set(false); + interaction.phase.set('idle'); } function isSameState(left: SwipeDeckState, right: SwipeDeckState): boolean { diff --git a/src/types.ts b/src/types.ts index 905be6a..bd75e99 100644 --- a/src/types.ts +++ b/src/types.ts @@ -525,6 +525,8 @@ export type SwipeDeckActions = { undo: SwipeDeckUndoAction; }; +export type SwipeDeckInteractionPhase = 'idle' | 'dragging' | 'dismissing' | 'undoing'; + export type SwipeDeckInteraction = { /** Absolute swipe progress from `0` to `1`. */ progress: SharedValue; @@ -538,6 +540,12 @@ export type SwipeDeckInteraction = { translationY: SharedValue; /** Whether the deck is currently being dragged or dismissed. */ isDragging: SharedValue; + /** + * UI-thread lifecycle phase for external visual feedback. + * + * Use model events for committed state changes; this value is for frame-synchronous UI. + */ + phase: SharedValue; }; export type SwipeDeckInstance = { From 167575dde95d13600a3f6cdcb1fe074007e4f37a Mon Sep 17 00:00:00 2001 From: harang Date: Tue, 16 Jun 2026 10:22:48 +0900 Subject: [PATCH 2/2] chore: bump interaction phase changeset to minor Interaction phase is a public API addition. The release bump should be minor rather than patch. Constraint: Preserve the original feature commit. Confidence: high Scope-risk: narrow Tested: yarn format:check Not-tested: Full test suite; changeset metadata only --- .changeset/phase-interaction-signal.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/phase-interaction-signal.md b/.changeset/phase-interaction-signal.md index 0738942..ffbe3fc 100644 --- a/.changeset/phase-interaction-signal.md +++ b/.changeset/phase-interaction-signal.md @@ -1,5 +1,5 @@ --- -'@react-native-motion-kit/swipe-deck': patch +'@react-native-motion-kit/swipe-deck': minor --- Add `interaction.phase` to `useDeckInteraction()` so UI-thread consumers can distinguish idle, dragging, dismissing, and undoing deck lifecycles without inferring from progress or JS events.