Commit 5fd06ba
authored
[Web] Fix incorrectly calculated
## Description
Time delta in `Rotation` gesture was incorrectly calculated 😢
## Test plan
<details>
<summary>Well, I don't think it is necessary, but repro below:</summary>
```tsx
// Repro for web Rotation velocity bug: RotationGestureDetector.timeDelta
// returned `currentTime + previousTime` (a sum of two absolute DOM
// timestamps) instead of their difference. RotationGestureHandler divides
// the rotation delta by it, so the reported velocity was orders of
// magnitude too small and kept shrinking the longer the page stayed open.
//
// The screen logs the velocity reported on rotation events next to a
// reference velocity computed on the JS side from rotation deltas and
// wall-clock time (both in rad/ms). Rotate with two fingers on a touch
// device, or press "Simulate rotation" on desktop web — it dispatches a
// synthetic two-finger 90° twist over ~0.7 s.
//
// Broken: ratio ~0.0001 or less (and drifting down). Fixed: ratio ~1.
import React, { useRef, useState } from 'react';
import { Platform, StyleSheet, Text, View } from 'react-native';
import {
GestureDetector,
GestureHandlerRootView,
useRotationGesture,
} from 'react-native-gesture-handler';
export default function EmptyExample() {
const [log, setLog] = useState<string[]>([]);
const reference = useRef<{ rotation: number; time: number } | null>(null);
const samples = useRef<{ reported: number; expected: number }[]>([]);
// Samples are collected in refs and rendered once when the gesture ends —
// a setState per update would re-render mid-gesture and reconfigure the
// detector, cutting the rotation short.
const rotation = useRotationGesture({
runOnJS: true,
onActivate: (e) => {
reference.current = { rotation: e.rotation, time: performance.now() };
samples.current = [];
},
onUpdate: (e) => {
const now = performance.now();
const previous = reference.current;
if (!previous || now - previous.time < 30) {
return;
}
const expected = (e.rotation - previous.rotation) / (now - previous.time);
reference.current = { rotation: e.rotation, time: now };
if (Math.abs(expected) < 1e-6) {
return;
}
samples.current.push({ reported: e.velocity, expected });
},
onFinalize: () => {
const collected = samples.current;
if (collected.length === 0) {
return;
}
const mean = (values: number[]) =>
values.reduce((sum, value) => sum + value, 0) / values.length;
const ratio =
mean(collected.map((s) => s.reported)) /
mean(collected.map((s) => s.expected));
setLog([
'--- rotation finished ---',
...collected
.slice(-8)
.map(
(s) =>
`velocity=${s.reported.toExponential(3)} ` +
`expected≈${s.expected.toExponential(3)} ` +
`ratio=${(s.reported / s.expected).toFixed(4)}`
),
`=== mean reported/expected ratio: ${ratio.toFixed(4)} ===`,
]);
},
});
const simulateRotation = async () => {
// Synthetic pointers are not "active pointers", so the browser throws
// NotFoundError when the library calls setPointerCapture on them.
// Swallow that in the demo — capture is irrelevant here.
const globals = window as unknown as { __captureShimmed?: boolean };
if (!globals.__captureShimmed) {
globals.__captureShimmed = true;
for (const method of [
'setPointerCapture',
'releasePointerCapture',
] as const) {
const original = Element.prototype[method];
Element.prototype[method] = function (pointerId: number) {
try {
original.call(this, pointerId);
} catch {
// ignore NotFoundError for synthetic pointers
}
};
}
}
// GestureDetector does not forward the child ref on web — locate the
// box through its testID instead.
const node = document.querySelector<HTMLElement>(
'[data-testid="rotationBox"]'
);
if (!node) {
return;
}
const rect = node.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const radius = Math.min(rect.width, rect.height) / 3;
const fire = (
type: string,
pointerId: number,
isPrimary: boolean,
angle: number,
side: 1 | -1
) =>
node.dispatchEvent(
new PointerEvent(type, {
pointerId,
pointerType: 'touch',
isPrimary,
clientX: centerX + side * radius * Math.cos(angle),
clientY: centerY + side * radius * Math.sin(angle),
buttons: 1,
bubbles: true,
cancelable: true,
})
);
const nextFrame = () =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
// Only one finger orbits while the other stays planted: one pointermove
// per frame keeps consecutive event timestamps a full frame apart, like
// real hardware. Moving both fingers would put two moves in the same
// frame ~0.1 ms apart and make per-update velocity noisy.
const totalAngle = Math.PI / 2;
const steps = 40;
fire('pointerdown', 101, true, 0, 1);
fire('pointerdown', 102, false, 0, -1);
for (let i = 1; i <= steps; i++) {
await nextFrame();
fire('pointermove', 101, true, (totalAngle * i) / steps, 1);
}
fire('pointerup', 101, true, totalAngle, 1);
fire('pointerup', 102, false, 0, -1);
};
return (
<GestureHandlerRootView style={styles.container}>
<Text style={styles.title}>Rotation velocity repro (web)</Text>
<Text style={styles.hint}>
90° over ~0.7 s ≈ 2.4e-3 rad/ms.{'\n'}
Broken: velocity ~1e-7, ratio ≈ 0. Fixed: ratio ≈ 1.
</Text>
<GestureDetector gesture={rotation}>
<View style={styles.box} testID="rotationBox">
<Text style={styles.boxLabel}>rotate here</Text>
</View>
</GestureDetector>
{Platform.OS === 'web' && (
<Text
style={styles.button}
onPress={() => {
void simulateRotation();
}}>
Simulate rotation
</Text>
)}
<View style={styles.log}>
{log.map((entry, i) => (
<Text key={i} style={styles.logLine}>
{entry}
</Text>
))}
</View>
</GestureHandlerRootView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
paddingTop: 60,
},
title: {
fontSize: 16,
fontWeight: 'bold',
},
hint: {
textAlign: 'center',
marginVertical: 12,
color: '#666',
},
box: {
width: 260,
height: 260,
backgroundColor: 'mediumpurple',
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
},
boxLabel: {
color: 'white',
fontSize: 18,
},
button: {
marginTop: 16,
paddingVertical: 8,
paddingHorizontal: 20,
backgroundColor: '#4630eb',
color: 'white',
borderRadius: 8,
overflow: 'hidden',
fontSize: 15,
},
log: {
marginTop: 20,
minHeight: 220,
alignSelf: 'stretch',
paddingHorizontal: 24,
},
logLine: {
fontFamily: 'monospace',
fontSize: 12,
},
});
```
</details>timeDelta (#4329)1 parent f922b3b commit 5fd06ba
1 file changed
Lines changed: 1 addition & 1 deletion
Lines changed: 1 addition & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
178 | 178 | | |
179 | 179 | | |
180 | 180 | | |
181 | | - | |
| 181 | + | |
182 | 182 | | |
183 | 183 | | |
0 commit comments