improve gesture detection

This commit is contained in:
Josia Pietsch 2025-02-15 03:08:18 +01:00
parent 012f13c827
commit 0c0d90a357
Signed by: jrpie
GPG key ID: E70B571D66986A2D
2 changed files with 219 additions and 124 deletions

View file

@ -6,14 +6,11 @@ import android.content.res.Resources
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.DisplayMetrics import android.util.DisplayMetrics
import android.view.GestureDetector
import android.view.KeyEvent import android.view.KeyEvent
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewConfiguration
import android.window.OnBackInvokedDispatcher import android.window.OnBackInvokedDispatcher
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.GestureDetectorCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import de.jrpie.android.launcher.R import de.jrpie.android.launcher.R
import de.jrpie.android.launcher.actions.Action import de.jrpie.android.launcher.actions.Action
@ -23,13 +20,6 @@ import de.jrpie.android.launcher.databinding.HomeBinding
import de.jrpie.android.launcher.preferences.LauncherPreferences import de.jrpie.android.launcher.preferences.LauncherPreferences
import de.jrpie.android.launcher.ui.tutorial.TutorialActivity import de.jrpie.android.launcher.ui.tutorial.TutorialActivity
import java.util.Locale import java.util.Locale
import java.util.Timer
import kotlin.concurrent.fixedRateTimer
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.tan
/** /**
* [HomeActivity] is the actual application Launcher, * [HomeActivity] is the actual application Launcher,
@ -43,10 +33,10 @@ import kotlin.math.tan
* - Setting global variables (preferences etc.) * - Setting global variables (preferences etc.)
* - Opening the [TutorialActivity] on new installations * - Opening the [TutorialActivity] on new installations
*/ */
class HomeActivity : UIObject, AppCompatActivity(), class HomeActivity : UIObject, AppCompatActivity() {
GestureDetector.OnGestureListener, GestureDetector.OnDoubleTapListener {
private lateinit var binding: HomeBinding private lateinit var binding: HomeBinding
private lateinit var touchGestureDetector: TouchGestureDetector
private var sharedPreferencesListener = private var sharedPreferencesListener =
SharedPreferences.OnSharedPreferenceChangeListener { _, prefKey -> SharedPreferences.OnSharedPreferenceChangeListener { _, prefKey ->
@ -61,22 +51,29 @@ class HomeActivity : UIObject, AppCompatActivity(),
} }
} }
private var edgeWidth = 0.15f
private var bufferedPointerCount = 1 // how many fingers on screen
private var pointerBufferTimer = Timer()
private lateinit var mDetector: GestureDetectorCompat
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super<AppCompatActivity>.onCreate(savedInstanceState) super<AppCompatActivity>.onCreate(savedInstanceState)
super<UIObject>.onCreate() super<UIObject>.onCreate()
val displayMetrics = DisplayMetrics()
windowManager.defaultDisplay.getMetrics(displayMetrics)
val width = displayMetrics.widthPixels
val height = displayMetrics.heightPixels
touchGestureDetector = TouchGestureDetector(
this,
width,
height,
LauncherPreferences.enabled_gestures().edgeSwipeEdgeWidth() / 100f
)
// Initialise layout // Initialise layout
binding = HomeBinding.inflate(layoutInflater) binding = HomeBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
// Handle back key / gesture on Android 13+, cf. onKeyDown() // Handle back key / gesture on Android 13+, cf. onKeyDown()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
onBackInvokedDispatcher.registerOnBackInvokedCallback( onBackInvokedDispatcher.registerOnBackInvokedCallback(
@ -95,9 +92,6 @@ class HomeActivity : UIObject, AppCompatActivity(),
override fun onStart() { override fun onStart() {
super<AppCompatActivity>.onStart() super<AppCompatActivity>.onStart()
mDetector = GestureDetectorCompat(this, this)
mDetector.setOnDoubleTapListener(this)
super<UIObject>.onStart() super<UIObject>.onStart()
LauncherPreferences.getSharedPreferences() LauncherPreferences.getSharedPreferences()
@ -172,7 +166,8 @@ class HomeActivity : UIObject, AppCompatActivity(),
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
edgeWidth = LauncherPreferences.enabled_gestures().edgeSwipeEdgeWidth() / 100f touchGestureDetector.edgeWidth =
LauncherPreferences.enabled_gestures().edgeSwipeEdgeWidth() / 100f
initClock() initClock()
updateSettingsFallbackButtonVisibility() updateSettingsFallbackButtonVisibility()
@ -211,95 +206,8 @@ class HomeActivity : UIObject, AppCompatActivity(),
return true return true
} }
override fun onFling(e1: MotionEvent?, e2: MotionEvent, dX: Float, dY: Float): Boolean {
if (e1 == null) return false
val displayMetrics = DisplayMetrics()
windowManager.defaultDisplay.getMetrics(displayMetrics)
val width = displayMetrics.widthPixels
val height = displayMetrics.heightPixels
val diffX = e1.x - e2.x
val diffY = e1.y - e2.y
val doubleActions = LauncherPreferences.enabled_gestures().doubleSwipe()
val edgeActions = LauncherPreferences.enabled_gestures().edgeSwipe()
val threshold = ViewConfiguration.get(this).scaledTouchSlop
val angularThreshold = tan(Math.PI / 6)
var gesture = if (angularThreshold * abs(diffX) > abs(diffY)) { // horizontal swipe
if (diffX > threshold)
Gesture.SWIPE_LEFT
else if (diffX < -threshold)
Gesture.SWIPE_RIGHT
else null
} else if (angularThreshold * abs(diffY) > abs(diffX)) { // vertical swipe
// Only open if the swipe was not from the phones top edge
// TODO: replace 100px by sensible dp value (e.g. twice the height of the status bar)
if (diffY < -threshold && e1.y > 100)
Gesture.SWIPE_DOWN
else if (diffY > threshold)
Gesture.SWIPE_UP
else null
} else null
if (doubleActions && bufferedPointerCount > 1) {
gesture = gesture?.let(Gesture::getDoubleVariant)
}
if (edgeActions) {
if (max(e1.x, e2.x) < edgeWidth * width) {
gesture = gesture?.getEdgeVariant(Gesture.Edge.LEFT)
} else if (min(e1.x, e2.x) > (1 - edgeWidth) * width) {
gesture = gesture?.getEdgeVariant(Gesture.Edge.RIGHT)
}
if (max(e1.y, e2.y) < edgeWidth * height) {
gesture = gesture?.getEdgeVariant(Gesture.Edge.TOP)
} else if (min(e1.y, e2.y) > (1 - edgeWidth) * height) {
gesture = gesture?.getEdgeVariant(Gesture.Edge.BOTTOM)
}
}
gesture?.invoke(this)
return true
}
override fun onLongPress(event: MotionEvent) {
Gesture.LONG_CLICK(this)
}
override fun onDoubleTap(event: MotionEvent): Boolean {
Gesture.DOUBLE_CLICK(this)
return false
}
// Tooltip
override fun onSingleTapConfirmed(event: MotionEvent): Boolean {
return false
}
override fun onTouchEvent(event: MotionEvent): Boolean { override fun onTouchEvent(event: MotionEvent): Boolean {
return touchGestureDetector.onTouchEvent(event) || super.onTouchEvent(event)
// Buffer / Debounce the pointer count
if (event.pointerCount > bufferedPointerCount) {
bufferedPointerCount = event.pointerCount
pointerBufferTimer = fixedRateTimer("pointerBufferTimer", true, 300, 1000) {
bufferedPointerCount = 1
this.cancel() // a non-recurring timer
}
}
return if (mDetector.onTouchEvent(event)) {
false
} else {
super.onTouchEvent(event)
}
} }
override fun setOnClicks() { override fun setOnClicks() {
@ -329,16 +237,4 @@ class HomeActivity : UIObject, AppCompatActivity(),
override fun isHomeScreen(): Boolean { override fun isHomeScreen(): Boolean {
return true return true
} }
/* TODO: Remove those. For now they are necessary
* because this inherits from GestureDetector.OnGestureListener */
override fun onDoubleTapEvent(event: MotionEvent): Boolean { return false }
override fun onDown(event: MotionEvent): Boolean { return false }
override fun onScroll(e1: MotionEvent?, e2: MotionEvent, dX: Float, dY: Float): Boolean { return false }
override fun onShowPress(event: MotionEvent) {}
override fun onSingleTapUp(event: MotionEvent): Boolean { return false }
} }

View file

@ -0,0 +1,199 @@
package de.jrpie.android.launcher.ui
import android.content.Context
import android.view.MotionEvent
import android.view.ViewConfiguration
import de.jrpie.android.launcher.actions.Gesture
import de.jrpie.android.launcher.preferences.LauncherPreferences
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.tan
class TouchGestureDetector(
private val context: Context,
val width: Int,
val height: Int,
var edgeWidth: Float
) {
private val ANGULAR_THRESHOLD = tan(Math.PI / 6)
private val TOUCH_SLOP: Int
private val TOUCH_SLOP_SQUARE: Int
private val DOUBLE_TAP_SLOP: Int
private val DOUBLE_TAP_SLOP_SQUARE: Int
private val LONG_PRESS_TIMEOUT: Int
private val TAP_TIMEOUT: Int
private val DOUBLE_TAP_TIMEOUT: Int
data class Vector(val x: Float, val y: Float) {
fun absSquared(): Float {
return this.x * this.x + this.y * this.y
}
fun plus(vector: Vector): Vector {
return Vector(this.x + vector.x, this.y + vector.y)
}
fun max(other: Vector): Vector {
return Vector(max(this.x, other.x), max(this.y, other.y))
}
fun min(other: Vector): Vector {
return Vector(min(this.x, other.x), min(this.y, other.y))
}
operator fun minus(vector: Vector): Vector {
return Vector(this.x - vector.x, this.y - vector.y)
}
}
class PointerPath(
val number: Int,
val start: Vector,
var last: Vector = start
) {
var min = Vector(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY)
var max = Vector(Float.NEGATIVE_INFINITY, Float.NEGATIVE_INFINITY)
fun sizeSquared(): Float {
return (max - min).absSquared()
}
fun getDirection(): Vector {
return last - start
}
fun update(vector: Vector) {
min = min.min(vector)
max = max.max(vector)
last = vector
}
}
private fun PointerPath.isTap(): Boolean {
return sizeSquared() < TOUCH_SLOP_SQUARE
}
init {
val configuration = ViewConfiguration.get(context)
TOUCH_SLOP = configuration.scaledTouchSlop
TOUCH_SLOP_SQUARE = TOUCH_SLOP * TOUCH_SLOP
DOUBLE_TAP_SLOP = configuration.scaledDoubleTapSlop
DOUBLE_TAP_SLOP_SQUARE = DOUBLE_TAP_SLOP * DOUBLE_TAP_SLOP
LONG_PRESS_TIMEOUT = ViewConfiguration.getLongPressTimeout()
TAP_TIMEOUT = ViewConfiguration.getTapTimeout()
DOUBLE_TAP_TIMEOUT = ViewConfiguration.getDoubleTapTimeout()
}
private var paths = HashMap<Int, PointerPath>()
private var lastTappedTime = 0L
private var lastTappedLocation: Vector? = null
fun onTouchEvent(event: MotionEvent): Boolean {
val pointerIdToIndex =
(0..<event.pointerCount).associateBy { event.getPointerId(it) }
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
paths = HashMap()
}
// add new pointers
(0..<event.pointerCount).filter {
!paths.containsKey(event.getPointerId(it))
}.forEach {
val index = pointerIdToIndex[it] ?: return@forEach
paths[it] = PointerPath(
paths.entries.size,
Vector(event.getX(index), event.getY(index))
)
}
(0..<event.pointerCount).forEach {
val index = pointerIdToIndex[it] ?: return@forEach
paths[it]?.update(Vector(event.getX(index), event.getY(index)))
}
if (event.actionMasked == MotionEvent.ACTION_UP) {
classifyPaths(paths, event.downTime, event.eventTime)
}
return true
}
private fun getGestureForDirection(direction: Vector): Gesture? {
return if (ANGULAR_THRESHOLD * abs(direction.x) > abs(direction.y)) { // horizontal swipe
if (direction.x > TOUCH_SLOP)
Gesture.SWIPE_RIGHT
else if (direction.x < -TOUCH_SLOP)
Gesture.SWIPE_LEFT
else null
} else if (ANGULAR_THRESHOLD * abs(direction.y) > abs(direction.x)) { // vertical swipe
if (direction.y < -TOUCH_SLOP)
Gesture.SWIPE_UP
else if (direction.y > TOUCH_SLOP)
Gesture.SWIPE_DOWN
else null
} else null
}
private fun classifyPaths(paths: Map<Int, PointerPath>, timeStart: Long, timeEnd: Long) {
val duration = timeEnd - timeStart
val pointerCount = paths.entries.size
if (paths.entries.isEmpty()) {
return
}
val mainPointerPath = paths.entries.firstOrNull { it.value.number == 0 }?.value ?: return
// Ignore swipes at the very top, since this interferes with the status bar.
// TODO: replace 100px by sensible dp value (e.g. twice the height of the status bar)
if (paths.entries.any { it.value.start.y < 100 }) {
return
}
if (pointerCount == 1 && mainPointerPath.isTap()) {
// detect taps
if (duration in 0..TAP_TIMEOUT) {
if (timeStart - lastTappedTime < DOUBLE_TAP_TIMEOUT &&
lastTappedLocation?.let {
(mainPointerPath.last - it).absSquared() < DOUBLE_TAP_SLOP_SQUARE} == true
) {
Gesture.DOUBLE_CLICK.invoke(context)
} else {
lastTappedTime = timeEnd
lastTappedLocation = mainPointerPath.last
}
} else if (duration > LONG_PRESS_TIMEOUT) {
// TODO: Don't wait until the finger is lifted.
// Instead set a timer to start long click as soon as LONG_PRESS_TIMEOUT is reached
Gesture.LONG_CLICK.invoke(context)
}
} else {
// detect swipes
val doubleActions = LauncherPreferences.enabled_gestures().doubleSwipe()
val edgeActions = LauncherPreferences.enabled_gestures().edgeSwipe()
var gesture = getGestureForDirection(mainPointerPath.getDirection())
if (doubleActions && pointerCount > 1) {
if (paths.entries.any { getGestureForDirection(it.value.getDirection()) != gesture }) {
// the directions of the pointers don't match
return
}
gesture = gesture?.let(Gesture::getDoubleVariant)
}
if (edgeActions) {
if (mainPointerPath.max.x < edgeWidth * width) {
gesture = gesture?.getEdgeVariant(Gesture.Edge.LEFT)
} else if (mainPointerPath.min.x > (1 - edgeWidth) * width) {
gesture = gesture?.getEdgeVariant(Gesture.Edge.RIGHT)
}
if (mainPointerPath.max.y < edgeWidth * height) {
gesture = gesture?.getEdgeVariant(Gesture.Edge.TOP)
} else if (mainPointerPath.min.y > (1 - edgeWidth) * height) {
gesture = gesture?.getEdgeVariant(Gesture.Edge.BOTTOM)
}
}
gesture?.invoke(context)
}
}
}