From 0c0d90a35749cec150917c4167514b8097b32afc Mon Sep 17 00:00:00 2001 From: Josia Pietsch Date: Sat, 15 Feb 2025 03:08:18 +0100 Subject: [PATCH] improve gesture detection --- .../jrpie/android/launcher/ui/HomeActivity.kt | 144 ++----------- .../launcher/ui/TouchGestureDetector.kt | 199 ++++++++++++++++++ 2 files changed, 219 insertions(+), 124 deletions(-) create mode 100644 app/src/main/java/de/jrpie/android/launcher/ui/TouchGestureDetector.kt diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/HomeActivity.kt b/app/src/main/java/de/jrpie/android/launcher/ui/HomeActivity.kt index b41eff3..973e0ca 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/HomeActivity.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/HomeActivity.kt @@ -6,14 +6,11 @@ import android.content.res.Resources import android.os.Build import android.os.Bundle import android.util.DisplayMetrics -import android.view.GestureDetector import android.view.KeyEvent import android.view.MotionEvent import android.view.View -import android.view.ViewConfiguration import android.window.OnBackInvokedDispatcher import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.GestureDetectorCompat import androidx.core.view.isVisible import de.jrpie.android.launcher.R 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.ui.tutorial.TutorialActivity 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, @@ -43,10 +33,10 @@ import kotlin.math.tan * - Setting global variables (preferences etc.) * - Opening the [TutorialActivity] on new installations */ -class HomeActivity : UIObject, AppCompatActivity(), - GestureDetector.OnGestureListener, GestureDetector.OnDoubleTapListener { +class HomeActivity : UIObject, AppCompatActivity() { private lateinit var binding: HomeBinding + private lateinit var touchGestureDetector: TouchGestureDetector private var sharedPreferencesListener = 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?) { super.onCreate(savedInstanceState) super.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 binding = HomeBinding.inflate(layoutInflater) setContentView(binding.root) + // Handle back key / gesture on Android 13+, cf. onKeyDown() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { onBackInvokedDispatcher.registerOnBackInvokedCallback( @@ -95,9 +92,6 @@ class HomeActivity : UIObject, AppCompatActivity(), override fun onStart() { super.onStart() - mDetector = GestureDetectorCompat(this, this) - mDetector.setOnDoubleTapListener(this) - super.onStart() LauncherPreferences.getSharedPreferences() @@ -172,7 +166,8 @@ class HomeActivity : UIObject, AppCompatActivity(), override fun onResume() { super.onResume() - edgeWidth = LauncherPreferences.enabled_gestures().edgeSwipeEdgeWidth() / 100f + touchGestureDetector.edgeWidth = + LauncherPreferences.enabled_gestures().edgeSwipeEdgeWidth() / 100f initClock() updateSettingsFallbackButtonVisibility() @@ -211,95 +206,8 @@ class HomeActivity : UIObject, AppCompatActivity(), 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 { - - // 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) - } + return touchGestureDetector.onTouchEvent(event) || super.onTouchEvent(event) } override fun setOnClicks() { @@ -329,16 +237,4 @@ class HomeActivity : UIObject, AppCompatActivity(), override fun isHomeScreen(): Boolean { 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 } - - - } diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/TouchGestureDetector.kt b/app/src/main/java/de/jrpie/android/launcher/ui/TouchGestureDetector.kt new file mode 100644 index 0000000..6a462e9 --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/ui/TouchGestureDetector.kt @@ -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() + + private var lastTappedTime = 0L + private var lastTappedLocation: Vector? = null + + fun onTouchEvent(event: MotionEvent): Boolean { + val pointerIdToIndex = + (0.. 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, 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) + } + } +} \ No newline at end of file