diff --git a/.changeset/phase-interaction-signal.md b/.changeset/phase-interaction-signal.md new file mode 100644 index 0000000..ffbe3fc --- /dev/null +++ b/.changeset/phase-interaction-signal.md @@ -0,0 +1,17 @@ +--- +'@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. + +```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 = {