Skip to content

Commit 6876486

Browse files
authored
Merge pull request #7202 from opengisch/kinetic-scroll
Add kinetic inertia for map pan and pinch-zoom gestures
2 parents 3bea34e + 97e764d commit 6876486

File tree

3 files changed

+249
-9
lines changed

3 files changed

+249
-9
lines changed

src/qml/KineticHandler.qml

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import QtQuick
2+
3+
/**
4+
* Provides kinetic (inertia) scrolling for map pan and pinch-zoom gestures.
5+
*
6+
* Qt's pointer handlers only report instantaneous position, not velocity.
7+
* We sample finger positions over a short time window to compute release velocity.
8+
* After the gesture ends motion continues with exponential friction decay (~60fps timer).
9+
*
10+
* \ingroup qml
11+
*/
12+
Item {
13+
id: kineticHandler
14+
15+
required property Item mapCanvas
16+
required property Item mapCanvasWrapper
17+
18+
property var panSamples: []
19+
property var zoomSamples: []
20+
21+
function addPanSample(x, y) {
22+
const now = Date.now();
23+
panSamples.push({
24+
x: x,
25+
y: y,
26+
time: now
27+
});
28+
while (panSamples.length > 1 && now - panSamples[0].time > 100) {
29+
panSamples.shift();
30+
}
31+
}
32+
33+
function resetPanSamples() {
34+
panSamples = [];
35+
}
36+
37+
function addZoomSample(scale) {
38+
const now = Date.now();
39+
zoomSamples.push({
40+
scale: scale,
41+
time: now
42+
});
43+
while (zoomSamples.length > 1 && now - zoomSamples[0].time > 150) {
44+
zoomSamples.shift();
45+
}
46+
}
47+
48+
function resetZoomSamples() {
49+
zoomSamples = [];
50+
}
51+
52+
property real panVx: 0
53+
property real panVy: 0
54+
property real panAccumX: 0
55+
property real panAccumY: 0
56+
property bool panRunning: false
57+
58+
property real zoomVelocity: 1.0
59+
property point zoomCenter
60+
property bool zoomRunning: false
61+
62+
function startPanInertia() {
63+
const now = Date.now();
64+
while (panSamples.length > 1 && now - panSamples[0].time > 100) {
65+
panSamples.shift();
66+
}
67+
68+
if (panSamples.length < 2) {
69+
return;
70+
}
71+
72+
const first = panSamples[0];
73+
const last = panSamples[panSamples.length - 1];
74+
const dt = last.time - first.time;
75+
if (dt <= 0) {
76+
return;
77+
}
78+
let vxMs = (last.x - first.x) / dt;
79+
let vyMs = (last.y - first.y) / dt;
80+
const speed = Math.sqrt(vxMs * vxMs + vyMs * vyMs);
81+
if (speed < 0.15) {
82+
return;
83+
}
84+
if (speed > 5.0) {
85+
const cap = 5.0 / speed;
86+
vxMs *= cap;
87+
vyMs *= cap;
88+
}
89+
panVx = vxMs * 16;
90+
panVy = vyMs * 16;
91+
panAccumX = 0;
92+
panAccumY = 0;
93+
panRunning = true;
94+
activate();
95+
}
96+
97+
function startZoomInertia(center) {
98+
const now = Date.now();
99+
while (zoomSamples.length > 1 && now - zoomSamples[0].time > 150) {
100+
zoomSamples.shift();
101+
}
102+
103+
if (zoomSamples.length < 2) {
104+
return;
105+
}
106+
107+
const first = zoomSamples[0];
108+
const last = zoomSamples[zoomSamples.length - 1];
109+
const dt = last.time - first.time;
110+
if (dt <= 0) {
111+
return;
112+
}
113+
const ratio = last.scale / first.scale;
114+
const framesElapsed = dt / 16;
115+
const perFrameFactor = Math.pow(ratio, 1 / framesElapsed);
116+
if (Math.abs(perFrameFactor - 1.0) < 0.003) {
117+
return;
118+
}
119+
zoomVelocity = perFrameFactor;
120+
zoomCenter = center;
121+
zoomRunning = true;
122+
activate();
123+
}
124+
125+
function activate() {
126+
if (!inertiaTimer.running) {
127+
mapCanvas.freeze('kinetic');
128+
inertiaTimer.start();
129+
}
130+
}
131+
132+
function stopAll() {
133+
panRunning = false;
134+
zoomRunning = false;
135+
if (inertiaTimer.running) {
136+
inertiaTimer.stop();
137+
mapCanvas.unfreeze('kinetic');
138+
}
139+
}
140+
141+
Timer {
142+
id: inertiaTimer
143+
interval: 16
144+
repeat: true
145+
146+
onTriggered: {
147+
if (kineticHandler.panRunning) {
148+
kineticHandler.panAccumX += kineticHandler.panVx;
149+
kineticHandler.panAccumY += kineticHandler.panVy;
150+
const moveX = Math.round(kineticHandler.panAccumX);
151+
const moveY = Math.round(kineticHandler.panAccumY);
152+
if (moveX !== 0 || moveY !== 0) {
153+
const cx = kineticHandler.mapCanvas.width / 2;
154+
const cy = kineticHandler.mapCanvas.height / 2;
155+
kineticHandler.mapCanvasWrapper.pan(Qt.point(cx + moveX, cy + moveY), Qt.point(cx, cy));
156+
kineticHandler.panAccumX -= moveX;
157+
kineticHandler.panAccumY -= moveY;
158+
}
159+
kineticHandler.panVx *= 0.96;
160+
kineticHandler.panVy *= 0.96;
161+
if (kineticHandler.panVx * kineticHandler.panVx + kineticHandler.panVy * kineticHandler.panVy < 0.25) {
162+
kineticHandler.panRunning = false;
163+
}
164+
}
165+
166+
if (kineticHandler.zoomRunning) {
167+
kineticHandler.mapCanvasWrapper.zoomByFactor(kineticHandler.zoomCenter, 1.0 / kineticHandler.zoomVelocity);
168+
kineticHandler.zoomVelocity = 1.0 + (kineticHandler.zoomVelocity - 1.0) * 0.92;
169+
if (Math.abs(kineticHandler.zoomVelocity - 1.0) < 0.001) {
170+
kineticHandler.zoomRunning = false;
171+
}
172+
}
173+
174+
if (!kineticHandler.panRunning && !kineticHandler.zoomRunning) {
175+
inertiaTimer.stop();
176+
kineticHandler.mapCanvas.unfreeze('kinetic');
177+
}
178+
}
179+
}
180+
}

src/qml/MapCanvas.qml

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ Item {
133133
jumpDetails.toRotation = !isNaN(rotation) ? rotation : -1;
134134
jumpDetails.position = 0.0;
135135
jumpDetails.handleMargins = handleMargins;
136+
kineticHandler.stopAll();
136137
freeze('jumping');
137138
jumpDetails.enabled = true;
138139
jumpDetails.position = 1.0;
@@ -220,6 +221,12 @@ Item {
220221
}
221222
}
222223

224+
KineticHandler {
225+
id: kineticHandler
226+
mapCanvas: mapArea
227+
mapCanvasWrapper: mapCanvasWrapper
228+
}
229+
223230
MapCanvasMap {
224231
id: mapCanvasWrapper
225232

@@ -279,6 +286,9 @@ Item {
279286
}
280287

281288
onPressedChanged: {
289+
if (pressed) {
290+
kineticHandler.stopAll();
291+
}
282292
if (longPressActive)
283293
mapArea.longPressReleased("stylus");
284294
longPressActive = false;
@@ -295,27 +305,38 @@ Item {
295305
dragThreshold: 5
296306

297307
property var oldPos
298-
property real oldTranslationY
308+
property real oldTranslationY: 0
309+
property real activeScale: 1.0
299310

300311
property bool isZooming: false
301312
property bool isPanning: false
302313
property point zoomCenter
303314

304315
onActiveChanged: {
305316
if (active) {
317+
kineticHandler.stopAll();
306318
if (mainTapHandler.doublePressed) {
307319
oldTranslationY = 0;
320+
activeScale = 1.0;
308321
zoomCenter = centroid.position;
309322
isZooming = true;
323+
kineticHandler.resetZoomSamples();
324+
kineticHandler.addZoomSample(1.0);
310325
freeze('zoom');
311326
} else {
312327
oldPos = centroid.position;
313328
isPanning = true;
329+
kineticHandler.resetPanSamples();
330+
kineticHandler.addPanSample(centroid.position.x, centroid.position.y);
314331
freeze('pan');
315332
}
316333
} else {
317-
if (isZooming || isPanning) {
318-
unfreeze(isZooming ? 'zoom' : 'pan');
334+
if (isPanning) {
335+
kineticHandler.startPanInertia();
336+
unfreeze('pan');
337+
} else if (isZooming) {
338+
kineticHandler.startZoomInertia(centroid.position);
339+
unfreeze('zoom');
319340
}
320341
isZooming = false;
321342
isPanning = false;
@@ -325,9 +346,12 @@ Item {
325346
onCentroidChanged: {
326347
if (active) {
327348
if (isZooming) {
349+
activeScale += (1 - Math.pow(0.8, (translation.y - oldTranslationY) / 60));
350+
kineticHandler.addZoomSample(activeScale);
328351
mapCanvasWrapper.zoomByFactor(zoomCenter, Math.pow(0.8, (translation.y - oldTranslationY) / 60));
329352
oldTranslationY = translation.y;
330353
} else if (isPanning) {
354+
kineticHandler.addPanSample(centroid.position.x, centroid.position.y);
331355
mapCanvasWrapper.pan(centroid.position, oldPos);
332356
oldPos = centroid.position;
333357
}
@@ -365,6 +389,9 @@ Item {
365389

366390
onPressedChanged: {
367391
if (pressed) {
392+
if (!pinchHandler.pinchReleasing) {
393+
kineticHandler.stopAll();
394+
}
368395
if (point.pressedButtons !== Qt.RightButton) {
369396
if (timer.running) {
370397
timer.stop();
@@ -405,27 +432,40 @@ Item {
405432
dragThreshold: 5
406433

407434
property var oldPos
408-
property real oldTranslationY
435+
property real oldTranslationY: 0.0
436+
property real activeScale: 1.0
409437

410438
property bool isZooming: false
411439
property bool isPanning: false
412440
property point zoomCenter
413441

414442
onActiveChanged: {
415443
if (active) {
444+
if (!pinchHandler.pinchReleasing) {
445+
kineticHandler.stopAll();
446+
}
416447
if (mainTapHandler.doublePressed) {
417448
oldTranslationY = 0;
449+
activeScale = 1.0;
418450
zoomCenter = centroid.position;
419451
isZooming = true;
452+
kineticHandler.resetZoomSamples();
453+
kineticHandler.addZoomSample(1.0);
420454
freeze('zoom');
421455
} else {
422456
oldPos = centroid.position;
423457
isPanning = true;
458+
kineticHandler.resetPanSamples();
459+
kineticHandler.addPanSample(centroid.position.x, centroid.position.y);
424460
freeze('pan');
425461
}
426462
} else {
427-
if (isZooming || isPanning) {
428-
unfreeze(isZooming ? 'zoom' : 'pan');
463+
if (isPanning) {
464+
kineticHandler.startPanInertia();
465+
unfreeze('pan');
466+
} else if (isZooming) {
467+
kineticHandler.startZoomInertia(centroid.position);
468+
unfreeze('zoom');
429469
}
430470
isZooming = false;
431471
isPanning = false;
@@ -435,9 +475,12 @@ Item {
435475
onCentroidChanged: {
436476
if (active) {
437477
if (isZooming) {
478+
activeScale += (1 - Math.pow(0.8, (translation.y - oldTranslationY) / 60));
479+
kineticHandler.addZoomSample(activeScale);
438480
mapCanvasWrapper.zoomByFactor(zoomCenter, Math.pow(0.8, (translation.y - oldTranslationY) / 60));
439481
oldTranslationY = translation.y;
440482
} else if (isPanning) {
483+
kineticHandler.addPanSample(centroid.position.x, centroid.position.y);
441484
mapCanvasWrapper.pan(centroid.position, oldPos);
442485
oldPos = centroid.position;
443486
}
@@ -568,23 +611,36 @@ Item {
568611

569612
property bool rotationActive: false
570613
property bool rotationTresholdReached: false
614+
property bool pinchReleasing: false
571615

572616
onActiveChanged: {
573617
if (active) {
618+
kineticHandler.stopAll();
574619
freeze('pinch');
575620
oldScale = 1.0;
576621
oldRotation = 0.0;
577622
rotationTresholdReached = false;
578623
oldPos = centroid.position;
624+
kineticHandler.resetPanSamples();
625+
kineticHandler.resetZoomSamples();
626+
kineticHandler.addPanSample(centroid.position.x, centroid.position.y);
627+
kineticHandler.addZoomSample(1.0);
579628
} else {
629+
pinchReleasing = true;
630+
kineticHandler.startPanInertia();
631+
kineticHandler.startZoomInertia(centroid.position);
580632
unfreeze('pinch');
633+
Qt.callLater(function () {
634+
pinchReleasing = false;
635+
});
581636
}
582637
}
583638

584639
onCentroidChanged: {
585640
var oldPos1 = oldPos;
586641
oldPos = centroid.position;
587642
if (active) {
643+
kineticHandler.addPanSample(centroid.position.x, centroid.position.y);
588644
mapCanvasWrapper.pan(centroid.position, oldPos1);
589645
}
590646
}
@@ -602,9 +658,12 @@ Item {
602658
}
603659

604660
onActiveScaleChanged: {
605-
mapCanvasWrapper.zoomByFactor(pinchHandler.centroid.position, oldScale / pinchHandler.activeScale);
606-
mapCanvasWrapper.pan(pinchHandler.centroid.position, oldPos);
607-
oldScale = pinchHandler.activeScale;
661+
if (active) {
662+
kineticHandler.addZoomSample(pinchHandler.activeScale);
663+
mapCanvasWrapper.zoomByFactor(pinchHandler.centroid.position, oldScale / pinchHandler.activeScale);
664+
mapCanvasWrapper.pan(pinchHandler.centroid.position, oldPos);
665+
oldScale = pinchHandler.activeScale;
666+
}
608667
}
609668
}
610669

src/qml/qml.qrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
<file>GridRenderer.qml</file>
2222
<file>LinePolygon.qml</file>
2323
<file>LocationMarker.qml</file>
24+
<file>KineticHandler.qml</file>
2425
<file>MapCanvas.qml</file>
2526
<file>MapCanvasPointHandler.qml</file>
2627
<file>MeasuringTool.qml</file>

0 commit comments

Comments
 (0)