diff --git a/android/app/src/main/java/com/izzy2lost/x1box/MainActivity.kt b/android/app/src/main/java/com/izzy2lost/x1box/MainActivity.kt index d825656c0c..df18e03126 100644 --- a/android/app/src/main/java/com/izzy2lost/x1box/MainActivity.kt +++ b/android/app/src/main/java/com/izzy2lost/x1box/MainActivity.kt @@ -11,11 +11,13 @@ import android.os.Process import android.util.Log import android.view.InputDevice import android.view.KeyEvent +import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.view.WindowInsets import android.view.WindowInsetsController import android.view.WindowManager +import android.view.ViewConfiguration import android.widget.BaseAdapter import android.widget.FrameLayout import android.widget.ImageView @@ -30,6 +32,7 @@ import org.libsdl.app.SDLSurface import java.io.File import java.nio.ByteBuffer import java.nio.ByteOrder +import kotlin.math.max class MainActivity : SDLActivity(), InputManager.InputDeviceListener { companion object { @@ -58,6 +61,7 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener { private var startupSnapshotSlot: Int? = null private var startupSnapshotLoadScheduled = false @Volatile private var processTerminationScheduled = false + private lateinit var swipeUpGestureRecognizer: SwipeUpGestureRecognizer override fun createSDLSurface(context: Context): SDLSurface { return super.createSDLSurface(context).apply { @@ -76,6 +80,7 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener { if (requestedSlot in 1..TOTAL_SNAPSHOT_SLOTS) { startupSnapshotSlot = requestedSlot } + initializeSwipeMenuGesture() setupOnScreenController() setupControllerDetection() hideSystemUI() @@ -121,6 +126,16 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener { return super.dispatchKeyEvent(event) } + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + if (shouldHandleActivitySwipeMenu() && swipeUpGestureRecognizer.onTouchEvent(event)) { + return true + } + if (!shouldHandleActivitySwipeMenu()) { + swipeUpGestureRecognizer.reset() + } + return super.dispatchTouchEvent(event) + } + private fun hideSystemUI() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { // Android 11 (API 30) and above @@ -144,6 +159,23 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener { } } + private fun initializeSwipeMenuGesture() { + val swipeTouchSlop = ViewConfiguration.get(this).scaledTouchSlop.toFloat() + swipeUpGestureRecognizer = SwipeUpGestureRecognizer( + minDistancePx = { + max(currentGestureHostHeight() * 0.14f, swipeTouchSlop * 8f) + }, + touchSlopPx = { swipeTouchSlop }, + canStartAt = { _, y -> + y >= currentGestureHostHeight() * 0.35f + }, + onTriggered = { + onScreenController?.resetAllInputs() + showInGameMenu() + }, + ) + } + private fun setupOnScreenController() { // Create on-screen controller onScreenController = OnScreenController(this).apply { @@ -180,6 +212,7 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener { } override fun onPause() { + swipeUpGestureRecognizer.reset() onScreenController?.resetAllInputs() super.onPause() } @@ -267,6 +300,7 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener { if (shouldShow != isControllerVisible) { isControllerVisible = shouldShow + swipeUpGestureRecognizer.reset() onScreenController?.visibility = if (shouldShow) View.VISIBLE else View.GONE } } @@ -292,6 +326,7 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener { override fun onDestroy() { Log.i(TAG, "onDestroy()") + swipeUpGestureRecognizer.reset() inGameMenuDialog?.dismiss() inGameMenuDialog = null val shouldTerminateProcess = isFinishing && !isChangingConfigurations @@ -665,6 +700,11 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener { } private fun showInGameMenu() { + swipeUpGestureRecognizer.reset() + if (inGameMenuDialog?.isShowing == true) { + return + } + val options = arrayOf( getString(R.string.in_game_menu_resume), if (isControllerVisible) { @@ -700,6 +740,15 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener { dialog.show() } + private fun shouldHandleActivitySwipeMenu(): Boolean { + return !isControllerVisible && inGameMenuDialog?.isShowing != true + } + + private fun currentGestureHostHeight(): Float { + val hostHeight = mLayout?.height ?: window.decorView.height + return max(hostHeight, 1).toFloat() + } + private fun exitToGameLibrary() { val intent = Intent(this, GameLibraryActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) diff --git a/android/app/src/main/java/com/izzy2lost/x1box/OnScreenController.kt b/android/app/src/main/java/com/izzy2lost/x1box/OnScreenController.kt index 8aac10b647..01b173a517 100644 --- a/android/app/src/main/java/com/izzy2lost/x1box/OnScreenController.kt +++ b/android/app/src/main/java/com/izzy2lost/x1box/OnScreenController.kt @@ -8,6 +8,8 @@ import android.graphics.PointF import android.util.AttributeSet import android.view.MotionEvent import android.view.View +import android.view.ViewConfiguration +import kotlin.math.max import kotlin.math.pow import kotlin.math.sqrt @@ -25,6 +27,21 @@ class OnScreenController @JvmOverloads constructor( private var menuButtonRadius = 0f private var menuButtonPressed = false private var menuButtonPointerId = -1 + private val swipeTouchSlop = ViewConfiguration.get(context).scaledTouchSlop.toFloat() + private val swipeUpGestureRecognizer = SwipeUpGestureRecognizer( + minDistancePx = { + max(height * 0.14f, swipeTouchSlop * 8f) + }, + touchSlopPx = { swipeTouchSlop }, + canStartAt = { x, y -> + y >= height * 0.35f && !isPointInInteractiveElement(x, y) + }, + onTriggered = { + handleCancel() + onMenuButtonTapped?.invoke() + invalidate() + }, + ) var onMenuButtonTapped: (() -> Unit)? = null @@ -323,6 +340,11 @@ class OnScreenController @JvmOverloads constructor( } override fun onTouchEvent(event: MotionEvent): Boolean { + if (swipeUpGestureRecognizer.onTouchEvent(event)) { + invalidate() + return true + } + val pointerIndex = event.actionIndex val pointerId = event.getPointerId(pointerIndex) val x = event.getX(pointerIndex) @@ -450,6 +472,8 @@ class OnScreenController @JvmOverloads constructor( } private fun handleCancel() { + swipeUpGestureRecognizer.reset() + if (menuButtonPointerId != -1) { menuButtonPressed = false menuButtonPointerId = -1 @@ -534,6 +558,18 @@ class OnScreenController @JvmOverloads constructor( return sqrt(dx.pow(2) + dy.pow(2)) <= radius } + private fun isPointInInteractiveElement(x: Float, y: Float): Boolean { + if (isPointInCircle(x, y, menuButtonCenter, menuButtonRadius)) { + return true + } + + if (sticks.values.any { isPointInCircle(x, y, it.center, it.radius) }) { + return true + } + + return buttons.values.any { isPointInCircle(x, y, it.center, it.radius) } + } + fun setVisibility(visible: Boolean) { visibility = if (visible) View.VISIBLE else View.GONE } diff --git a/android/app/src/main/java/com/izzy2lost/x1box/SwipeUpGestureRecognizer.kt b/android/app/src/main/java/com/izzy2lost/x1box/SwipeUpGestureRecognizer.kt new file mode 100644 index 0000000000..dbcfec1ff0 --- /dev/null +++ b/android/app/src/main/java/com/izzy2lost/x1box/SwipeUpGestureRecognizer.kt @@ -0,0 +1,106 @@ +package com.izzy2lost.x1box + +import android.view.MotionEvent +import kotlin.math.abs +import kotlin.math.max + +class SwipeUpGestureRecognizer( + private val minDistancePx: () -> Float, + private val touchSlopPx: () -> Float, + private val canStartAt: (x: Float, y: Float) -> Boolean, + private val onTriggered: () -> Unit, +) { + private var activePointerId = INVALID_POINTER_ID + private var startX = 0f + private var startY = 0f + private var tracking = false + private var triggered = false + + fun onTouchEvent(event: MotionEvent): Boolean { + if (triggered) { + when (event.actionMasked) { + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL -> reset() + MotionEvent.ACTION_POINTER_UP -> { + if (event.getPointerId(event.actionIndex) == activePointerId) { + reset() + } + } + } + return true + } + + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + reset() + val x = event.getX(0) + val y = event.getY(0) + if (canStartAt(x, y)) { + activePointerId = event.getPointerId(0) + startX = x + startY = y + tracking = true + } + } + + MotionEvent.ACTION_POINTER_DOWN -> { + reset() + } + + MotionEvent.ACTION_MOVE -> { + if (!tracking) { + return false + } + + val pointerIndex = event.findPointerIndex(activePointerId) + if (pointerIndex < 0) { + reset() + return false + } + + val x = event.getX(pointerIndex) + val y = event.getY(pointerIndex) + val dx = x - startX + val dy = y - startY + val upwardDistance = -dy + val slop = touchSlopPx() + + if (dy > slop || abs(dx) > max(slop * 5f, upwardDistance * 0.9f + slop)) { + reset() + return false + } + + if (upwardDistance >= minDistancePx() && + upwardDistance >= abs(dx) * 1.35f) { + triggered = true + tracking = false + onTriggered() + return true + } + } + + MotionEvent.ACTION_POINTER_UP -> { + if (event.getPointerId(event.actionIndex) == activePointerId) { + reset() + } + } + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL -> reset() + } + + return false + } + + fun reset() { + activePointerId = INVALID_POINTER_ID + startX = 0f + startY = 0f + tracking = false + triggered = false + } + + private companion object { + const val INVALID_POINTER_ID = -1 + } +}