pinch to zoom in for scrollable table
09:02 28 Jan 2026

When performing a pinch-to-zoom gesture, the focal point (the spot between the two fingers) is supposed to stay fixed.

But in my case, it doesn’t..you can see it in the video:

The focal point starts right on the square, but as I zoom in, the square moves away from that point instead of staying under it.

Basically, the zoom doesn’t stay centered around the focal point as expected.

here is the code for handlling the pinch gesture:

  const [refreshing, setRefreshing] = useState(false);
  const [contentSize, setContentSize] = useState({ width: 0, height: 0 });
  const [scaleJS, setScaleJS] = useState(1);
  const verticalScrollRef = useRef(null);
  const horizontalScrollRef = useRef(null);
  const doubleTapRef = useRef(null);
  const translateX = useSharedValue(0);
  const translateY = useSharedValue(0);
  const scale = useSharedValue(1);
  const savedScale = useSharedValue(1);
  const lastOffset = useSharedValue({ x: 0, y: 0 });
  const panRef = useRef(null);
  const pinchRef = useRef(null);
  const { width, height } = useWindowDimensions();
  const [viewport, setViewport] = useState({ width, height });

  const MIN_ZOOM = 0.1;
  const MAX_ZOOM = 3.0;

  const onPinchGestureEvent = (event: any) => {
    "worklet";
    const { scale: gestureScale, focalX, focalY } = event.nativeEvent;

    const nextScale = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, savedScale.value * gestureScale));
    
    const pinchRatio = nextScale / scale.value;

    let nextX = translateX.value + (focalX - translateX.value) * (1 - pinchRatio);
    let nextY = translateY.value + (focalY - translateY.value) * (1 - pinchRatio);

    const scaledW = contentSize.width * nextScale;
    const scaledH = contentSize.height * nextScale;

    const minX = viewport.width < scaledW ? viewport.width - scaledW : 0;
    const minY = viewport.height < scaledH ? viewport.height - scaledH : 0;

    nextX = Math.min(0, Math.max(nextX, minX));
    nextY = Math.min(0, Math.max(nextY, minY));

    translateX.value = nextX;
    translateY.value = nextY;
    scale.value = nextScale;
  };

  const onPinchEnd = () => {
    "worklet";
    savedScale.value = scale.value;
    lastOffset.value = { x: translateX.value, y: translateY.value };

    if (scale.value <= 1) {
      translateX.value = withTiming(0);
      translateY.value = withTiming(0);
      lastOffset.value = { x: 0, y: 0 };
    }
  };

  const resetZoom = useCallback(() => {
  cancelAnimation(scale);

  scale.value = withTiming(1, { duration: 180 });
  savedScale.value = 1;

  translateX.value = withTiming(0, { duration: 180 });
   translateY.value = withTiming(0, { duration: 180 });

  setScaleJS(1);

  verticalScrollRef.current?.scrollTo({ y: 0, animated: false });
  horizontalScrollRef.current?.scrollTo({ x: 0, animated: false });

}, []);


  
   

  const onViewportLayout = (event: any) => {
    const { width, height } = event.nativeEvent.layout;
    setViewport({ width, height });
  };


  const onContentLayout = (event: any) => {
    const { width, height } = event.nativeEvent.layout;
    setContentSize({ width, height });
  };

  const onPanGestureEvent = (event: any) => {
    "worklet";

    const scaledW = contentSize.width * scale.value;
    const scaledH = contentSize.height * scale.value;

    // Limits: Content edge vs Viewport edge
    const minX = viewport.width < scaledW ? viewport.width - scaledW : 0;
    const minY = viewport.height < scaledH ? viewport.height - scaledH : 0;

    let nextX = lastOffset.value.x + event.nativeEvent.translationX;
    let nextY = lastOffset.value.y + event.nativeEvent.translationY;

    // CLAMP: Never let the user scroll past the content edges
    translateX.value = Math.min(0, Math.max(nextX, minX));
    translateY.value = Math.min(0, Math.max(nextY, minY));
  };

  const onPanEnd = () => {
    "worklet";
    lastOffset.value = { x: translateX.value, y: translateY.value };
  };

  return (
    
      
{!!error && ( Couldn’t load matches {String(error)} onRefresh()}> Retry )} {status === "loading" && page === 1 ? ( ) : null} setViewport(e.nativeEvent.layout)}> { if (e.nativeEvent.state === State.END) { resetZoom(); } }} > {drawPaths.map((p, i) => ( ))} {bracket.rounds.map((r) => { const colH = layout.colHeights[r.id] ?? layout.surfaceH; const list = bracket.matchesByRound[r.id] || []; return ( {r.name} {list.map((m) => { const rect = layout.pos[idOf(m)]; const top = rect ? rect.y - UI.ROUND_TITLE_H : 0; return ( ); })} {!list.length && No matches} ); })} {showMoreCard ? ( More {status === "loading" ? "Loading..." : "Load More"} ) : null} ); }
android pinchzoom scrollable-table