Skip to content

Commit

Permalink
Make FlingHandler use velocity as the activation metric. (#2796)
Browse files Browse the repository at this point in the history
## Description
Fixed fling component handler to activate based on fling velocity
instead of fling distance.
The fling also no longer has to be performed from start to finish
entirely within the component's bounds.
This feature was tested on both emulated and live android phones.
## Test plan
- clone this branch
- run `yarn`
- go to `example`
- copy fling example from the docs into the
`example/src/empty/EmptyExample.tsx` file

### Web
- run `yarn start`

### Android
- go to `example/android`
- run `yarn start`

### IOS
- IOS remains unchanged, it already implements this mechanism in a
different way.

## Reference

https://github.com/software-mansion/react-native-gesture-handler/assets/74246391/6868ff68-4e6e-41fb-93a7-0cd35e85cd6e

---------

Co-authored-by: Michał Bert <[email protected]>
Co-authored-by: Jakub Piasecki <[email protected]>
  • Loading branch information
3 people authored Mar 15, 2024
1 parent 08c921a commit 3904238
Show file tree
Hide file tree
Showing 5 changed files with 195 additions and 55 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,19 @@ package com.swmansion.gesturehandler.core
import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import android.view.VelocityTracker

class FlingGestureHandler : GestureHandler<FlingGestureHandler>() {
var numberOfPointersRequired = DEFAULT_NUMBER_OF_TOUCHES_REQUIRED
var direction = DEFAULT_DIRECTION

private val maxDurationMs = DEFAULT_MAX_DURATION_MS
private val minAcceptableDelta = DEFAULT_MIN_ACCEPTABLE_DELTA
private var startX = 0f
private var startY = 0f
private val minVelocity = DEFAULT_MIN_VELOCITY
private val minDirectionalAlignment = DEFAULT_MIN_DIRECTION_ALIGNMENT
private var handler: Handler? = null
private var maxNumberOfPointersSimultaneously = 0
private val failDelayed = Runnable { fail() }
private var velocityTracker: VelocityTracker? = null

override fun resetConfig() {
super.resetConfig()
Expand All @@ -23,8 +24,7 @@ class FlingGestureHandler : GestureHandler<FlingGestureHandler>() {
}

private fun startFling(event: MotionEvent) {
startX = event.rawX
startY = event.rawY
velocityTracker = VelocityTracker.obtain()
begin()
maxNumberOfPointersSimultaneously = 1
if (handler == null) {
Expand All @@ -35,26 +35,40 @@ class FlingGestureHandler : GestureHandler<FlingGestureHandler>() {
handler!!.postDelayed(failDelayed, maxDurationMs)
}

private fun tryEndFling(event: MotionEvent) = if (
maxNumberOfPointersSimultaneously == numberOfPointersRequired &&
(
direction and DIRECTION_RIGHT != 0 &&
event.rawX - startX > minAcceptableDelta ||
direction and DIRECTION_LEFT != 0 &&
startX - event.rawX > minAcceptableDelta ||
direction and DIRECTION_UP != 0 &&
startY - event.rawY > minAcceptableDelta ||
direction and DIRECTION_DOWN != 0 &&
event.rawY - startY > minAcceptableDelta
private fun tryEndFling(event: MotionEvent): Boolean {
addVelocityMovement(velocityTracker, event)

val velocityVector = Vector.fromVelocity(velocityTracker!!)

fun getVelocityAlignment(
direction: Int,
): Boolean = (
this.direction and direction != 0 &&
velocityVector.isSimilar(Vector.fromDirection(direction), minDirectionalAlignment)
)
) {
handler!!.removeCallbacksAndMessages(null)
activate()
true
} else {
false
}

val alignmentList = arrayOf(
DIRECTION_LEFT,
DIRECTION_RIGHT,
DIRECTION_UP,
DIRECTION_DOWN,
).map { direction -> getVelocityAlignment(direction) }

val isAligned = alignmentList.any { it }
val isFast = velocityVector.magnitude > this.minVelocity

return if (
maxNumberOfPointersSimultaneously == numberOfPointersRequired &&
isAligned &&
isFast
) {
handler!!.removeCallbacksAndMessages(null)
activate()
true
} else {
false
}
}
override fun activate(force: Boolean) {
super.activate(force)
end()
Expand Down Expand Up @@ -92,12 +106,23 @@ class FlingGestureHandler : GestureHandler<FlingGestureHandler>() {
}

override fun onReset() {
velocityTracker?.recycle()
velocityTracker = null
handler?.removeCallbacksAndMessages(null)
}

private fun addVelocityMovement(tracker: VelocityTracker?, event: MotionEvent) {
val offsetX = event.rawX - event.x
val offsetY = event.rawY - event.y
event.offsetLocation(offsetX, offsetY)
tracker!!.addMovement(event)
event.offsetLocation(-offsetX, -offsetY)
}

companion object {
private const val DEFAULT_MAX_DURATION_MS: Long = 800
private const val DEFAULT_MIN_ACCEPTABLE_DELTA: Long = 160
private const val DEFAULT_MIN_VELOCITY: Long = 2000
private const val DEFAULT_MIN_DIRECTION_ALIGNMENT: Double = 0.75
private const val DEFAULT_DIRECTION = DIRECTION_RIGHT
private const val DEFAULT_NUMBER_OF_TOUCHES_REQUIRED = 1
}
Expand Down
56 changes: 56 additions & 0 deletions android/src/main/java/com/swmansion/gesturehandler/core/Vector.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.swmansion.gesturehandler.core

import android.view.VelocityTracker
import com.swmansion.gesturehandler.core.GestureHandler.Companion.DIRECTION_DOWN
import com.swmansion.gesturehandler.core.GestureHandler.Companion.DIRECTION_LEFT
import com.swmansion.gesturehandler.core.GestureHandler.Companion.DIRECTION_RIGHT
import com.swmansion.gesturehandler.core.GestureHandler.Companion.DIRECTION_UP
import kotlin.math.hypot

class Vector(val x: Double, val y: Double) {
private val unitX: Double
private val unitY: Double
val magnitude = hypot(x, y)

init {
val isMagnitudeSufficient = magnitude > MINIMAL_MAGNITUDE

unitX = if (isMagnitudeSufficient) x / magnitude else 0.0
unitY = if (isMagnitudeSufficient) y / magnitude else 0.0
}

private fun computeSimilarity(vector: Vector): Double {
return unitX * vector.unitX + unitY * vector.unitY
}

fun isSimilar(vector: Vector, threshold: Double): Boolean {
return computeSimilarity(vector) > threshold
}

companion object {
private val VECTOR_LEFT: Vector = Vector(-1.0, 0.0)
private val VECTOR_RIGHT: Vector = Vector(1.0, 0.0)
private val VECTOR_UP: Vector = Vector(0.0, -1.0)
private val VECTOR_DOWN: Vector = Vector(0.0, 1.0)
private val VECTOR_ZERO: Vector = Vector(0.0, 0.0)
const val MINIMAL_MAGNITUDE = 0.1

fun fromDirection(direction: Int): Vector =
when (direction) {
DIRECTION_LEFT -> VECTOR_LEFT
DIRECTION_RIGHT -> VECTOR_RIGHT
DIRECTION_UP -> VECTOR_UP
DIRECTION_DOWN -> VECTOR_DOWN
else -> VECTOR_ZERO
}

fun fromVelocity(tracker: VelocityTracker): Vector {
tracker.computeCurrentVelocity(1000)

val velocityX = tracker.xVelocity.toDouble()
val velocityY = tracker.yVelocity.toDouble()

return Vector(velocityX, velocityY)
}
}
}
8 changes: 1 addition & 7 deletions src/web/constants.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,2 @@
export const DEFAULT_TOUCH_SLOP = 15;

export const Direction = {
RIGHT: 1,
LEFT: 2,
UP: 4,
DOWN: 8,
};
export const MINIMAL_FLING_VELOCITY = 0.1;
61 changes: 37 additions & 24 deletions src/web/handlers/FlingGestureHandler.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,25 @@
import { State } from '../../State';
import { Direction } from '../constants';
import { Directions } from '../../Directions';
import { AdaptedEvent, Config } from '../interfaces';

import GestureHandler from './GestureHandler';
import Vector from '../tools/Vector';

const DEFAULT_MAX_DURATION_MS = 800;
const DEFAULT_MIN_ACCEPTABLE_DELTA = 32;
const DEFAULT_DIRECTION = Direction.RIGHT;
const DEFAULT_MIN_VELOCITY = 700;
const DEFAULT_MIN_DIRECTION_ALIGNMENT = 0.75;
const DEFAULT_DIRECTION = Directions.RIGHT;
const DEFAULT_NUMBER_OF_TOUCHES_REQUIRED = 1;

export default class FlingGestureHandler extends GestureHandler {
private numberOfPointersRequired = DEFAULT_NUMBER_OF_TOUCHES_REQUIRED;
private direction = DEFAULT_DIRECTION;
private direction: Directions = DEFAULT_DIRECTION;

private maxDurationMs = DEFAULT_MAX_DURATION_MS;
private minAcceptableDelta = DEFAULT_MIN_ACCEPTABLE_DELTA;
private minVelocity = DEFAULT_MIN_VELOCITY;
private minDirectionalAlignment = DEFAULT_MIN_DIRECTION_ALIGNMENT;
private delayTimeout!: number;

private startX = 0;
private startY = 0;

private maxNumberOfPointersSimultaneously = 0;
private keyPointer = NaN;

Expand All @@ -40,9 +40,6 @@ export default class FlingGestureHandler extends GestureHandler {
}

private startFling(): void {
this.startX = this.tracker.getLastX(this.keyPointer);
this.startY = this.tracker.getLastY(this.keyPointer);

this.begin();

this.maxNumberOfPointersSimultaneously = 1;
Expand All @@ -51,21 +48,29 @@ export default class FlingGestureHandler extends GestureHandler {
}

private tryEndFling(): boolean {
const velocityVector = Vector.fromVelocity(this.tracker, this.keyPointer);

const getAlignment = (direction: Directions) => {
return (
direction & this.direction &&
velocityVector.isSimilar(
Vector.fromDirection(direction),
this.minDirectionalAlignment
)
);
};

// list of alignments to all activated directions
const alignmentList = Object.values(Directions).map(getAlignment);

const isAligned = alignmentList.some(Boolean);
const isFast = velocityVector.magnitude > this.minVelocity;

if (
this.maxNumberOfPointersSimultaneously ===
this.numberOfPointersRequired &&
((this.direction & Direction.RIGHT &&
this.tracker.getLastX(this.keyPointer) - this.startX >
this.minAcceptableDelta) ||
(this.direction & Direction.LEFT &&
this.startX - this.tracker.getLastX(this.keyPointer) >
this.minAcceptableDelta) ||
(this.direction & Direction.UP &&
this.startY - this.tracker.getLastY(this.keyPointer) >
this.minAcceptableDelta) ||
(this.direction & Direction.DOWN &&
this.tracker.getLastY(this.keyPointer) - this.startY >
this.minAcceptableDelta))
isAligned &&
isFast
) {
clearTimeout(this.delayTimeout);
this.activate();
Expand Down Expand Up @@ -120,18 +125,26 @@ export default class FlingGestureHandler extends GestureHandler {
}
}

protected onPointerMove(event: AdaptedEvent): void {
private pointerMoveAction(event: AdaptedEvent): void {
this.tracker.track(event);

if (this.currentState !== State.BEGAN) {
return;
}

this.tryEndFling();
}

protected onPointerMove(event: AdaptedEvent): void {
this.pointerMoveAction(event);
super.onPointerMove(event);
}

protected onPointerOutOfBounds(event: AdaptedEvent): void {
this.pointerMoveAction(event);
super.onPointerOutOfBounds(event);
}

protected onPointerUp(event: AdaptedEvent): void {
super.onPointerUp(event);
this.onUp(event);
Expand Down
52 changes: 52 additions & 0 deletions src/web/tools/Vector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Directions } from '../../Directions';
import { MINIMAL_FLING_VELOCITY } from '../constants';
import PointerTracker from './PointerTracker';

export default class Vector {
private readonly x;
private readonly y;
private readonly unitX;
private readonly unitY;
private readonly _magnitude;

constructor(x: number, y: number) {
this.x = x;
this.y = y;

this._magnitude = Math.hypot(this.x, this.y);
const isMagnitudeSufficient = this._magnitude > MINIMAL_FLING_VELOCITY;

this.unitX = isMagnitudeSufficient ? this.x / this._magnitude : 0;
this.unitY = isMagnitudeSufficient ? this.y / this._magnitude : 0;
}

static fromDirection(direction: Directions) {
return DirectionToVectorMappings.get(direction)!;
}

static fromVelocity(tracker: PointerTracker, pointerId: number) {
return new Vector(
tracker.getVelocityX(pointerId),
tracker.getVelocityY(pointerId)
);
}

get magnitude() {
return this._magnitude;
}

computeSimilarity(vector: Vector) {
return this.unitX * vector.unitX + this.unitY * vector.unitY;
}

isSimilar(vector: Vector, threshold: number) {
return this.computeSimilarity(vector) > threshold;
}
}

const DirectionToVectorMappings = new Map<Directions, Vector>([
[Directions.LEFT, new Vector(-1, 0)],
[Directions.RIGHT, new Vector(1, 0)],
[Directions.UP, new Vector(0, -1)],
[Directions.DOWN, new Vector(0, 1)],
]);

0 comments on commit 3904238

Please sign in to comment.