Skip to content

Instantly share code, notes, and snippets.

@KristofferEriksson
Created February 7, 2024 10:27
Show Gist options
  • Save KristofferEriksson/4a406501889d7fb768ed8de101f7be72 to your computer and use it in GitHub Desktop.
Save KristofferEriksson/4a406501889d7fb768ed8de101f7be72 to your computer and use it in GitHub Desktop.
A custom React Typescript hook for advanced touch gestures in UI
import { useEffect, useRef } from "react";
type GestureType =
| "swipeUp"
| "swipeDown"
| "swipeLeft"
| "swipeRight"
| "tap"
| "pinch"
| "zoom";
interface GestureConfig {
gesture: GestureType;
touchCount: number;
callback: () => void;
elementRef?: React.RefObject<HTMLElement>;
}
const useGesture = (config: GestureConfig) => {
// Use a function to lazily get the target element in a client-side environment
const getTargetElement = () => {
if (typeof window !== "undefined") {
return config.elementRef?.current || document;
}
// Return a dummy object for SSR
return {
addEventListener: () => {},
removeEventListener: () => {},
};
};
const targetElement = getTargetElement();
const gestureStateRef = useRef({
touchStartX: 0,
touchStartY: 0,
touchEndX: 0,
touchEndY: 0,
touchTime: 0,
initialDistance: 0,
finalDistance: 0,
gestureTriggered: false,
});
useEffect(() => {
const onTouchStart = (e: Event) => {
const touchEvent = e as TouchEvent;
if (touchEvent.touches.length === config.touchCount) {
e.preventDefault();
const touch = touchEvent.touches[0];
if (!touch) return;
gestureStateRef.current = {
...gestureStateRef.current,
touchStartX: touch.clientX,
touchStartY: touch.clientY,
touchTime: Date.now(),
gestureTriggered: false,
};
if (config.gesture === "pinch" || config.gesture === "zoom") {
const touch2 = touchEvent.touches[1];
if (!touch2) return;
gestureStateRef.current.initialDistance = Math.hypot(
touch2.clientX - touch.clientX,
touch2.clientY - touch.clientY,
);
}
}
};
const onTouchMove = (e: Event) => {
const touchEvent = e as TouchEvent;
if (
touchEvent.touches.length === config.touchCount &&
!gestureStateRef.current.gestureTriggered
) {
e.preventDefault();
const touch = touchEvent.touches[0];
if (!touch) return;
gestureStateRef.current.touchEndX = touch.clientX;
gestureStateRef.current.touchEndY = touch.clientY;
if (config.gesture === "pinch" || config.gesture === "zoom") {
const touch2 = touchEvent.touches[1];
if (!touch2) return;
gestureStateRef.current.finalDistance = Math.hypot(
touch2.clientX - touch.clientX,
touch2.clientY - touch.clientY,
);
}
}
};
const triggerGesture = () => {
config.callback();
};
const onTouchEnd = () => {
handleGesture();
};
const handleGesture = () => {
if (gestureStateRef.current.gestureTriggered) return;
const {
touchStartX,
touchStartY,
touchEndX,
touchEndY,
touchTime,
initialDistance,
finalDistance,
} = gestureStateRef.current;
const dx = touchEndX - touchStartX;
const dy = touchEndY - touchStartY;
const timeDiff = Date.now() - touchTime;
const distance = Math.hypot(dx, dy);
switch (config.gesture) {
case "swipeUp":
if (dy < -50 && Math.abs(dx) < 50) triggerGesture();
break;
case "swipeDown":
if (dy > 50 && Math.abs(dx) < 50) triggerGesture();
break;
case "swipeLeft":
if (dx < -50 && Math.abs(dy) < 50) triggerGesture();
break;
case "swipeRight":
if (dx > 50 && Math.abs(dy) < 50) triggerGesture();
break;
case "tap":
if (distance < 30 && timeDiff < 200) triggerGesture();
break;
case "pinch":
if (finalDistance < initialDistance) triggerGesture();
break;
case "zoom":
if (finalDistance > initialDistance) triggerGesture();
break;
}
gestureStateRef.current.gestureTriggered = true;
};
// Only attach event listeners in the browser environment
if (typeof window !== "undefined" || typeof document !== "undefined") {
targetElement.addEventListener("touchstart", onTouchStart, {
passive: false,
});
targetElement.addEventListener("touchmove", onTouchMove, {
passive: false,
});
targetElement.addEventListener("touchend", onTouchEnd, {
passive: false,
});
return () => {
targetElement.removeEventListener("touchstart", onTouchStart);
targetElement.removeEventListener("touchmove", onTouchMove);
targetElement.removeEventListener("touchend", onTouchEnd);
};
}
}, [config, targetElement]);
return gestureStateRef.current;
};
export default useGesture;
@chukwumaokere
Copy link

Seriously underrated and powerful snippet. Thanks for this!

@KristofferEriksson
Copy link
Author

Seriously underrated and powerful snippet. Thanks for this!

Thank you @chukwumaokere!

@noahsark769
Copy link

This is awesome! I found one bug in the implementation - touchEndX and touchEndY need to be reset on touchStart, otherwise there can be stale state which can result in gestures being incorrectly triggered, especially if you're swiping in a context which updates the position of a scrollable div. https://gist.github.com/noahsark769/cab946c5bd07b75f4adf389c775fec95 should have a fix!

@hortynz
Copy link

hortynz commented Oct 2, 2024

much appreciated! your example really helped me to resolve a problem I'd been vexed by for half a day

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment