Skip to content

Pre-existing pan jump in _handleShifting on programmatic zoomTo during initialZoom-locked drag #174

@thomasttvo

Description

@thomasttvo

Surfaced during PR #151 review (Claude reviewer thread PRRT_kwDOGE0Kh85_gT2R, #151 (comment)). Pre-existing — verified the same control-flow ordering is present on origin/master (class-component _handleShifting at src/ReactNativeZoomableView.tsx:685+). Filing as a follow-up; not in scope for #151.

Summary

When disablePanOnInitialZoom={true} is set and the user is mid-pan at initialZoom, programmatically calling ref.zoomTo() (auto-fit, timed/state-driven zoom) produces a single-frame pan jump as soon as the animation crosses out of initialZoom. The jump magnitude equals the cumulative finger displacement that occurred while pan was blocked.

Root cause

In _handleShifting (post-PR #151 worklet form), the panEnabled/disablePanOnInitialZoom early-return runs before _calcOffsetShiftSinceLastGestureState:

const _handleShifting = (e: GestureTouchEvent) => {
  'worklet';
  if (
    !panEnabled.value ||
    (disablePanOnInitialZoom.value && zoom.value === initialZoom.value)
  ) {
    return;            // ← blocked frames return here
  }
  const shift = _calcOffsetShiftSinceLastGestureState(...);
  // ...
};

_calcOffsetShiftSinceLastGestureState is the only path that updates lastGestureCenterPosition.value during shift gestures. The seed-on-transition block in _handlePanResponderMove runs only on the gestureType !== 'shift''shift' transition and does not re-fire mid-gesture. So while pan is blocked at initialZoom, the tracker stays frozen at the position recorded at the shift-transition.

publicZoomTo writes zoom.value/zoomToDestination.value/prevZoom.value but does not touch lastGestureCenterPosition (in contrast to the pinch-transition reset at the move-handler that resets both lastGestureCenterPosition and lastGestureTouchDistance).

When the animation lifts the early-return gate, the next move event computes delta = currentTouch - staleLastGestureCenterPosition and applies the entire blocked displacement in one frame.

Reproduction

  1. <ReactNativeZoomableView disablePanOnInitialZoom initialZoom={1} ref={r} />.
  2. Finger down at (50, 50). Drag to (160, 50) over several frames — _handleShifting early-returns each frame; tracker stays at the shift-transition seed.
  3. App calls r.current.zoomTo(2) mid-drag.
  4. As soon as zoom !== 1, the next move event applies the cumulative blocked shift in one frame — visible discontinuity.

Suggested fix

Either:

Option A — keep tracker current on blocked frames:

const _handleShifting = (e: GestureTouchEvent) => {
  'worklet';
  lastGestureCenterPosition.value = { x: e.allTouches[0].x, y: e.allTouches[0].y };
  if (!panEnabled.value || (disablePanOnInitialZoom.value && zoom.value === initialZoom.value)) {
    return;
  }
  const shift = _calcOffsetShiftSinceLastGestureState(...);
  // ...
};

Option B — publicZoomTo resets gesture-tracking refs when gestureStarted.value is true, mirroring the pinch-transition reset.

Either change closes the asymmetry vs. the pinch-transition path that already exists in this file.

Notes

  • Trigger requires disablePanOnInitialZoom={true} (opt-in) + active 1-finger drag + programmatic zoomTo/zoomBy/withTiming mid-drag.
  • The pre-PR-Reanimated #151 SPECS.md row that documented "Same jump occurs when disablePanOnInitialZoom auto-unblocks as zoom crosses above initialZoom" was removed in Reanimated #151's SPECS rewrite without fixing the underlying behavior; consider restoring documentation if not fixing immediately.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions