Skip to content

Commit 8bbc597

Browse files
j-piaseckitomekzaw
andauthored
Change how velocity is calculated on the new web implementation (#2443)
## Description Changes how pointer velocity is calculated on the web. Previously, it was using only the last two events which wasn't the best solution as the speed of the pointer just before it's released may not represent the speed of the swipe. This changes it to be calculated using the same algorithm as Flutter. ## Test plan Use the `Velocity test` example added to the app --------- Co-authored-by: Tomek Zawadzki <tomekzawadzki98@gmail.com>
1 parent 74b715e commit 8bbc597

File tree

7 files changed

+405
-12
lines changed

7 files changed

+405
-12
lines changed

example/src/App.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ import BottomSheetNewApi from './new_api/bottom_sheet';
4242
import ChatHeadsNewApi from './new_api/chat_heads';
4343
import DragNDrop from './new_api/drag_n_drop';
4444
import BetterHorizontalDrawer from './new_api/betterHorizontalDrawer';
45-
import ManualGestures from './new_api/manualGestures/index';
45+
import ManualGestures from './new_api/manualGestures';
46+
import VelocityTest from './new_api/velocityTest';
4647

4748
interface Example {
4849
name: string;
@@ -116,6 +117,7 @@ const EXAMPLES: ExamplesSection[] = [
116117
component: ReanimatedSimple,
117118
},
118119
{ name: 'Camera', component: Camera },
120+
{ name: 'Velocity test', component: VelocityTest },
119121
{ name: 'Transformations', component: Transformations },
120122
{ name: 'Overlap parents', component: OverlapParents },
121123
{ name: 'Overlap siblings', component: OverlapSiblings },
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { StyleSheet, View } from 'react-native';
2+
import Animated, {
3+
measure,
4+
useAnimatedRef,
5+
useAnimatedStyle,
6+
useSharedValue,
7+
withDecay,
8+
} from 'react-native-reanimated';
9+
10+
import React from 'react';
11+
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
12+
13+
const BOX_SIZE = 120;
14+
15+
export default function App() {
16+
const aref = useAnimatedRef<View>();
17+
const offsetX = useSharedValue(0);
18+
const offsetY = useSharedValue(0);
19+
20+
const pan = Gesture.Pan()
21+
.onChange((event) => {
22+
offsetX.value += event.changeX;
23+
offsetY.value += event.changeY;
24+
})
25+
.onFinalize((event) => {
26+
// If we can't get view size, just ignore it. Half of the view will be
27+
// able to go outside the screen
28+
const size = measure(aref) ?? { width: 0, height: 0 };
29+
30+
offsetX.value = withDecay({
31+
velocity: event.velocityX,
32+
clamp: [-size.width / 2 + BOX_SIZE / 2, size.width / 2 - BOX_SIZE / 2],
33+
});
34+
35+
offsetY.value = withDecay({
36+
velocity: event.velocityY,
37+
clamp: [
38+
-size.height / 2 + BOX_SIZE / 2,
39+
size.height / 2 - BOX_SIZE / 2,
40+
],
41+
});
42+
});
43+
44+
const animatedStyles = useAnimatedStyle(() => ({
45+
transform: [{ translateX: offsetX.value }, { translateY: offsetY.value }],
46+
}));
47+
48+
return (
49+
<View style={styles.container} ref={aref}>
50+
<GestureDetector gesture={pan}>
51+
<Animated.View style={[styles.box, animatedStyles]} />
52+
</GestureDetector>
53+
</View>
54+
);
55+
}
56+
57+
const styles = StyleSheet.create({
58+
container: {
59+
flex: 1,
60+
alignItems: 'center',
61+
justifyContent: 'center',
62+
height: '100%',
63+
},
64+
box: {
65+
width: BOX_SIZE,
66+
height: BOX_SIZE,
67+
backgroundColor: '#001A72',
68+
borderRadius: 20,
69+
cursor: 'grab',
70+
},
71+
});

src/web/handlers/PanGestureHandler.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { PixelRatio } from 'react-native';
21
import { State } from '../../State';
32
import { DEFAULT_TOUCH_SLOP } from '../constants';
43
import { AdaptedEvent, Config } from '../interfaces';
@@ -188,7 +187,6 @@ export default class PanGestureHandler extends GestureHandler {
188187

189188
protected transformNativeEvent() {
190189
const rect: DOMRect = this.view.getBoundingClientRect();
191-
const ratio = PixelRatio.get();
192190

193191
const translationX: number = this.getTranslationX();
194192
const translationY: number = this.getTranslationY();
@@ -198,8 +196,8 @@ export default class PanGestureHandler extends GestureHandler {
198196
translationY: isNaN(translationY) ? 0 : translationY,
199197
absoluteX: this.tracker.getLastAvgX(),
200198
absoluteY: this.tracker.getLastAvgY(),
201-
velocityX: this.velocityX * ratio * 10,
202-
velocityY: this.velocityY * ratio * 10,
199+
velocityX: this.velocityX,
200+
velocityY: this.velocityY,
203201
x: this.tracker.getLastAvgX() - rect.left,
204202
y: this.tracker.getLastAvgY() - rect.top,
205203
};

src/web/tools/CircularBuffer.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
export default class CircularBuffer<T> {
2+
private bufferSize: number;
3+
private buffer: T[];
4+
private index: number;
5+
private actualSize: number;
6+
7+
constructor(size: number) {
8+
this.bufferSize = size;
9+
this.buffer = new Array<T>(size);
10+
this.index = 0;
11+
this.actualSize = 0;
12+
}
13+
14+
public get size(): number {
15+
return this.actualSize;
16+
}
17+
18+
public push(element: T): void {
19+
this.buffer[this.index] = element;
20+
this.index = (this.index + 1) % this.bufferSize;
21+
this.actualSize = Math.min(this.actualSize + 1, this.bufferSize);
22+
}
23+
24+
public get(at: number): T {
25+
if (this.actualSize === this.bufferSize) {
26+
let index = (this.index + at) % this.bufferSize;
27+
if (index < 0) {
28+
index += this.bufferSize;
29+
}
30+
31+
return this.buffer[index];
32+
} else {
33+
return this.buffer[at];
34+
}
35+
}
36+
37+
public clear(): void {
38+
this.buffer = new Array<T>(this.bufferSize);
39+
this.index = 0;
40+
this.actualSize = 0;
41+
}
42+
}

src/web/tools/LeastSquareSolver.ts

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
// Implementation taken from Flutter's LeastSquareSolver
2+
// https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/gestures/lsq_solver.dart
3+
4+
class Vector {
5+
private offset: number;
6+
private length: number;
7+
private elements: number[];
8+
9+
constructor(length: number) {
10+
this.offset = 0;
11+
this.length = length;
12+
this.elements = new Array<number>(length);
13+
}
14+
15+
public static fromVOL(
16+
values: number[],
17+
offset: number,
18+
length: number
19+
): Vector {
20+
const result = new Vector(0);
21+
22+
result.offset = offset;
23+
result.length = length;
24+
result.elements = values;
25+
26+
return result;
27+
}
28+
29+
public get(index: number): number {
30+
return this.elements[this.offset + index];
31+
}
32+
33+
public set(index: number, value: number): void {
34+
this.elements[this.offset + index] = value;
35+
}
36+
37+
public dot(other: Vector): number {
38+
let result = 0;
39+
for (let i = 0; i < this.length; i++) {
40+
result += this.get(i) * other.get(i);
41+
}
42+
return result;
43+
}
44+
45+
public norm() {
46+
return Math.sqrt(this.dot(this));
47+
}
48+
}
49+
50+
class Matrix {
51+
private columns: number;
52+
private elements: number[];
53+
54+
constructor(rows: number, columns: number) {
55+
this.columns = columns;
56+
this.elements = new Array<number>(rows * columns);
57+
}
58+
59+
public get(row: number, column: number): number {
60+
return this.elements[row * this.columns + column];
61+
}
62+
63+
public set(row: number, column: number, value: number): void {
64+
this.elements[row * this.columns + column] = value;
65+
}
66+
67+
public getRow(row: number): Vector {
68+
return Vector.fromVOL(this.elements, row * this.columns, this.columns);
69+
}
70+
}
71+
72+
/// An nth degree polynomial fit to a dataset.
73+
class PolynomialFit {
74+
/// The polynomial coefficients of the fit.
75+
///
76+
/// For each `i`, the element `coefficients[i]` is the coefficient of
77+
/// the `i`-th power of the variable.
78+
public coefficients: number[];
79+
80+
/// Creates a polynomial fit of the given degree.
81+
///
82+
/// There are n + 1 coefficients in a fit of degree n.
83+
constructor(degree: number) {
84+
this.coefficients = new Array<number>(degree + 1);
85+
}
86+
}
87+
88+
const precisionErrorTolerance = 1e-10;
89+
90+
/// Uses the least-squares algorithm to fit a polynomial to a set of data.
91+
export default class LeastSquareSolver {
92+
/// The x-coordinates of each data point.
93+
private x: number[];
94+
/// The y-coordinates of each data point.
95+
private y: number[];
96+
/// The weight to use for each data point.
97+
private w: number[];
98+
99+
/// Creates a least-squares solver.
100+
///
101+
/// The [x], [y], and [w] arguments must not be null.
102+
constructor(x: number[], y: number[], w: number[]) {
103+
this.x = x;
104+
this.y = y;
105+
this.w = w;
106+
}
107+
108+
/// Fits a polynomial of the given degree to the data points.
109+
///
110+
/// When there is not enough data to fit a curve null is returned.
111+
public solve(degree: number): PolynomialFit | null {
112+
if (degree > this.x.length) {
113+
// Not enough data to fit a curve.
114+
return null;
115+
}
116+
117+
const result = new PolynomialFit(degree);
118+
119+
// Shorthands for the purpose of notation equivalence to original C++ code.
120+
const m = this.x.length;
121+
const n = degree + 1;
122+
123+
// Expand the X vector to a matrix A, pre-multiplied by the weights.
124+
const a = new Matrix(n, m);
125+
for (let h = 0; h < m; h++) {
126+
a.set(0, h, this.w[h]);
127+
128+
for (let i = 1; i < n; i++) {
129+
a.set(i, h, a.get(i - 1, h) * this.x[h]);
130+
}
131+
}
132+
133+
// Apply the Gram-Schmidt process to A to obtain its QR decomposition.
134+
135+
// Orthonormal basis, column-major ordVectorer.
136+
const q = new Matrix(n, m);
137+
// Upper triangular matrix, row-major order.
138+
const r = new Matrix(n, m);
139+
140+
for (let j = 0; j < n; j += 1) {
141+
for (let h = 0; h < m; h += 1) {
142+
q.set(j, h, a.get(j, h));
143+
}
144+
for (let i = 0; i < j; i += 1) {
145+
const dot = q.getRow(j).dot(q.getRow(i));
146+
for (let h = 0; h < m; h += 1) {
147+
q.set(j, h, q.get(j, h) - dot * q.get(i, h));
148+
}
149+
}
150+
151+
const norm = q.getRow(j).norm();
152+
if (norm < precisionErrorTolerance) {
153+
// Vectors are linearly dependent or zero so no solution.
154+
return null;
155+
}
156+
157+
const inverseNorm = 1.0 / norm;
158+
for (let h = 0; h < m; h += 1) {
159+
q.set(j, h, q.get(j, h) * inverseNorm);
160+
}
161+
for (let i = 0; i < n; i += 1) {
162+
r.set(j, i, i < j ? 0.0 : q.getRow(j).dot(a.getRow(i)));
163+
}
164+
}
165+
166+
// Solve R B = Qt W Y to find B. This is easy because R is upper triangular.
167+
// We just work from bottom-right to top-left calculating B's coefficients.
168+
const wy = new Vector(m);
169+
for (let h = 0; h < m; h += 1) {
170+
wy.set(h, this.y[h] * this.w[h]);
171+
}
172+
for (let i = n - 1; i >= 0; i -= 1) {
173+
result.coefficients[i] = q.getRow(i).dot(wy);
174+
for (let j = n - 1; j > i; j -= 1) {
175+
result.coefficients[i] -= r.get(i, j) * result.coefficients[j];
176+
}
177+
result.coefficients[i] /= r.get(i, i);
178+
}
179+
180+
return result;
181+
}
182+
}

src/web/tools/PointerTracker.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { AdaptedEvent } from '../interfaces';
2+
import VelocityTracker from './VelocityTracker';
23

34
export interface TrackerElement {
45
lastX: number;
@@ -10,11 +11,10 @@ export interface TrackerElement {
1011
velocityY: number;
1112
}
1213

13-
// Used to scale velocity so that it is similar to velocity in Android/iOS
14-
const VELOCITY_FACTOR = 0.2;
1514
const MAX_POINTERS = 20;
1615

1716
export default class PointerTracker {
17+
private velocityTracker = new VelocityTracker();
1818
private trackedPointers: Map<number, TrackerElement> = new Map<
1919
number,
2020
TrackerElement
@@ -74,12 +74,11 @@ export default class PointerTracker {
7474

7575
this.lastMovedPointerId = event.pointerId;
7676

77-
const dx = event.x - element.lastX;
78-
const dy = event.y - element.lastY;
79-
const dt = event.time - element.timeStamp;
77+
this.velocityTracker.add(event);
78+
const [velocityX, velocityY] = this.velocityTracker.getVelocity();
8079

81-
element.velocityX = (dx / dt) * 1000 * VELOCITY_FACTOR;
82-
element.velocityY = (dy / dt) * 1000 * VELOCITY_FACTOR;
80+
element.velocityX = velocityX;
81+
element.velocityY = velocityY;
8382

8483
element.lastX = event.x;
8584
element.lastY = event.y;
@@ -223,6 +222,7 @@ export default class PointerTracker {
223222
}
224223

225224
public resetTracker(): void {
225+
this.velocityTracker.reset();
226226
this.trackedPointers.clear();
227227
this.lastMovedPointerId = NaN;
228228

0 commit comments

Comments
 (0)