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
24 changes: 24 additions & 0 deletions .changeset/swipe-event-source.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@react-native-motion-kit/swipe-deck': minor

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Mark required SwipeEvent source as major

This changeset publishes a minor release, but the same change makes source a required member of the exported SwipeEvent<T> type at src/types.ts:36 and notes that object literals, fixtures, or useDeckEvent('swipe', initialValue) values must now be updated. Consumers can take this as a minor upgrade and have TypeScript builds fail, so either make source optional/backward-compatible or change this changeset to major.

Useful? React with 👍 / 👎.

---

Add `source` to committed swipe events so apps can distinguish gesture commits from programmatic action commits.

```tsx
ProfileDeck.useDeckEventListener('swipe', (event) => {
if (event.source === 'gesture') {
console.log('User swiped', event.direction);
return;
}

console.log('Programmatic action swiped', event.direction);
});
```

`event.source` is `'gesture'` when a pan release commits the swipe and `'programmatic'` when
`actions.swipeLeft()` or `actions.swipeRight()` commits it. `programmatic` does not mean button; map
it to a button only when that matches your app's UI.

This is a TypeScript-visible event payload shape change: `source` is a required field on
`SwipeEvent<T>`, so object literals, fixtures, or `useDeckEvent('swipe', initialValue)` values must
include it.
16 changes: 12 additions & 4 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ const ProfileDeck = createSwipeDeck<Profile>({
});

function ProfileDeckEvents() {
ProfileDeck.useDeckEventListener('swipe', ({ item, direction }) => {
console.log(item, direction);
ProfileDeck.useDeckEventListener('swipe', ({ item, direction, source }) => {
console.log(item, direction, source);
});
ProfileDeck.useDeckEventListener('endReached', () => {
console.log('No more cards');
Expand Down Expand Up @@ -228,7 +228,11 @@ function ProfileDeckEvents() {

return (
<Text>
{endReached ? 'Done' : lastSwipe ? `Last swipe: ${lastSwipe.direction}` : 'No swipe yet'}
{endReached
? 'Done'
: lastSwipe
? `Last swipe: ${lastSwipe.direction} from ${lastSwipe.source}`
: 'No swipe yet'}
</Text>
);
}
Expand All @@ -240,7 +244,11 @@ Event hook은 commit 단위의 latest-value API입니다.
- `initialValue`는 event payload shape, `null`, `undefined`, 또는 `endReached`의 `false`로 제한됩니다.
`swipe` 같은 object event에는 `null`을 사용하세요. `{}`는 event payload 타입을 넓혀버리지 않도록 의도적으로 막습니다.
- Object initial value를 넘기면 `eventName`을 기준으로 contextual typing이 걸립니다.
그래서 `useDeckEvent('swipe', { ... })`에서는 `item`, `index`, `direction`이 자동완성됩니다.
그래서 `useDeckEvent('swipe', { ... })`에서는 `item`, `index`, `direction`, `source`가 자동완성됩니다.
- Swipe event에는 `source: 'gesture' | 'programmatic'`이 포함됩니다. `gesture`는 사용자가 pan
gesture를 threshold/velocity 기준 이상으로 놓아서 commit된 경우입니다. `programmatic`은
`actions.swipeLeft()` 또는 `actions.swipeRight()`로 commit된 경우이며, button을 뜻하지는
않습니다. 앱 UI에서 그 action을 button으로만 호출한다면 앱에서 button 의미로 매핑하면 됩니다.
- Initial value 없이 named deck을 읽어야 한다면 id를 두 번째 인자로 넘기면 됩니다.
`useDeckEvent('swipe', 'nearby')`처럼 사용할 수 있고, initial value도 함께 넘기는 경우에는 id가 세 번째 인자입니다.
- Root가 attach될 때와 detach될 때 event snapshot은 clear됩니다. 그래서 fresh/detached deck에서는
Expand Down
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ const ProfileDeck = createSwipeDeck<Profile>({
});

function ProfileDeckEvents() {
ProfileDeck.useDeckEventListener('swipe', ({ item, direction }) => {
console.log(item, direction);
ProfileDeck.useDeckEventListener('swipe', ({ item, direction, source }) => {
console.log(item, direction, source);
});
ProfileDeck.useDeckEventListener('endReached', () => {
console.log('No more cards');
Expand Down Expand Up @@ -227,7 +227,11 @@ function ProfileDeckEvents() {

return (
<Text>
{endReached ? 'Done' : lastSwipe ? `Last swipe: ${lastSwipe.direction}` : 'No swipe yet'}
{endReached
? 'Done'
: lastSwipe
? `Last swipe: ${lastSwipe.direction} from ${lastSwipe.source}`
: 'No swipe yet'}
</Text>
);
}
Expand All @@ -240,7 +244,11 @@ Event hooks are commit-level, latest-value APIs:
`endReached`. Use `null` for object events such as `swipe`; `{}` is intentionally rejected so
the event payload type is not widened away.
- If you do pass an object initial value, the object is contextually typed from `eventName`, so
`useDeckEvent('swipe', { ... })` autocompletes `item`, `index`, and `direction`.
`useDeckEvent('swipe', { ... })` autocompletes `item`, `index`, `direction`, and `source`.
- Swipe events include `source: 'gesture' | 'programmatic'`. `gesture` means the user committed the
swipe by releasing a pan gesture past threshold/velocity. `programmatic` means the swipe was
committed through `actions.swipeLeft()` or `actions.swipeRight()`; apps may map that to a button
only when those actions are triggered by buttons in that app.
- For a named deck without an initial value, pass the id as the second argument:
`useDeckEvent('swipe', 'nearby')`. If you also pass an initial value, the id remains the third
argument.
Expand Down
4 changes: 2 additions & 2 deletions example/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,8 @@ export default function App() {

console.log('indexChangeEvent', indexChangeEvent);

ProfileDeck.useDeckEventListener('swipe', ({ item, direction }) => {
console.log(`Swiped ${item.name} ${direction}`);
ProfileDeck.useDeckEventListener('swipe', ({ item, direction, source }) => {
console.log(`Swiped ${item.name} ${direction} ${source}`);
});
ProfileDeck.useDeckEventListener('undo', ({ item, direction }) => {
console.log(`Undid ${item.name} ${direction}`);
Expand Down
21 changes: 14 additions & 7 deletions src/__tests__/SwipeDeck.integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ describe('SwipeDeck factory hooks', () => {
direction: 'right',
index: 0,
item: profiles[0],
source: 'programmatic',
});
expect(onIndexChange).toHaveBeenCalledWith(1);
});
Expand All @@ -130,7 +131,9 @@ describe('SwipeDeck factory hooks', () => {
function DeckControls() {
const actions = ProfileDeck.useDeckActions();
const lastSwipe = ProfileDeck.useDeckEvent('swipe', null);
const swipeText = lastSwipe ? `${lastSwipe.item.name}:${lastSwipe.direction}` : 'none';
const swipeText = lastSwipe
? `${lastSwipe.item.name}:${lastSwipe.direction}:${lastSwipe.source}`
: 'none';

return (
<View>
Expand Down Expand Up @@ -166,7 +169,7 @@ describe('SwipeDeck factory hooks', () => {

await user.press(screen.getByRole('button', { name: 'Swipe right' }));

expect(await screen.findByText('lastSwipe:Ada:right')).toBeOnTheScreen();
expect(await screen.findByText('lastSwipe:Ada:right:programmatic')).toBeOnTheScreen();

await renderResult.rerender(<Example mounted={false} />);

Expand Down Expand Up @@ -328,6 +331,7 @@ describe('SwipeDeck factory hooks', () => {
direction: 'right',
index: 0,
item: profiles[0],
source: 'programmatic',
});
expect(onIndexChange).toHaveBeenCalledTimes(1);
expect(onIndexChange).toHaveBeenCalledWith(1);
Expand Down Expand Up @@ -497,7 +501,9 @@ describe('SwipeDeck factory hooks', () => {
function DeckControls() {
const state = ProfileDeck.useDeckState();
const lastSwipe = ProfileDeck.useDeckEvent('swipe', null);
const swipeText = lastSwipe ? `${lastSwipe.item.name}:${lastSwipe.direction}` : 'none';
const swipeText = lastSwipe
? `${lastSwipe.item.name}:${lastSwipe.direction}:${lastSwipe.source}`
: 'none';

return (
<View>
Expand Down Expand Up @@ -551,12 +557,12 @@ describe('SwipeDeck factory hooks', () => {
]);

expect(await screen.findByText('state:1:true:false')).toBeOnTheScreen();
expect(screen.getByText('lastSwipe:Ada:right')).toBeOnTheScreen();
expect(screen.getByText('lastSwipe:Ada:right:gesture')).toBeOnTheScreen();
expect(onSwipe).toHaveBeenCalledTimes(1);

await renderResult.rerender(<Example allowedDirections={['left']} />);

expect(await screen.findByText('lastSwipe:Ada:right')).toBeOnTheScreen();
expect(await screen.findByText('lastSwipe:Ada:right:gesture')).toBeOnTheScreen();

fireGestureHandler(getByGestureTestId('swipe-deck-pan'), [
{ state: 2, y: 250 },
Expand All @@ -565,7 +571,7 @@ describe('SwipeDeck factory hooks', () => {
]);

expect(await screen.findByText('state:1:true:false')).toBeOnTheScreen();
expect(screen.getByText('lastSwipe:Ada:right')).toBeOnTheScreen();
expect(screen.getByText('lastSwipe:Ada:right:gesture')).toBeOnTheScreen();
expect(onSwipe).toHaveBeenCalledTimes(1);

fireGestureHandler(getByGestureTestId('swipe-deck-pan'), [
Expand All @@ -575,12 +581,13 @@ describe('SwipeDeck factory hooks', () => {
]);

expect(await screen.findByText('state:2:false:true')).toBeOnTheScreen();
expect(screen.getByText('lastSwipe:Grace:left')).toBeOnTheScreen();
expect(screen.getByText('lastSwipe:Grace:left:gesture')).toBeOnTheScreen();
expect(onSwipe).toHaveBeenCalledTimes(2);
expect(onSwipe).toHaveBeenLastCalledWith({
direction: 'left',
index: 1,
item: profiles[1],
source: 'gesture',
});
});

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 @@ -87,12 +87,14 @@ describe('createSwipeDeckRegistry', () => {
direction: 'right',
index: 0,
item: { id: 'ada' },
source: 'gesture',
});

expect(store.getEventSnapshot('swipe')?.event).toEqual({
direction: 'right',
index: 0,
item: { id: 'ada' },
source: 'gesture',
});
expect(listener).toHaveBeenCalledTimes(1);

Expand All @@ -109,6 +111,7 @@ describe('createSwipeDeckRegistry', () => {
direction: 'left',
index: 0,
item: { id: 'grace' },
source: 'programmatic',
});

detach();
Expand Down
21 changes: 15 additions & 6 deletions src/hooks/useSwipeDeckDismissRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
SwipeDeckLayout,
SwipeDeckMotionEasing,
SwipeDirection,
SwipeEventSource,
} from '../types';

import { isSwipeDirectionAllowed } from '../core/directions';
Expand Down Expand Up @@ -82,10 +83,17 @@ type UseSwipeDeckDismissRuntimeArgs<T> = {
};

type UseSwipeDeckDismissRuntimeResult = {
/**
* Shared dismiss completion boundary for every accepted swipe source.
*
* Keep all future gesture/action dismiss entry points on this path so `SwipeEvent.source` remains
* commit-time truth instead of an app- or caller-inferred label.
*/
completeSwipeDismiss: (
finished: boolean | undefined,
currentAttachmentGeneration: number,
direction: SwipeDirection,
source: SwipeEventSource,
) => void;
swipeProgrammatically: (
direction: SwipeDirection,
Expand Down Expand Up @@ -138,7 +146,7 @@ export function useSwipeDeckDismissRuntime<T>({
const pendingCommitResetRef = useRef(false);

const commitSwipe = useCallback(
(direction: SwipeDirection) => {
(direction: SwipeDirection, source: SwipeEventSource) => {
const currentData = dataRef.current;
const commit = getSwipeCommit(
currentData.length,
Expand All @@ -153,7 +161,7 @@ export function useSwipeDeckDismissRuntime<T>({
const item = currentData[commit.swipedIndex] as T;

recordSwipeForUndo({ item, index: commit.swipedIndex, direction });
emitDeckEvent('swipe', { item, index: commit.swipedIndex, direction });
emitDeckEvent('swipe', { item, index: commit.swipedIndex, direction, source });
emitDeckEvent('indexChange', { index: commit.nextIndex });
activeIndexRef.current = commit.nextIndex;
pendingCommitResetRef.current = true;
Expand All @@ -177,12 +185,12 @@ export function useSwipeDeckDismissRuntime<T>({
);

const commitSwipeIfCurrent = useCallback(
(generation: number, direction: SwipeDirection) => {
(generation: number, direction: SwipeDirection, source: SwipeEventSource) => {
if (generation !== attachmentGenerationRef.current) {
return;
}

commitSwipe(direction);
commitSwipe(direction, source);
},
[attachmentGenerationRef, commitSwipe],
);
Expand Down Expand Up @@ -220,6 +228,7 @@ export function useSwipeDeckDismissRuntime<T>({
finished: boolean | undefined,
currentAttachmentGeneration: number,
direction: SwipeDirection,
source: SwipeEventSource,
) => {
'worklet';

Expand All @@ -237,7 +246,7 @@ export function useSwipeDeckDismissRuntime<T>({
swipeDirectionSignal.set(0);
isDragging.set(false);
dragItemIndex.set(-1);
scheduleOnRN(commitSwipeIfCurrent, currentAttachmentGeneration, direction);
scheduleOnRN(commitSwipeIfCurrent, currentAttachmentGeneration, direction, source);
},
[
activeItemIndex,
Expand Down Expand Up @@ -327,7 +336,7 @@ export function useSwipeDeckDismissRuntime<T>({
const handleDismissCompletion = (finished: boolean | undefined) => {
'worklet';

completeSwipeDismiss(finished, currentAttachmentGeneration, direction);
completeSwipeDismiss(finished, currentAttachmentGeneration, direction, 'programmatic');
};

if (actionRuntime.type === 'springboard') {
Expand Down
4 changes: 3 additions & 1 deletion src/hooks/useSwipeDeckGestureRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
SwipeDeckLayout,
SwipeDeckMotionEasing,
SwipeDirection,
SwipeEventSource,
} from '../types';

import { resolveAllowedSwipeDirection, resolveSwipeDirection } from '../core/directions';
Expand All @@ -26,6 +27,7 @@ type CompleteSwipeDismiss = (
finished: boolean | undefined,
currentAttachmentGeneration: number,
direction: SwipeDirection,
source: SwipeEventSource,
) => void;

type ApplyScheduledRuntimeState = (
Expand Down Expand Up @@ -251,7 +253,7 @@ export function useSwipeDeckGestureRuntime({
withTiming(exitX, dismissTimingConfig, (finished) => {
'worklet';

completeSwipeDismiss(finished, currentAttachmentGeneration, direction);
completeSwipeDismiss(finished, currentAttachmentGeneration, direction, 'gesture');
}),
);
})
Expand Down
1 change: 1 addition & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export type {
SwipeDeckState,
SwipeDirection,
SwipeEvent,
SwipeEventSource,
SwipeRenderInfo,
SwipeRole,
UndoEvent,
Expand Down
11 changes: 11 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type { SharedValue, WithSpringConfig, WithTimingConfig } from 'react-nati

export type SwipeDirection = 'left' | 'right';

export type SwipeEventSource = 'gesture' | 'programmatic';

export type SwipeRole = 'current' | 'next';

export type SwipeDeckLayout = {
Expand All @@ -23,6 +25,15 @@ export type SwipeEvent<T> = {
item: T;
index: number;
direction: SwipeDirection;
/**
* How the swipe was committed.
*
* `gesture` means the user pan gesture released past the configured threshold/velocity policy.
* `programmatic` means the swipe was committed through `actions.swipeLeft()` or
* `actions.swipeRight()`. Programmatic does not imply a button; callers may map it to their own UI
* trigger when appropriate.
*/
source: SwipeEventSource;
};

export type UndoEvent<T> = {
Expand Down
6 changes: 5 additions & 1 deletion type-tests/useDeckEvent.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { SwipeEvent } from '../src';
import type { SwipeEvent, SwipeEventSource } from '../src';

import { createSwipeDeck } from '../src';

Expand All @@ -12,6 +12,8 @@ const profile: Profile = { id: 'ada', name: 'Ada' };

function expectType<T>(_value: T): void {}

expectType<SwipeEventSource>('gesture');
expectType<SwipeEventSource>('programmatic');
expectType<SwipeEvent<Profile> | undefined>(ProfileDeck.useDeckEvent('swipe'));
expectType<SwipeEvent<Profile> | undefined>(ProfileDeck.useDeckEvent('swipe', 'nearby'));
expectType<SwipeEvent<Profile> | null>(ProfileDeck.useDeckEvent('swipe', null));
Expand All @@ -21,6 +23,7 @@ expectType<SwipeEvent<Profile>>(
item: profile,
index: 0,
direction: 'right',
source: 'gesture',
}),
);
expectType<true | undefined>(ProfileDeck.useDeckEvent('endReached'));
Expand All @@ -34,6 +37,7 @@ ProfileDeck.useDeckEvent('swipe', {
item: profile,
index: 0,
direction: 'up',
source: 'gesture',
});

const rootWithCallbackProp = (
Expand Down