Skip to content

Commit 8c77817

Browse files
stanis-kgithub-actions[bot]
authored andcommitted
Add multiFingerMoveThreshold to MoveGestureDetector (internal-12291)
`multiFingerMoveThreshold` is needed to have feature parity with Navigation Camera options in NavSDK, which have dedicated single finger and multi finger thresholds. https://mapbox.atlassian.net/browse/MAPSAND-2378 cc @mapbox/maps-android GitOrigin-RevId: 8148c5088fae23ffe848fb4d9729a9aef343c07b
1 parent 397a7ad commit 8c77817

5 files changed

Lines changed: 288 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## Main
44

5+
## 0.10.0 - April 16, 2026
6+
* Add `multiFingerMoveThreshold` to `MoveGestureDetector`
7+
58
## 0.9.2 - October 09, 2025
69
* Fix concurrent modification exception for detectors
710

library/gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
VERSION_NAME=0.10.0-SNAPSHOT
1+
VERSION_NAME=0.11.0-SNAPSHOT
22
POM_NAME=Mapbox Android Gestures Library

library/src/main/java/com/mapbox/android/gestures/MoveGestureDetector.java

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ public class MoveGestureDetector extends ProgressiveGesture<MoveGestureDetector.
4444
@Nullable
4545
private RectF moveThresholdRect;
4646
private float moveThreshold;
47+
@Nullable
48+
private Float multiFingerMoveThreshold;
4749

4850
private final Map<Integer, MoveDistancesObject> moveDistancesObjectMap = new HashMap<>();
4951

@@ -118,6 +120,9 @@ protected boolean analyzeEvent(@NonNull MotionEvent motionEvent) {
118120
motionEvent.getPointerId(motionEvent.getActionIndex()),
119121
new MoveDistancesObject(x, y)
120122
);
123+
// Reset accumulated distances for existing pointers so the new finger
124+
// configuration doesn't immediately exceed the threshold.
125+
resetMoveDistancesExcept(motionEvent.getPointerId(motionEvent.getActionIndex()), motionEvent);
121126
break;
122127

123128
case MotionEvent.ACTION_UP:
@@ -128,6 +133,9 @@ protected boolean analyzeEvent(@NonNull MotionEvent motionEvent) {
128133
resetFocal = true; //recalculating focal point
129134

130135
moveDistancesObjectMap.remove(motionEvent.getPointerId(motionEvent.getActionIndex()));
136+
// Reset accumulated distances for remaining pointers so lifting a finger
137+
// doesn't immediately exceed the single-finger threshold.
138+
resetMoveDistancesExcept(-1, motionEvent);
131139
break;
132140

133141
case MotionEvent.ACTION_CANCEL:
@@ -167,6 +175,26 @@ protected boolean analyzeMovement() {
167175
return false;
168176
}
169177

178+
/**
179+
* Resets the "since start" accumulated distances for all pointers currently in the map,
180+
* except the one with {@code excludedPointerId} (pass -1 to reset all).
181+
* This prevents previously accumulated distances from triggering a false threshold
182+
* crossing after the pointer configuration changes (finger added or removed).
183+
*/
184+
private void resetMoveDistancesExcept(int excludedPointerId, MotionEvent motionEvent) {
185+
for (int i = 0; i < motionEvent.getPointerCount(); i++) {
186+
int pointerId = motionEvent.getPointerId(i);
187+
if (pointerId == excludedPointerId) {
188+
continue;
189+
}
190+
if (moveDistancesObjectMap.containsKey(pointerId)) {
191+
float cx = motionEvent.getX(i);
192+
float cy = motionEvent.getY(i);
193+
moveDistancesObjectMap.put(pointerId, new MoveDistancesObject(cx, cy));
194+
}
195+
}
196+
}
197+
170198
private void updateMoveDistancesObjects() {
171199
for (int pointerId : pointerIdList) {
172200
moveDistancesObjectMap.get(pointerId).addNewPosition(
@@ -177,16 +205,25 @@ private void updateMoveDistancesObjects() {
177205
}
178206

179207
boolean checkAnyMoveAboveThreshold() {
208+
float threshold = getCurrentMoveThreshold();
209+
boolean isInRect = moveThresholdRect != null && moveThresholdRect.contains(getFocalPoint().x, getFocalPoint().y);
180210
for (MoveDistancesObject moveDistancesObject : moveDistancesObjectMap.values()) {
181-
boolean thresholdExceeded = Math.abs(moveDistancesObject.getDistanceXSinceStart()) >= moveThreshold
182-
|| Math.abs(moveDistancesObject.getDistanceYSinceStart()) >= moveThreshold;
183-
184-
boolean isInRect = moveThresholdRect != null && moveThresholdRect.contains(getFocalPoint().x, getFocalPoint().y);
185-
return !isInRect && thresholdExceeded;
211+
boolean thresholdExceeded = Math.abs(moveDistancesObject.getDistanceXSinceStart()) >= threshold
212+
|| Math.abs(moveDistancesObject.getDistanceYSinceStart()) >= threshold;
213+
if (!isInRect && thresholdExceeded) {
214+
return true;
215+
}
186216
}
187217
return false;
188218
}
189219

220+
private float getCurrentMoveThreshold() {
221+
if (getPointersCount() > 1 && multiFingerMoveThreshold != null) {
222+
return multiFingerMoveThreshold;
223+
}
224+
return moveThreshold;
225+
}
226+
190227
@Override
191228
protected boolean canExecute(int invokedGestureType) {
192229
return super.canExecute(invokedGestureType) && checkAnyMoveAboveThreshold();
@@ -262,6 +299,34 @@ public void setMoveThresholdResource(@DimenRes int moveThresholdDimen) {
262299
setMoveThreshold(context.getResources().getDimension(moveThresholdDimen));
263300
}
264301

302+
/**
303+
* Get the delta pixel threshold required to qualify it as a multi-finger move gesture.
304+
* If not explicitly set, this value falls back to {@link #getMoveThreshold()}.
305+
*
306+
* @return multi-finger delta pixel threshold
307+
*/
308+
public float getMultiFingerMoveThreshold() {
309+
return multiFingerMoveThreshold != null ? multiFingerMoveThreshold : moveThreshold;
310+
}
311+
312+
/**
313+
* Set the delta pixel threshold required to qualify it as a multi-finger move gesture.
314+
*
315+
* @param multiFingerMoveThreshold delta threshold
316+
*/
317+
public void setMultiFingerMoveThreshold(float multiFingerMoveThreshold) {
318+
this.multiFingerMoveThreshold = multiFingerMoveThreshold;
319+
}
320+
321+
/**
322+
* Set the delta dp threshold required to qualify it as a multi-finger move gesture.
323+
*
324+
* @param multiFingerMoveThresholdDimen delta threshold
325+
*/
326+
public void setMultiFingerMoveThresholdResource(@DimenRes int multiFingerMoveThresholdDimen) {
327+
setMultiFingerMoveThreshold(context.getResources().getDimension(multiFingerMoveThresholdDimen));
328+
}
329+
265330
/**
266331
* Returns X distance of the focal point in pixels
267332
* calculated during the last {@link OnMoveGestureListener#onMove(MoveGestureDetector, float, float)} call.

library/src/test/java/com/mapbox/android/gestures/MoveGestureDetectorTest.java

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@
55
import org.junit.Test;
66

77
import static org.mockito.Mockito.doReturn;
8+
import static org.mockito.Mockito.never;
89
import static org.mockito.Mockito.spy;
910
import static org.mockito.Mockito.times;
1011
import static org.mockito.Mockito.verify;
12+
import static org.junit.Assert.assertFalse;
13+
import static org.junit.Assert.assertTrue;
1114

1215
public class MoveGestureDetectorTest extends
1316
AbstractGestureDetectorTest<MoveGestureDetector, MoveGestureDetector.OnMoveGestureListener> {
@@ -66,4 +69,158 @@ public void analyzeEventTest() {
6669
gestureDetector, gestureDetector.velocityX, gestureDetector.velocityY
6770
);
6871
}
72+
73+
@Test
74+
public void multiFingerMoveThreshold_fallsBackToMoveThreshold() {
75+
doReturn(true).when(listener).onMoveBegin(gestureDetector);
76+
doReturn(true).when(gestureDetector).checkPressure();
77+
doReturn(false).when(gestureDetector).isSloppyGesture();
78+
gestureDetector.setMoveThreshold(10f);
79+
80+
MotionEvent downEvent = TestUtils.INSTANCE.getMotionEvent(MotionEvent.ACTION_DOWN, 100, 100, null);
81+
gestureDetector.onTouchEvent(downEvent);
82+
MotionEvent pointerDownEvent = TestUtils.INSTANCE.getMotionEvent(
83+
MotionEvent.ACTION_POINTER_DOWN, 110, 100, downEvent);
84+
gestureDetector.onTouchEvent(pointerDownEvent);
85+
MotionEvent moveEvent = TestUtils.INSTANCE.getMotionEvent(MotionEvent.ACTION_MOVE, 122, 100, pointerDownEvent);
86+
gestureDetector.onTouchEvent(moveEvent);
87+
88+
verify(listener, times(1)).onMoveBegin(gestureDetector);
89+
}
90+
91+
@Test
92+
public void multiFingerMoveThreshold_overridesMoveThresholdForMultiFingerMoves() {
93+
doReturn(true).when(listener).onMoveBegin(gestureDetector);
94+
doReturn(true).when(gestureDetector).checkPressure();
95+
doReturn(false).when(gestureDetector).isSloppyGesture();
96+
gestureDetector.setMoveThreshold(10f);
97+
gestureDetector.setMultiFingerMoveThreshold(30f);
98+
99+
MotionEvent downEvent = TestUtils.INSTANCE.getMotionEvent(MotionEvent.ACTION_DOWN, 100, 100, null);
100+
gestureDetector.onTouchEvent(downEvent);
101+
MotionEvent pointerDownEvent = TestUtils.INSTANCE.getMotionEvent(
102+
MotionEvent.ACTION_POINTER_DOWN, 110, 100, downEvent);
103+
gestureDetector.onTouchEvent(pointerDownEvent);
104+
105+
MotionEvent moveBelowMultiThreshold = TestUtils.INSTANCE.getMotionEvent(
106+
MotionEvent.ACTION_MOVE, 125, 100, pointerDownEvent);
107+
gestureDetector.onTouchEvent(moveBelowMultiThreshold);
108+
verify(listener, never()).onMoveBegin(gestureDetector);
109+
110+
MotionEvent moveAboveMultiThreshold = TestUtils.INSTANCE.getMotionEvent(
111+
MotionEvent.ACTION_MOVE, 145, 100, moveBelowMultiThreshold);
112+
gestureDetector.onTouchEvent(moveAboveMultiThreshold);
113+
verify(listener, times(1)).onMoveBegin(gestureDetector);
114+
}
115+
116+
@Test
117+
public void checkAnyMoveAboveThreshold_checksAllPointers() {
118+
doReturn(true).when(gestureDetector).checkPressure();
119+
doReturn(false).when(gestureDetector).isSloppyGesture();
120+
gestureDetector.setMoveThreshold(10f);
121+
122+
MotionEvent downEvent = TestUtils.INSTANCE.getMotionEvent(
123+
MotionEvent.ACTION_DOWN,
124+
new float[]{0f},
125+
new float[]{0f},
126+
0,
127+
null
128+
);
129+
gestureDetector.onTouchEvent(downEvent);
130+
131+
MotionEvent pointerDownEvent = TestUtils.INSTANCE.getMotionEvent(
132+
MotionEvent.ACTION_POINTER_DOWN,
133+
new float[]{0f, 100f},
134+
new float[]{0f, 0f},
135+
1,
136+
downEvent
137+
);
138+
gestureDetector.onTouchEvent(pointerDownEvent);
139+
140+
MotionEvent moveEvent = TestUtils.INSTANCE.getMotionEvent(
141+
MotionEvent.ACTION_MOVE,
142+
new float[]{5f, 120f},
143+
new float[]{0f, 0f},
144+
0,
145+
pointerDownEvent
146+
);
147+
gestureDetector.onTouchEvent(moveEvent);
148+
149+
assertTrue("Any pointer above threshold should start move gesture",
150+
gestureDetector.checkAnyMoveAboveThreshold());
151+
}
152+
153+
/**
154+
* Regression test: moving 400px with 2 fingers should NOT trigger onMoveBegin for single-finger
155+
* after one finger is lifted, even though 400px exceeds the single-finger threshold (100px).
156+
* The distances must be reset when the pointer configuration changes.
157+
*/
158+
@Test
159+
public void noFalseBeginOnPointerUp_distancesResetAfterLiftingFinger() {
160+
doReturn(true).when(listener).onMoveBegin(gestureDetector);
161+
doReturn(true).when(gestureDetector).checkPressure();
162+
doReturn(false).when(gestureDetector).isSloppyGesture();
163+
164+
// single-finger threshold: 100px, multi-finger threshold: 500px
165+
gestureDetector.setMoveThreshold(100f);
166+
gestureDetector.setMultiFingerMoveThreshold(500f);
167+
168+
// Put first finger down
169+
MotionEvent downEvent = TestUtils.INSTANCE.getMotionEvent(MotionEvent.ACTION_DOWN, 100, 100, null);
170+
gestureDetector.onTouchEvent(downEvent);
171+
172+
// Put second finger down
173+
MotionEvent pointerDownEvent = TestUtils.INSTANCE.getMotionEvent(
174+
MotionEvent.ACTION_POINTER_DOWN, 200, 100, downEvent);
175+
gestureDetector.onTouchEvent(pointerDownEvent);
176+
177+
// Move 400px — below multi-finger threshold (500px), so no onMoveBegin yet
178+
MotionEvent moveEvent = TestUtils.INSTANCE.getMotionEvent(MotionEvent.ACTION_MOVE, 500, 100, pointerDownEvent);
179+
gestureDetector.onTouchEvent(moveEvent);
180+
verify(listener, never()).onMoveBegin(gestureDetector);
181+
182+
// Lift one finger — the remaining pointer's accumulated distance must be reset.
183+
// After the pointer-up, checkAnyMoveAboveThreshold() must return false because distances
184+
// were reset, so a subsequent MOVE event does NOT cross the single-finger threshold.
185+
MotionEvent pointerUpEvent = TestUtils.INSTANCE.getMotionEvent(MotionEvent.ACTION_POINTER_UP, 500, 100, moveEvent);
186+
gestureDetector.onTouchEvent(pointerUpEvent);
187+
188+
// checkAnyMoveAboveThreshold() should now return false since distances were reset
189+
assertFalse("Distances should be reset after lifting a finger",
190+
gestureDetector.checkAnyMoveAboveThreshold());
191+
}
192+
193+
/**
194+
* Regression test: moving 400px with 1 finger should NOT trigger onMoveBegin for multi-finger
195+
* right after a second finger is added, even though 400px exceeds the single-finger threshold.
196+
* The distances of existing pointers must be reset when a new pointer is added.
197+
*/
198+
@Test
199+
public void noFalseBeginOnPointerDown_distancesResetAfterAddingFinger() {
200+
doReturn(true).when(listener).onMoveBegin(gestureDetector);
201+
doReturn(true).when(gestureDetector).checkPressure();
202+
doReturn(false).when(gestureDetector).isSloppyGesture();
203+
204+
// single-finger threshold: 500px, multi-finger threshold: 100px
205+
gestureDetector.setMoveThreshold(500f);
206+
gestureDetector.setMultiFingerMoveThreshold(100f);
207+
208+
// Put first finger down and move 400px — below single-finger threshold (500px), no begin yet
209+
MotionEvent downEvent = TestUtils.INSTANCE.getMotionEvent(MotionEvent.ACTION_DOWN, 100, 100, null);
210+
gestureDetector.onTouchEvent(downEvent);
211+
MotionEvent moveEvent = TestUtils.INSTANCE.getMotionEvent(MotionEvent.ACTION_MOVE, 500, 100, downEvent);
212+
gestureDetector.onTouchEvent(moveEvent);
213+
verify(listener, never()).onMoveBegin(gestureDetector);
214+
215+
// Add a second finger — existing pointer's accumulated distance must be reset.
216+
// After the pointer-down, checkAnyMoveAboveThreshold() must return false because distances
217+
// were reset, so a subsequent MOVE event does NOT immediately cross the multi-finger threshold.
218+
MotionEvent pointerDownEvent = TestUtils.INSTANCE.getMotionEvent(
219+
MotionEvent.ACTION_POINTER_DOWN, 500, 100, moveEvent);
220+
gestureDetector.onTouchEvent(pointerDownEvent);
221+
222+
// checkAnyMoveAboveThreshold() should now return false since distances were reset
223+
assertFalse("Distances should be reset after adding a finger",
224+
gestureDetector.checkAnyMoveAboveThreshold());
225+
}
69226
}

library/src/test/java/com/mapbox/android/gestures/TestUtils.kt

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,63 @@ import android.view.MotionEvent
44

55
object TestUtils {
66

7+
fun getMotionEvent(
8+
actionMasked: Int,
9+
pointerXs: FloatArray,
10+
pointerYs: FloatArray,
11+
actionIndex: Int,
12+
previousEvent: MotionEvent? = null
13+
): MotionEvent {
14+
require(pointerXs.size == pointerYs.size) { "Pointer coordinates must have matching sizes" }
15+
require(pointerXs.isNotEmpty()) { "At least one pointer is required" }
16+
require(actionIndex in pointerXs.indices) { "Action index is out of pointer bounds" }
17+
18+
val currentTime = System.currentTimeMillis()
19+
val downTime = previousEvent?.downTime ?: currentTime
20+
val pointerCount = pointerXs.size
21+
22+
val action = when (actionMasked) {
23+
MotionEvent.ACTION_POINTER_DOWN,
24+
MotionEvent.ACTION_POINTER_UP -> actionMasked or (actionIndex shl MotionEvent.ACTION_POINTER_INDEX_SHIFT)
25+
else -> actionMasked
26+
}
27+
28+
val properties = arrayOfNulls<MotionEvent.PointerProperties>(pointerCount)
29+
for (i in 0 until pointerCount) {
30+
val pp = MotionEvent.PointerProperties()
31+
pp.id = i
32+
pp.toolType = MotionEvent.TOOL_TYPE_FINGER
33+
properties[i] = pp
34+
}
35+
36+
val pointerCoords = arrayOfNulls<MotionEvent.PointerCoords>(pointerCount)
37+
for (i in 0 until pointerCount) {
38+
val pc = MotionEvent.PointerCoords()
39+
pc.x = pointerXs[i]
40+
pc.y = pointerYs[i]
41+
pc.pressure = 1f
42+
pc.size = 1f
43+
pointerCoords[i] = pc
44+
}
45+
46+
return MotionEvent.obtain(
47+
downTime,
48+
currentTime,
49+
action,
50+
pointerCount,
51+
properties,
52+
pointerCoords,
53+
0,
54+
0,
55+
1f,
56+
1f,
57+
0,
58+
0,
59+
0,
60+
0
61+
)
62+
}
63+
764
fun getMotionEvent(action: Int, x: Float, y: Float, previousEvent: MotionEvent? = null): MotionEvent {
865
val currentTime = System.currentTimeMillis()
966
val downTime = previousEvent?.downTime ?: System.currentTimeMillis()

0 commit comments

Comments
 (0)