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 anisPinchingboolean (true when a pinch gesture is in progress). - The hook should handle the lifecycle of touch events correctly, including
touchstart,touchmove, andtouchend. - When
touchmoveoccurs with two active touches, theisPinchingstate should betrue. - When
touchendoccurs,isPinchingshould reset tofalse, 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:
- On
touchstartwith two fingers, the hook should start tracking the pinch gesture. - On
touchmovewith two fingers, the hook should update thescalebased on the change in distance between the fingers.isPinchingshould betrue. - On
touchendortouchcancelwith one or zero fingers remaining, the pinch gesture ends, andisPinchingshould befalse. The finalscaleshould be maintained. - If the user starts a pinch gesture with two fingers and then one finger is lifted (
touchstarton one finger, then a second finger is added), it should still initiate the pinch. - The initial
scaleshould 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
scaleshould 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 scalecalculation:initialScale * (currentDistance / initialDistance)(let's assumeinitialScalewas 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 scalecalculation: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
scaleshould 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 withintouchmoveto 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-originCSS 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
scaleshould be accumulated. Each pinch gesture should start from the current scale, not reset to 1.