diff --git a/.changeset/calm-cards-glide.md b/.changeset/calm-cards-glide.md new file mode 100644 index 0000000..8653b6e --- /dev/null +++ b/.changeset/calm-cards-glide.md @@ -0,0 +1,7 @@ +--- +'@react-native-motion-kit/swipe-deck': patch +--- + +Prevent gesture-driven dismisses from briefly jumping when the finger is released on Android. +Committed swipes now animate only their primary dismiss axis, preserving the final cross-axis drag +position instead of restarting an unnecessary timing animation during the gesture handoff. diff --git a/src/__tests__/animation.test.ts b/src/__tests__/animation.test.ts index 5c521f9..e74061a 100644 --- a/src/__tests__/animation.test.ts +++ b/src/__tests__/animation.test.ts @@ -3,6 +3,7 @@ import { Easing } from 'react-native-reanimated'; import { mergeSwipeDeckMotionPreset, + resolveSwipeDeckDismissAxisMotion, resolveSwipeDeckDismissAxisDuration, resolveSwipeDeckDismissDestination, resolveSwipeDeckDismissDestinationDistance, @@ -14,6 +15,40 @@ import { SwipeDeckMotion, } from '../motion/animation'; +describe('resolveSwipeDeckDismissAxisMotion', () => { + it('selects only the dismiss axis and leaves cross-axis displacement out of the animation', () => { + expect( + resolveSwipeDeckDismissAxisMotion({ + destination: { axis: 'x', translateX: 620, translateY: 37 }, + translationX: 180, + translationY: 12, + velocityX: 900, + velocityY: 240, + }), + ).toEqual({ + axis: 'x', + destination: 620, + translation: 180, + velocity: 900, + }); + + expect( + resolveSwipeDeckDismissAxisMotion({ + destination: { axis: 'y', translateX: 37, translateY: -780 }, + translationX: 12, + translationY: -180, + velocityX: 240, + velocityY: -900, + }), + ).toEqual({ + axis: 'y', + destination: -780, + translation: -180, + velocity: -900, + }); + }); +}); + describe('SwipeDeckMotion', () => { it('creates a discriminated tinder motion preset', () => { expect(SwipeDeckMotion.tinder({ nextScale: 0.9 })).toMatchObject({ diff --git a/src/hooks/useSwipeDeckGestureRuntime.ts b/src/hooks/useSwipeDeckGestureRuntime.ts index ee694a8..4df7965 100644 --- a/src/hooks/useSwipeDeckGestureRuntime.ts +++ b/src/hooks/useSwipeDeckGestureRuntime.ts @@ -18,8 +18,9 @@ import type { import { resolveSwipeDirection } from '../core/directions'; import { resolveSwipeProgressIntent, resolveSwipeDirectionSignal } from '../core/swipeDeckRuntime'; import { - resolveSwipeDeckDismissDestination, + resolveSwipeDeckDismissAxisMotion, resolveSwipeDeckDismissAxisDuration, + resolveSwipeDeckDismissDestination, resolveSwipeDeckGestureStartYRatio, } from '../motion/animation'; @@ -244,10 +245,17 @@ export function useSwipeDeckGestureRuntime({ translationX: event.translationX, translationY: event.translationY, }); + const dismissAxisMotion = resolveSwipeDeckDismissAxisMotion({ + destination, + translationX: event.translationX, + translationY: event.translationY, + velocityX: event.velocityX, + velocityY: event.velocityY, + }); const resolvedDismissDuration = resolveSwipeDeckDismissAxisDuration({ - translation: destination.axis === 'x' ? event.translationX : event.translationY, - velocity: destination.axis === 'x' ? event.velocityX : event.velocityY, - destination: destination.axis === 'x' ? destination.translateX : destination.translateY, + translation: dismissAxisMotion.translation, + velocity: dismissAxisMotion.velocity, + destination: dismissAxisMotion.destination, duration: dismissDuration, minDuration: dismissMinDuration, maxDuration: dismissMaxDuration, @@ -262,22 +270,25 @@ export function useSwipeDeckGestureRuntime({ swipeDirectionSignal.set(progressDirection); signedSwipeProgress.set(withTiming(progressDirection, dismissTimingConfig)); swipeProgress.set(withTiming(1, dismissTimingConfig)); - activeTranslateY.set( - withTiming(destination.translateY, dismissTimingConfig, (finished) => { - 'worklet'; - if (destination.axis === 'y') { + // Keep the cross axis on its last drag frame. Restarting it from the final Android + // gesture event can introduce a visible one-frame jump at the dismiss handoff. + if (dismissAxisMotion.axis === 'y') { + activeTranslateY.set( + withTiming(dismissAxisMotion.destination, dismissTimingConfig, (finished) => { + 'worklet'; + completeSwipeDismiss(finished, currentAttachmentGeneration, direction, 'gesture'); - } - }), - ); + }), + ); + return; + } + activeTranslateX.set( - withTiming(destination.translateX, dismissTimingConfig, (finished) => { + withTiming(dismissAxisMotion.destination, dismissTimingConfig, (finished) => { 'worklet'; - if (destination.axis === 'x') { - completeSwipeDismiss(finished, currentAttachmentGeneration, direction, 'gesture'); - } + completeSwipeDismiss(finished, currentAttachmentGeneration, direction, 'gesture'); }), ); }) diff --git a/src/motion/animation.ts b/src/motion/animation.ts index 6f93c75..65bc971 100644 --- a/src/motion/animation.ts +++ b/src/motion/animation.ts @@ -78,6 +78,21 @@ export type SwipeDeckTranslation = SwipeDeckMotionTranslation; export type SwipeDeckDismissDestination = SwipeDeckMotionVector; +type SwipeDeckDismissAxisMotion = { + axis: SwipeDeckDismissDestination['axis']; + translation: number; + velocity: number; + destination: number; +}; + +type ResolveSwipeDeckDismissAxisMotionArgs = { + destination: SwipeDeckDismissDestination; + translationX: number; + translationY: number; + velocityX: number; + velocityY: number; +}; + type ResolveSwipeDeckTinderRotationAnchorArgs = { mode: SwipeDeckTinderRotationMode; origin?: SwipeDeckTinderFixedRotationOrigin; @@ -339,6 +354,32 @@ export function resolveSwipeDeckDismissDestination( }); } +export function resolveSwipeDeckDismissAxisMotion({ + destination, + translationX, + translationY, + velocityX, + velocityY, +}: ResolveSwipeDeckDismissAxisMotionArgs): SwipeDeckDismissAxisMotion { + 'worklet'; + + if (destination.axis === 'x') { + return { + axis: 'x', + translation: translationX, + velocity: velocityX, + destination: destination.translateX, + }; + } + + return { + axis: 'y', + translation: translationY, + velocity: velocityY, + destination: destination.translateY, + }; +} + function mergeSwipeDeckTinderRotationConfig( base: SwipeDeckTinderRotationConfig | undefined, override: SwipeDeckTinderRotationConfig | undefined,