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 = {