Created
February 7, 2024 10:27
-
-
Save KristofferEriksson/4a406501889d7fb768ed8de101f7be72 to your computer and use it in GitHub Desktop.
A custom React Typescript hook for advanced touch gestures in UI
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
Seriously underrated and powerful snippet. Thanks for this!
Thank you @chukwumaokere!
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!
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
Seriously underrated and powerful snippet. Thanks for this!