Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/phase-interaction-signal.md
Original file line number Diff line number Diff line change
@@ -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 <Animated.View style={dismissingStyle} />;
}
```
9 changes: 9 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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를 구독합니다.
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
80 changes: 80 additions & 0 deletions example/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<View pointerEvents="none" style={styles.phaseFeedback}>
<Animated.View style={[styles.phasePill, styles.idlePhasePill, idleStyle]}>
<Text style={styles.phaseText}>Idle</Text>
</Animated.View>
<Animated.View style={[styles.phasePill, styles.draggingPhasePill, draggingStyle]}>
<Text style={styles.phaseText}>Drag</Text>
</Animated.View>
<Animated.View style={[styles.phasePill, styles.dismissPhasePill, dismissingStyle]}>
<Text style={styles.phaseText}>Dismiss</Text>
</Animated.View>
<Animated.View style={[styles.phasePill, styles.undoPhasePill, undoingStyle]}>
<Text style={styles.phaseText}>Undo</Text>
</Animated.View>
</View>
);
}

type ProfileCardProps = {
isActive: boolean;
profile: Profile;
Expand Down Expand Up @@ -202,6 +247,7 @@ export default function App() {
)}
</ProfileDeck.Card>
</ProfileDeck.Root>
<DeckPhaseFeedback />
<ProfileDeckControls />
</View>
</View>
Expand Down Expand Up @@ -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,
Expand Down
95 changes: 82 additions & 13 deletions src/__tests__/SwipeDeck.integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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()}`,
);
}}
>
<Text>Probe swipe right</Text>
Expand Down Expand Up @@ -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(
Expand All @@ -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();
});

Expand All @@ -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]);

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -714,18 +716,85 @@ describe('SwipeDeck factory hooks', () => {
expect(screen.getByText('Ada')).toBeOnTheScreen();
});

it('publishes undoing phase immediately when programmatic undo is accepted', async () => {
const ProfileDeck = createSwipeDeck<Profile>();
const user = userEvent.setup();

function DeckControls() {
const state = ProfileDeck.useDeckState();
const actions = ProfileDeck.useDeckActions();
const interaction = ProfileDeck.useDeckInteraction();
const [probe, setProbe] = useState('none');

return (
<View>
<Text>
state:{state.activeIndex}:{String(state.canSwipe)}:{String(state.canUndo)}:
{String(state.isCompleted)}
</Text>
<Text>probe:{probe}</Text>
<Pressable
accessibilityLabel="Swipe right"
accessibilityRole="button"
onPress={() => actions.swipeRight()}
>
<Text>Swipe right</Text>
</Pressable>
<Pressable
accessibilityLabel="Probe undo"
accessibilityRole="button"
onPress={() => {
const accepted = actions.undo(
SwipeDeckUndoMotion.timing({
duration: 120,
}),
);

setProbe(`${String(accepted)}:${interaction.phase.get()}`);
}}
>
<Text>Probe undo</Text>
</Pressable>
</View>
);
}

function Example() {
return (
<>
<DeckControls />
<ProfileDeck.Root data={profiles} getKey={getProfileKey} undoEnabled>
<ProfileDeck.Card>{({ item }) => <Text>{item.name}</Text>}</ProfileDeck.Card>
</ProfileDeck.Root>
</>
);
}

await render(<Example />);
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<Profile>();
const onSwipe = jest.fn();

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]);

Expand Down Expand Up @@ -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();
});
Expand Down
3 changes: 3 additions & 0 deletions src/__tests__/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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();

Expand All @@ -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');
});
});
4 changes: 4 additions & 0 deletions src/components/SwipeDeck.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ function Root<T>({
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);
Expand Down Expand Up @@ -237,6 +238,7 @@ function Root<T>({
hasUndoHistoryRef,
isAnimating,
isDragging,
interactionPhase,
layoutRef,
emitDeckEvent: deckStore.emitEvent,
publishDeckStateSnapshot,
Expand Down Expand Up @@ -271,6 +273,7 @@ function Root<T>({
gestureStartYRatio,
isAnimating,
isDragging,
interactionPhase,
layoutRef,
emitDeckEvent: deckStore.emitEvent,
recordSwipeForUndo,
Expand Down Expand Up @@ -301,6 +304,7 @@ function Root<T>({
hasActiveCard,
isAnimating,
isDragging,
interactionPhase,
layout,
resolvedSwipeThreshold,
resolvedVelocityThreshold,
Expand Down
Loading