Hone logo
Hone
Problems

Implement usePinch Hook for Zoomable Elements in React

This challenge focuses on building a custom React hook, usePinch, that enables pinch-to-zoom functionality on DOM elements. This is a common requirement for image viewers, maps, and other interactive UIs where users need to magnify or reduce content with touch gestures.

Problem Description

You are tasked with creating a custom React hook named usePinch that accepts a React ref object pointing to a DOM element. This hook should listen for touch events on the ref's current element and calculate the pinch gesture's scale factor. The hook should return an object containing the current scale and a boolean indicating if a pinch gesture is active.

Key Requirements:

  • The hook should be implemented in TypeScript.
  • It should track the initial distance between two touch points.
  • It should calculate the current distance between touch points.
  • It should determine the scale factor by comparing the current distance to the initial distance.
  • It should expose the current scale (a number, where 1 is the default/no zoom) and an isPinching boolean (true when a pinch gesture is in progress).
  • The hook should handle the lifecycle of touch events correctly, including touchstart, touchmove, and touchend.
  • When touchmove occurs with two active touches, the isPinching state should be true.
  • When touchend occurs, isPinching should reset to false, and the scale should be updated to reflect the final zoom level.
  • The hook should handle cases where touch events might occur on elements outside the ref's current element (e.g., if the user drags their fingers off the element and back on).

Expected Behavior:

When the usePinch hook is attached to a DOM element:

  1. On touchstart with two fingers, the hook should start tracking the pinch gesture.
  2. On touchmove with two fingers, the hook should update the scale based on the change in distance between the fingers. isPinching should be true.
  3. On touchend or touchcancel with one or zero fingers remaining, the pinch gesture ends, and isPinching should be false. The final scale should be maintained.
  4. If the user starts a pinch gesture with two fingers and then one finger is lifted (touchstart on one finger, then a second finger is added), it should still initiate the pinch.
  5. The initial scale should be 1.

Edge Cases:

  • Handling cases where touch events fire with fewer than two or more than two fingers. The pinch gesture should only be active when exactly two fingers are down and moving.
  • Ensuring the hook cleans up event listeners when the component unmounts to prevent memory leaks.
  • The scale should not become negative.

Examples

Let's imagine a component that uses usePinch to zoom an image.

Example 1: Initial State

import React, { useRef } from 'react';
import usePinch from './usePinch'; // Assuming usePinch is in './usePinch'

function ZoomableImage() {
  const imageRef = useRef<HTMLImageElement>(null);
  const { scale, isPinching } = usePinch(imageRef);

  return (
    <img
      ref={imageRef}
      src="your-image.jpg"
      alt="Zoomable"
      style={{
        transform: `scale(${scale})`,
        transition: isPinching ? 'none' : 'transform 0.2s ease-out', // Smooth transition when not pinching
        cursor: 'grab',
        transformOrigin: 'center center', // Or 'top left' etc.
      }}
    />
  );
}

Output for Example 1:

Initially, scale will be 1 and isPinching will be false. The image will be displayed at its original size.

Explanation:

The usePinch hook is initialized. No touch events have occurred, so the scale is the default 1, and no pinch is active.

Example 2: Pinching In

Suppose the user places two fingers on the img element and moves them closer together.

Input to usePinch (internal state):

  • Initial touch points: [{ clientX: 100, clientY: 100 }, { clientX: 200, clientY: 200 }]
  • Initial distance: sqrt((200-100)^2 + (200-100)^2) = sqrt(10000 + 10000) = sqrt(20000) ≈ 141.4
  • Current touch points during pinch: [{ clientX: 110, clientY: 110 }, { clientX: 190, clientY: 190 }]
  • Current distance: sqrt((190-110)^2 + (190-110)^2) = sqrt(80^2 + 80^2) = sqrt(6400 + 6400) = sqrt(12800) ≈ 113.1
  • scale calculation: initialScale * (currentDistance / initialDistance) (let's assume initialScale was 1). So, 1 * (113.1 / 141.4) ≈ 0.8.

Output from usePinch:

{ scale: 0.8, isPinching: true }

Explanation:

The user is actively pinching, and the fingers are moving closer, resulting in a scale less than 1, effectively zooming out or shrinking the element. isPinching is true.

Example 3: Pinching Out and Ending

Suppose the user starts with two fingers close together and moves them apart, then lifts one finger.

Input to usePinch (internal state):

  • Initial touch points: [{ clientX: 150, clientY: 150 }, { clientX: 170, clientY: 170 }]
  • Initial distance: sqrt((170-150)^2 + (170-150)^2) = sqrt(20^2 + 20^2) = sqrt(400 + 400) = sqrt(800) ≈ 28.3
  • Current touch points during pinch: [{ clientX: 100, clientY: 100 }, { clientX: 200, clientY: 200 }]
  • Current distance: sqrt((200-100)^2 + (200-100)^2) = sqrt(100^2 + 100^2) = sqrt(10000 + 10000) = sqrt(20000) ≈ 141.4
  • scale calculation: 1 * (141.4 / 28.3) ≈ 5.
  • On touchend, one finger is lifted.

Output from usePinch:

After the touchmove phase: { scale: 5, isPinching: true } After touchend: { scale: 5, isPinching: false }

Explanation:

The user is pinching outwards, increasing the distance between fingers, leading to a scale greater than 1. The isPinching state is true during the move. When touchend occurs, the isPinching flag resets to false, but the scale remains at 5.

Constraints

  • The hook must be implemented using TypeScript.
  • It should only respond to TouchEvents (touchstart, touchmove, touchend, touchcancel).
  • The scale should be a floating-point number.
  • The hook should not modify the DOM directly; it should return values that the consumer can use for styling.
  • Performance: The hook should be efficient and avoid unnecessary re-renders or heavy computations within event handlers.

Notes

  • Remember to calculate the distance between two touch points using the distance formula: sqrt((x2 - x1)^2 + (y2 - y1)^2).
  • Consider using event.preventDefault() judiciously within touchmove to prevent default browser scrolling behavior during pinch gestures, especially if your target element is scrollable. However, be mindful of potential conflicts if other gestures are also being handled.
  • The transform-origin CSS property on the element being zoomed is crucial for consistent scaling behavior. You might want to suggest a default or leave it to the consumer.
  • The scale should be accumulated. Each pinch gesture should start from the current scale, not reset to 1.
Loading editor...
typescript