diff --git a/.changeset/swipe-event-source.md b/.changeset/swipe-event-source.md new file mode 100644 index 0000000..c3cdde3 --- /dev/null +++ b/.changeset/swipe-event-source.md @@ -0,0 +1,24 @@ +--- +'@react-native-motion-kit/swipe-deck': minor +--- + +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`, so object literals, fixtures, or `useDeckEvent('swipe', initialValue)` values must +include it. diff --git a/README.ko.md b/README.ko.md index f077c98..a87defd 100644 --- a/README.ko.md +++ b/README.ko.md @@ -61,8 +61,8 @@ const ProfileDeck = createSwipeDeck({ }); 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'); @@ -228,7 +228,11 @@ function ProfileDeckEvents() { return ( - {endReached ? 'Done' : lastSwipe ? `Last swipe: ${lastSwipe.direction}` : 'No swipe yet'} + {endReached + ? 'Done' + : lastSwipe + ? `Last swipe: ${lastSwipe.direction} from ${lastSwipe.source}` + : 'No swipe yet'} ); } @@ -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에서는 diff --git a/README.md b/README.md index bd0b30e..7711ef0 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,8 @@ const ProfileDeck = createSwipeDeck({ }); 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'); @@ -227,7 +227,11 @@ function ProfileDeckEvents() { return ( - {endReached ? 'Done' : lastSwipe ? `Last swipe: ${lastSwipe.direction}` : 'No swipe yet'} + {endReached + ? 'Done' + : lastSwipe + ? `Last swipe: ${lastSwipe.direction} from ${lastSwipe.source}` + : 'No swipe yet'} ); } @@ -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. diff --git a/example/App.tsx b/example/App.tsx index c60eb10..91b2767 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -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}`); diff --git a/src/__tests__/SwipeDeck.integration.test.tsx b/src/__tests__/SwipeDeck.integration.test.tsx index 0930e75..5e72f59 100644 --- a/src/__tests__/SwipeDeck.integration.test.tsx +++ b/src/__tests__/SwipeDeck.integration.test.tsx @@ -119,6 +119,7 @@ describe('SwipeDeck factory hooks', () => { direction: 'right', index: 0, item: profiles[0], + source: 'programmatic', }); expect(onIndexChange).toHaveBeenCalledWith(1); }); @@ -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 ( @@ -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(); @@ -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); @@ -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 ( @@ -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(); - 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 }, @@ -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'), [ @@ -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', }); }); diff --git a/src/__tests__/registry.test.ts b/src/__tests__/registry.test.ts index a3cb26c..de725cc 100644 --- a/src/__tests__/registry.test.ts +++ b/src/__tests__/registry.test.ts @@ -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); @@ -109,6 +111,7 @@ describe('createSwipeDeckRegistry', () => { direction: 'left', index: 0, item: { id: 'grace' }, + source: 'programmatic', }); detach(); diff --git a/src/hooks/useSwipeDeckDismissRuntime.ts b/src/hooks/useSwipeDeckDismissRuntime.ts index 595d0df..9b1a7d5 100644 --- a/src/hooks/useSwipeDeckDismissRuntime.ts +++ b/src/hooks/useSwipeDeckDismissRuntime.ts @@ -14,6 +14,7 @@ import type { SwipeDeckLayout, SwipeDeckMotionEasing, SwipeDirection, + SwipeEventSource, } from '../types'; import { isSwipeDirectionAllowed } from '../core/directions'; @@ -82,10 +83,17 @@ type UseSwipeDeckDismissRuntimeArgs = { }; 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, @@ -138,7 +146,7 @@ export function useSwipeDeckDismissRuntime({ const pendingCommitResetRef = useRef(false); const commitSwipe = useCallback( - (direction: SwipeDirection) => { + (direction: SwipeDirection, source: SwipeEventSource) => { const currentData = dataRef.current; const commit = getSwipeCommit( currentData.length, @@ -153,7 +161,7 @@ export function useSwipeDeckDismissRuntime({ 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; @@ -177,12 +185,12 @@ export function useSwipeDeckDismissRuntime({ ); 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], ); @@ -220,6 +228,7 @@ export function useSwipeDeckDismissRuntime({ finished: boolean | undefined, currentAttachmentGeneration: number, direction: SwipeDirection, + source: SwipeEventSource, ) => { 'worklet'; @@ -237,7 +246,7 @@ export function useSwipeDeckDismissRuntime({ swipeDirectionSignal.set(0); isDragging.set(false); dragItemIndex.set(-1); - scheduleOnRN(commitSwipeIfCurrent, currentAttachmentGeneration, direction); + scheduleOnRN(commitSwipeIfCurrent, currentAttachmentGeneration, direction, source); }, [ activeItemIndex, @@ -327,7 +336,7 @@ export function useSwipeDeckDismissRuntime({ const handleDismissCompletion = (finished: boolean | undefined) => { 'worklet'; - completeSwipeDismiss(finished, currentAttachmentGeneration, direction); + completeSwipeDismiss(finished, currentAttachmentGeneration, direction, 'programmatic'); }; if (actionRuntime.type === 'springboard') { diff --git a/src/hooks/useSwipeDeckGestureRuntime.ts b/src/hooks/useSwipeDeckGestureRuntime.ts index d463524..aa2383f 100644 --- a/src/hooks/useSwipeDeckGestureRuntime.ts +++ b/src/hooks/useSwipeDeckGestureRuntime.ts @@ -12,6 +12,7 @@ import type { SwipeDeckLayout, SwipeDeckMotionEasing, SwipeDirection, + SwipeEventSource, } from '../types'; import { resolveAllowedSwipeDirection, resolveSwipeDirection } from '../core/directions'; @@ -26,6 +27,7 @@ type CompleteSwipeDismiss = ( finished: boolean | undefined, currentAttachmentGeneration: number, direction: SwipeDirection, + source: SwipeEventSource, ) => void; type ApplyScheduledRuntimeState = ( @@ -251,7 +253,7 @@ export function useSwipeDeckGestureRuntime({ withTiming(exitX, dismissTimingConfig, (finished) => { 'worklet'; - completeSwipeDismiss(finished, currentAttachmentGeneration, direction); + completeSwipeDismiss(finished, currentAttachmentGeneration, direction, 'gesture'); }), ); }) diff --git a/src/index.tsx b/src/index.tsx index c0bc1a6..5f92cbf 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -44,6 +44,7 @@ export type { SwipeDeckState, SwipeDirection, SwipeEvent, + SwipeEventSource, SwipeRenderInfo, SwipeRole, UndoEvent, diff --git a/src/types.ts b/src/types.ts index aa9136f..debe10d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 = { @@ -23,6 +25,15 @@ export type SwipeEvent = { 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 = { diff --git a/type-tests/useDeckEvent.tsx b/type-tests/useDeckEvent.tsx index ba92076..6ed6098 100644 --- a/type-tests/useDeckEvent.tsx +++ b/type-tests/useDeckEvent.tsx @@ -1,4 +1,4 @@ -import type { SwipeEvent } from '../src'; +import type { SwipeEvent, SwipeEventSource } from '../src'; import { createSwipeDeck } from '../src'; @@ -12,6 +12,8 @@ const profile: Profile = { id: 'ada', name: 'Ada' }; function expectType(_value: T): void {} +expectType('gesture'); +expectType('programmatic'); expectType | undefined>(ProfileDeck.useDeckEvent('swipe')); expectType | undefined>(ProfileDeck.useDeckEvent('swipe', 'nearby')); expectType | null>(ProfileDeck.useDeckEvent('swipe', null)); @@ -21,6 +23,7 @@ expectType>( item: profile, index: 0, direction: 'right', + source: 'gesture', }), ); expectType(ProfileDeck.useDeckEvent('endReached')); @@ -34,6 +37,7 @@ ProfileDeck.useDeckEvent('swipe', { item: profile, index: 0, direction: 'up', + source: 'gesture', }); const rootWithCallbackProp = (