Eventos de Tacto
Maneja interacciones de tacto en dispositivos móviles -- toques, gestos de deslizamiento, gestos de pinza y tacto multi-toque.
Referencia de Eventos
| Evento | Se Dispara Cuando | Objeto de Evento |
|---|---|---|
onTouchStart | Un dedo toca la pantalla | React.TouchEvent |
onTouchMove | Un dedo se mueve mientras toca la pantalla | React.TouchEvent |
onTouchEnd | Un dedo se levanta de la pantalla | React.TouchEvent |
onTouchCancel | El sistema cancela el toque (p. ej., llamada entrante, conflicto de gesto) | React.TouchEvent |
Cada React.TouchEvent expone:
| Propiedad | Tipo | Descripción |
|---|---|---|
touches | TouchList | Todos los dedos actualmente en la pantalla |
targetTouches | TouchList | Dedos en el elemento objetivo actual |
changedTouches | TouchList | Dedos implicados en el evento actual |
Cada objeto Touch contiene: identifier, clientX, clientY, screenX, screenY, pageX, pageY, target.
Receta
Tarjeta de referencia rápida -- lista para copiar y pegar.
// SwipeHandler.tsx -- detección básica de deslizamiento
"use client";
import { useRef, useCallback } from "react";
export function useSwipe(onSwipe: (direction: "left" | "right" | "up" | "down") => void) {
const startX = useRef(0);
const startY = useRef(0);
const handleTouchStart = useCallback((e: React.TouchEvent) => {
startX.current = e.changedTouches[0].clientX;
startY.current = e.changedTouches[0].clientY;
}, []);
const handleTouchEnd = useCallback(
(e: React.TouchEvent) => {
const dx = e.changedTouches[0].clientX - startX.current;
const dy = e.changedTouches[0].clientY - startY.current;
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
const threshold = 50;
if (Math.max(absDx, absDy) < threshold) return;
if (absDx > absDy) {
onSwipe(dx > 0 ? "right" : "left");
} else {
onSwipe(dy > 0 ? "down" : "up");
}
},
[onSwipe]
);
return { onTouchStart: handleTouchStart, onTouchEnd: handleTouchEnd };
}Cuándo usarlo: Necesitas detectar gestos de deslizamiento en dispositivos móviles y no puedes confiar en CSS scroll-snap o una biblioteca de gestos.
Ejemplo de Funcionamiento
// SwipeableCards.tsx
"use client";
import { useState, useCallback } from "react";
type Card = { id: number; title: string; color: string };
const cards: Card[] = [
{ id: 1, title: "Welcome", color: "#3b82f6" },
{ id: 2, title: "Features", color: "#10b981" },
{ id: 3, title: "Pricing", color: "#f59e0b" },
{ id: 4, title: "Get Started", color: "#ef4444" },
];
export default function SwipeableCards() {
const [currentIndex, setCurrentIndex] = useState(0);
const [touchStart, setTouchStart] = useState<number | null>(null);
const [touchDelta, setTouchDelta] = useState(0);
const handleTouchStart = useCallback((e: React.TouchEvent) => {
setTouchStart(e.changedTouches[0].clientX);
setTouchDelta(0);
}, []);
const handleTouchMove = useCallback(
(e: React.TouchEvent) => {
if (touchStart === null) return;
const current = e.changedTouches[0].clientX;
setTouchDelta(current - touchStart);
},
[touchStart]
);
const handleTouchEnd = useCallback(() => {
const threshold = 80;
if (touchDelta < -threshold && currentIndex < cards.length - 1) {
setCurrentIndex((prev) => prev + 1);
} else if (touchDelta > threshold && currentIndex > 0) {
setCurrentIndex((prev) => prev - 1);
}
setTouchStart(null);
setTouchDelta(0);
}, [touchDelta, currentIndex]);
return (
<div className="overflow-hidden w-full max-w-sm mx-auto">
<div
className="flex transition-transform duration-300 ease-out"
style={{
transform: `translateX(calc(-${currentIndex * 100}% + ${touchStart !== null ? touchDelta : 0}px))`,
transition: touchStart !== null ? "none" : "transform 0.3s ease-out",
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{cards.map((card) => (
<div
key={card.id}
className="min-w-full p-8 rounded-xl text-white text-center text-2xl font-bold"
style={{ backgroundColor: card.color }}
>
{card.title}
</div>
))}
</div>
<div className="flex justify-center gap-2 mt-4">
{cards.map((_, i) => (
<div
key={i}
className={`w-2 h-2 rounded-full ${i === currentIndex ? "bg-gray-800" : "bg-gray-300"}`}
/>
))}
</div>
</div>
);
}Lo que esto demuestra:
- Usar
onTouchStart,onTouchMoveyonTouchEndjuntos para navegar con deslizamiento - Rastrear el delta de toque en state para retroalimentación visual en tiempo real durante el deslizamiento
- Aplicar un umbral para distinguir deslizamientos intencionales de toques accidentales
- Cambiar entre transiciones CSS y sin transición durante tacto activo
Análisis Profundo
Cómo Funciona
- Los eventos de tacto se disparan en el orden:
touchstart->touchmove(repetido) ->touchendotouchcancel. - React normaliza eventos de tacto a través de
SyntheticEventpara que se comporten de manera consistente en navegadores. - Cada punto de toque tiene un
identifierúnico que persiste desdetouchstarthastatouchend, lo que permite rastrear multi-toque. changedTouchescontiene solo los toques que cambiaron en el evento actual, mientras quetouchescontiene todos los toques activos.- En la mayoría de navegadores móviles, los eventos de tacto se disparan ~60 veces por segundo durante un movimiento, por lo que los manejadores deben mantenerse ligeros.
Variaciones
Detección de deslizamiento izquierda/derecha con velocidad:
"use client";
import { useRef, useCallback } from "react";
export function useSwipeWithVelocity(
onSwipe: (dir: "left" | "right", velocity: number) => void
) {
const startRef = useRef({ x: 0, time: 0 });
const handleTouchStart = useCallback((e: React.TouchEvent) => {
startRef.current = {
x: e.changedTouches[0].clientX,
time: Date.now(),
};
}, []);
const handleTouchEnd = useCallback(
(e: React.TouchEvent) => {
const dx = e.changedTouches[0].clientX - startRef.current.x;
const dt = Date.now() - startRef.current.time;
const velocity = Math.abs(dx) / dt; // px por ms
// Deslizamiento rápido (alta velocidad) necesita menos distancia
const threshold = velocity > 0.5 ? 30 : 80;
if (Math.abs(dx) < threshold) return;
onSwipe(dx > 0 ? "right" : "left", velocity);
},
[onSwipe]
);
return { onTouchStart: handleTouchStart, onTouchEnd: handleTouchEnd };
}Pinch-to-zoom:
"use client";
import { useState, useRef, useCallback } from "react";
function getDistance(t1: React.Touch, t2: React.Touch): number {
return Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
}
export function PinchZoomImage({ src, alt }: { src: string; alt: string }) {
const [scale, setScale] = useState(1);
const initialDistance = useRef(0);
const initialScale = useRef(1);
const handleTouchStart = useCallback(
(e: React.TouchEvent) => {
if (e.touches.length === 2) {
initialDistance.current = getDistance(e.touches[0], e.touches[1]);
initialScale.current = scale;
}
},
[scale]
);
const handleTouchMove = useCallback((e: React.TouchEvent) => {
if (e.touches.length === 2) {
e.preventDefault();
const currentDistance = getDistance(e.touches[0], e.touches[1]);
const ratio = currentDistance / initialDistance.current;
setScale(Math.min(Math.max(initialScale.current * ratio, 0.5), 4));
}
}, []);
return (
<div
className="overflow-hidden touch-none"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
>
<img
src={src}
alt={alt}
className="w-full transition-transform"
style={{ transform: `scale(${scale})` }}
/>
</div>
);
}Presión larga en dispositivos móviles:
"use client";
import { useRef, useCallback } from "react";
export function useLongPress(onLongPress: () => void, delay = 500) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const start = useCallback(
(e: React.TouchEvent) => {
e.preventDefault();
timerRef.current = setTimeout(onLongPress, delay);
},
[onLongPress, delay]
);
const cancel = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
return {
onTouchStart: start,
onTouchEnd: cancel,
onTouchMove: cancel,
onTouchCancel: cancel,
};
}Tacto vs clic -- manejar ambos sin disparo doble:
"use client";
import { useRef, useCallback } from "react";
export function useTapOrClick(handler: () => void) {
const touchedRef = useRef(false);
const onTouchEnd = useCallback(
(e: React.TouchEvent) => {
touchedRef.current = true;
e.preventDefault(); // previene el evento de clic subsecuente
handler();
},
[handler]
);
const onClick = useCallback(() => {
// Solo se dispara en dispositivos sin tacto ya que preventDefault de tacto lo detiene
if (!touchedRef.current) {
handler();
}
touchedRef.current = false;
}, [handler]);
return { onTouchEnd, onClick };
}Prevenir desplazamiento durante interacción de tacto:
"use client";
import { useEffect, useRef } from "react";
export function usePreventScrollDuringTouch(active: boolean) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el || !active) return;
const prevent = (e: TouchEvent) => e.preventDefault();
// Debe usar { passive: false } para permitir preventDefault
el.addEventListener("touchmove", prevent, { passive: false });
return () => el.removeEventListener("touchmove", prevent);
}, [active]);
return ref;
}Notas de TypeScript
// El tipo de evento central
function handleTouch(e: React.TouchEvent<HTMLDivElement>) {
// changedTouches es una TouchList -- tipo array, no un array real
const touch: React.Touch = e.changedTouches[0];
const x: number = touch.clientX;
const y: number = touch.clientY;
const id: number = touch.identifier;
// Convertir TouchList a array para iteración
const allTouches: React.Touch[] = Array.from(e.touches);
}
// Tipado de elemento genérico
function handleTouchOnCanvas(e: React.TouchEvent<HTMLCanvasElement>) {
const canvas = e.currentTarget; // tipado como HTMLCanvasElement
const rect = canvas.getBoundingClientRect();
const touch = e.changedTouches[0];
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
}
// Tipo de retorno de hook personalizado
type SwipeHandlers = {
onTouchStart: (e: React.TouchEvent) => void;
onTouchMove: (e: React.TouchEvent) => void;
onTouchEnd: (e: React.TouchEvent) => void;
};
// Rastreo de identificador de toque en eventos
const activeTouches = useRef<Map<number, { startX: number; startY: number }>>(
new Map()
);Errores Comunes
-
touchesestá vacío enonTouchEnd-- Cuando el último dedo se levanta,touchesytargetTouchesestán vacíos porque no quedan dedos. Solución: UsachangedTouchesenonTouchEndpara obtener la posición del dedo que acaba de levantarse. -
e.preventDefault()en manejadores de tacto no hace nada con listeners pasivos -- React 17+ registra eventos de tacto como pasivos por defecto en la raíz, lo que significa quepreventDefault()enonTouchMovefalla silenciosamente y la página sigue desplazándose. Solución: Usa unrefy adjunta el listener manualmente con{ passive: false }, o aplicatouch-action: noneen CSS. -
Los eventos de tacto disparan un clic sintético 300ms después -- En navegadores móviles, un
touchendes seguido por un eventoclickretrasado. Si manejas tantoonTouchEndcomoonClick, el manejador se dispara dos veces. Solución: Llama ae.preventDefault()enonTouchEndpara suprimir el clic, o usa eventos de puntero en su lugar. -
TouchList no es un array --
e.touches,e.targetTouchesye.changedTouchesson objetosTouchList, no arrays. Llamar a.map()o.filter()directamente lanza un error. Solución: Convierte conArray.from(e.touches)antes de iterar. -
Los valores
identifierde multi-toque no son secuenciales -- No asumas que los identificadores de toque comienzan en 0 o se incrementan en 1. El navegador asigna IDs de números enteros arbitrarios. Solución: Rastrea toques usando unMap<number, TouchData>indexado poridentifier. -
Acceder a propiedades
nativeEventdespués del trabajo asincrónico -- React agrupa eventos sintéticos. Si almacenas el evento y lo lees más tarde, las propiedades pueden anularse. Solución: Extrae los valores que necesitas sincrónicamente (const x = e.changedTouches[0].clientX) antes de cualquierawaitosetState. -
El pinch zoom entra en conflicto con el zoom del navegador -- El pinch de dos dedos activa el zoom nativo del navegador en muchos navegadores móviles, compitiendo con tu manejador de pinch personalizado. Solución: Establece
touch-action: noneen el elemento y agrega<meta name="viewport" content="... user-scalable=no">(pero ten en cuenta que esto afecta la accesibilidad).
Alternativas
| Alternativa | Usar Cuando | No Usar Cuando |
|---|---|---|
Pointer Events (onPointerDown, etc.) | Quieres manejo unificado de ratón + tacto + lápiz | Necesitas específicamente información de multi-toque como touches y changedTouches |
CSS touch-action | Quieres controlar desplazamiento/zoom sin JS | Necesitas responder a la posición del toque o datos de gesto |
| Bibliotecas de gestos (use-gesture, Hammer.js) | Necesitas gestos complejos como rotar, pinza, fling con física | Un deslizamiento simple o toque es suficiente |
Solo onClick | Toques y clics se comportan igual para tu caso de uso | Necesitas arrastrar, deslizar o multi-toque |
CSS scroll-snap | Quieres comportamiento de carrusel deslizable | Necesitas control programático sobre umbrales de deslizamiento o animaciones |
Preguntas Frecuentes
¿Cuál es la diferencia entre touches, targetTouches y changedTouches?
touches-- todos los dedos actualmente en la pantallatargetTouches-- todos los dedos en el elemento que originó el toquechangedTouches-- solo los dedos que cambiaron en este evento específico (comenzaron, se movieron o se levantaron)- En
onTouchEnd, usachangedTouchesporquetouchesya no incluye el dedo levantado
¿Por qué mi onTouchMove preventDefault no detiene el desplazamiento?
- React registra listeners de eventos de tacto como pasivos por defecto en el nivel raíz
- Los listeners pasivos no pueden llamar a
preventDefault() - Usa un ref y adjunta manualmente con
addEventListener("touchmove", handler, { passive: false }) - O aplica
touch-action: noneen CSS en el elemento
¿Cómo detecto un toque versus un deslizamiento?
- Rastrea la posición de inicio en
onTouchStarty compárala con la posición final enonTouchEnd - Si la distancia total movida está por debajo de un umbral (p. ej., 10px), trátalo como un toque
- Si la distancia excede un umbral de deslizamiento (p. ej., 50px), trátalo como un deslizamiento
- También puedes considerar el tiempo -- un movimiento rápido y corto es más probable que sea un toque
¿Debería usar eventos de tacto o eventos de puntero en React 19?
- Los eventos de puntero son el estándar moderno y funcionan en ratón, tacto y lápiz
- Los eventos de tacto proporcionan detalles de multi-toque (
touches,changedTouches) que faltan en eventos de puntero - Usa eventos de puntero para interacciones de un solo punto (clic, arrastrar, pasar)
- Usa eventos de tacto cuando necesites gestos de multi-toque (pinza, rotación de dos dedos)
¿Cómo manejo el toque en un elemento canvas?
function handleCanvasTouch(e: React.TouchEvent<HTMLCanvasElement>) {
const rect = e.currentTarget.getBoundingClientRect();
const touch = e.changedTouches[0];
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
// dibujar en (x, y)
}¿Por qué mis manejadores de tacto se disparan dos veces en algunos dispositivos Android?
- Algunos navegadores Android disparan eventos de tacto y ratón para compatibilidad
onTouchEndse dispara primero, luego unclicksintético y a vecesmousedown/mouseup- Llama a
e.preventDefault()en tu manejador de tacto (con un listener no pasivo) para suprimir el evento del ratón
Relacionado
- Pointer Events -- Entrada unificada en ratón, tacto y lápiz
- Media & Animation Events -- Manejo del ciclo de vida de medios y animaciones CSS