diff --git a/lawnchair/src/app/lawnchair/gestures/DirectionalGestureListener.kt b/lawnchair/src/app/lawnchair/gestures/DirectionalGestureListener.kt index 7149083c73..52c2a86c93 100644 --- a/lawnchair/src/app/lawnchair/gestures/DirectionalGestureListener.kt +++ b/lawnchair/src/app/lawnchair/gestures/DirectionalGestureListener.kt @@ -17,6 +17,12 @@ abstract class DirectionalGestureListener(ctx: Context?) : OnTouchListener { fun onTouchEvent(event: MotionEvent): Boolean { handledGesture = false mGestureDetector.onTouchEvent(event) + + val action = event.actionMasked + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) { + onActionUp(handledGesture) + } + return handledGesture } @@ -29,6 +35,18 @@ abstract class DirectionalGestureListener(ctx: Context?) : OnTouchListener { private fun shouldReactToSwipe(diff: Float, velocity: Float): Boolean = abs(diff) > SWIPE_THRESHOLD && abs(velocity) > SWIPE_VELOCITY_THRESHOLD + override fun onScroll( + e1: MotionEvent?, + e2: MotionEvent, + distanceX: Float, + distanceY: Float, + ): Boolean { + val diffX = e2.x - (e1?.x ?: 0f) + val diffY = e2.y - (e1?.y ?: 0f) + onScroll(diffX, diffY) + return false + } + override fun onDown(e: MotionEvent): Boolean { return true } @@ -49,20 +67,20 @@ abstract class DirectionalGestureListener(ctx: Context?) : OnTouchListener { shouldReactToSwipe(diffY, velocityY) -> { if (diffY < 0) { Log.d("GESTURE_DETECTION", "Swipe Up Detected") - onSwipeTop() + onSwipeTop(velocityY) } else { Log.d("GESTURE_DETECTION", "Swipe Down Detected") - onSwipeDown() + onSwipeDown(velocityY) } } shouldReactToSwipe(diffX, velocityX) -> { if (diffX > 0) { Log.d("GESTURE_DETECTION", "Swipe Right Detected") - onSwipeRight() + onSwipeRight(velocityX) } else { Log.d("GESTURE_DETECTION", "Swipe Left Detected") - onSwipeLeft() + onSwipeLeft(velocityX) } } @@ -72,12 +90,20 @@ abstract class DirectionalGestureListener(ctx: Context?) : OnTouchListener { } } - abstract fun onSwipeRight(): Boolean - abstract fun onSwipeLeft(): Boolean - abstract fun onSwipeTop(): Boolean - abstract fun onSwipeDown(): Boolean + abstract fun onSwipeRight(velocity: Float): Boolean + abstract fun onSwipeLeft(velocity: Float): Boolean + abstract fun onSwipeTop(velocity: Float): Boolean + abstract fun onSwipeDown(velocity: Float): Boolean + + open fun onScroll(diffX: Float, diffY: Float) {} + open fun onActionUp(handled: Boolean) {} companion object { + /** We dampen the velocity slightly so the icon doesn't fly off-screen */ + const val VELOCITY_FACTOR = 0.5f + + const val TRANSLATION_FACTOR = 0.05f + private const val SWIPE_THRESHOLD = 100 private const val SWIPE_VELOCITY_THRESHOLD = 100 } diff --git a/lawnchair/src/app/lawnchair/gestures/IconGestureListener.kt b/lawnchair/src/app/lawnchair/gestures/IconGestureListener.kt index 129ec7bf31..8b82585feb 100644 --- a/lawnchair/src/app/lawnchair/gestures/IconGestureListener.kt +++ b/lawnchair/src/app/lawnchair/gestures/IconGestureListener.kt @@ -1,30 +1,87 @@ package app.lawnchair.gestures -import android.content.Context import android.util.Log +import android.view.ViewConfiguration +import androidx.dynamicanimation.animation.DynamicAnimation +import androidx.dynamicanimation.animation.SpringForce import androidx.lifecycle.lifecycleScope +import app.lawnchair.animation.PhysicsAnimator import app.lawnchair.gestures.config.GestureHandlerConfig import app.lawnchair.gestures.type.GestureType import app.lawnchair.launcher import app.lawnchair.preferences2.PreferenceManager2 +import com.android.launcher3.BubbleTextView import com.android.launcher3.util.ComponentKey import com.android.launcher3.util.VibratorWrapper +import kotlin.math.abs import kotlinx.coroutines.launch class IconGestureListener( - private val context: Context, + private val view: BubbleTextView, private val prefs: PreferenceManager2, private val componentKey: ComponentKey?, -) : DirectionalGestureListener(context) { +) : DirectionalGestureListener(view.context) { - override fun onSwipeRight() = handleGesture(GestureType.SWIPE_RIGHT) - override fun onSwipeLeft() = handleGesture(GestureType.SWIPE_LEFT) - override fun onSwipeTop() = handleGesture(GestureType.SWIPE_UP) - override fun onSwipeDown() = handleGesture(GestureType.SWIPE_DOWN) + private val context get() = view.context + + private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop + private var scrollLock = ScrollLock.NONE + private var isAnimating = false + + /** Represents the axis on which the current scroll gesture is locked. + */ + private enum class ScrollLock { NONE, HORIZONTAL, VERTICAL } + + private val configuredGestures by lazy { + GestureType.entries.filter { resolveGesture(it) != null }.toSet() + } + + override fun onSwipeRight(velocity: Float) = handleGesture(GestureType.SWIPE_RIGHT, velocity) + override fun onSwipeLeft(velocity: Float) = handleGesture(GestureType.SWIPE_LEFT, velocity) + override fun onSwipeTop(velocity: Float) = handleGesture(GestureType.SWIPE_UP, velocity) + override fun onSwipeDown(velocity: Float) = handleGesture(GestureType.SWIPE_DOWN, velocity) + + override fun onScroll(diffX: Float, diffY: Float) { + if (scrollLock == ScrollLock.NONE) { + val absX = abs(diffX) + val absY = abs(diffY) + if (absX > touchSlop || absY > touchSlop) { + if (isAnimating || view.translationX != 0f || view.translationY != 0f) { + PhysicsAnimator.getInstance(view).cancel() + isAnimating = false + } + scrollLock = if (absX > absY) ScrollLock.HORIZONTAL else ScrollLock.VERTICAL + } else { + return + } + } + + // Apply translation only to the locked axis + if (scrollLock == ScrollLock.HORIZONTAL) { + val gestureType = if (diffX > 0) GestureType.SWIPE_RIGHT else GestureType.SWIPE_LEFT + view.translationX = if (hasGestureConfigured(gestureType)) diffX * TRANSLATION_FACTOR else 0f + view.translationY = 0f + } else { + val gestureType = if (diffY > 0) GestureType.SWIPE_DOWN else GestureType.SWIPE_UP + view.translationY = if (hasGestureConfigured(gestureType)) diffY * TRANSLATION_FACTOR else 0f + view.translationX = 0f + } + } + + override fun onActionUp(handled: Boolean) { + scrollLock = ScrollLock.NONE + if (!handled && (view.translationX != 0f || view.translationY != 0f)) { + isAnimating = true + PhysicsAnimator.getInstance(view) + .spring(DynamicAnimation.TRANSLATION_X, 0f, 0f, SpringForce.STIFFNESS_HIGH, SpringForce.DAMPING_RATIO_NO_BOUNCY) + .spring(DynamicAnimation.TRANSLATION_Y, 0f, 0f, SpringForce.STIFFNESS_HIGH, SpringForce.DAMPING_RATIO_NO_BOUNCY) + .start() + } + } /** Check if there's any gesture configured for this entry */ fun hasAnyGestureConfigured(): Boolean { - return GestureType.entries.any(::hasGestureConfigured) + return configuredGestures.isNotEmpty() } /** Check if there's a horizontal gesture configured for this entry. (Swipe left/right) */ @@ -42,16 +99,18 @@ class IconGestureListener( /** Check if there's a specific gesture configured for this entry * @param gestureType The type of gesture to check */ private fun hasGestureConfigured(gestureType: GestureType): Boolean { - return resolveGesture(gestureType) != null + return configuredGestures.contains(gestureType) } /** Launch gesture configured operation for a specific gesture type * @param gestureType The type of gesture that triggered the event */ - private fun handleGesture(gestureType: GestureType): Boolean { + private fun handleGesture(gestureType: GestureType, velocity: Float): Boolean { val gesture = resolveGesture(gestureType) ?: return false Log.d("GESTURE_HANDLER", "Handling gesture: ${gestureType.name}") + animateIconSwipe(gestureType, velocity) + context.launcher.lifecycleScope.launch { Log.d("GESTURE_HANDLER", "Triggering gesture: ${gestureType.name}") // Lawnchair-TODO: Migrate to MSDL vibration? @@ -62,6 +121,22 @@ class IconGestureListener( return true } + /** Animates a spring-back effect on the icon following a swipe gesture. + */ + private fun animateIconSwipe(gestureType: GestureType, velocity: Float) { + // We spring back to 0, but use the swipe velocity to "overshoot" naturally, + // then dampen the velocity slightly so the icon doesn't fly off screen + val (velX, velY) = when (gestureType) { + GestureType.SWIPE_RIGHT, GestureType.SWIPE_LEFT -> (velocity * VELOCITY_FACTOR) to 0f + GestureType.SWIPE_UP, GestureType.SWIPE_DOWN -> 0f to (velocity * VELOCITY_FACTOR) + } + + PhysicsAnimator.getInstance(view) + .spring(DynamicAnimation.TRANSLATION_X, 0f, velX, SpringForce.STIFFNESS_MEDIUM, SpringForce.DAMPING_RATIO_NO_BOUNCY) + .spring(DynamicAnimation.TRANSLATION_Y, 0f, velY, SpringForce.STIFFNESS_MEDIUM, SpringForce.DAMPING_RATIO_NO_BOUNCY) + .start() + } + /** Get gesture handler for that app's component * @param gestureType The type of gesture to resolve for the current component */ private fun resolveGesture(gestureType: GestureType): GestureHandlerConfig? { diff --git a/src/com/android/launcher3/BubbleTextView.java b/src/com/android/launcher3/BubbleTextView.java index d8a14fabdb..2ee36b8665 100644 --- a/src/com/android/launcher3/BubbleTextView.java +++ b/src/com/android/launcher3/BubbleTextView.java @@ -111,12 +111,12 @@ import java.util.Locale; import java.util.Objects; import app.lawnchair.preferences2.PreferenceCacheExtensionsKt; -import app.lawnchair.LawnchairApp; import app.lawnchair.font.FontManager; import app.lawnchair.gestures.IconGestureListener; import app.lawnchair.preferences.PreferenceManager; import app.lawnchair.preferences2.PreferenceManager2; import app.lawnchair.util.LawnchairUtilsKt; +import app.lawnchair.animation.PhysicsAnimator; /** * TextView that draws a bubble behind the text. We cannot use a LineBackgroundSpan @@ -505,7 +505,7 @@ public class BubbleTextView extends TextView implements ItemInfoUpdateReceiver, setTag(itemInfo); // Lawnchair: Icon swipe gesture feature mGestureListener = shouldSupportIconSwipeGestures() - ? new IconGestureListener(getContext(), pref2, itemInfo.getComponentKey()) + ? new IconGestureListener(this, pref2, itemInfo.getComponentKey()) : null; } @@ -1606,6 +1606,10 @@ public class BubbleTextView extends TextView implements ItemInfoUpdateReceiver, if (mIcon != null) { mIcon.resetScale(); } + // LC-Note: Cancel physics animator for icon swipe gesture + PhysicsAnimator.getInstance(this).cancel(); + setTranslationX(0); + setTranslationY(0); } private void updateIcon(Drawable newIcon) {