mirror of
https://github.com/jrpie/Launcher.git
synced 2025-02-22 14:01:28 +01:00
improve gesture detection
This commit is contained in:
parent
012f13c827
commit
0c0d90a357
2 changed files with 219 additions and 124 deletions
|
@ -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<AppCompatActivity>.onCreate(savedInstanceState)
|
||||
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
|
||||
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<AppCompatActivity>.onStart()
|
||||
|
||||
mDetector = GestureDetectorCompat(this, this)
|
||||
mDetector.setOnDoubleTapListener(this)
|
||||
|
||||
super<UIObject>.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 }
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue