feat: Animate icon swipe gestures (#6964)

This commit improves the feedback of the icon swipe gestures by providing visual animations on swipe, similar to Lawnchair Legacy.
This commit is contained in:
TheSuperDragon authored and GitHub committed 2026-07-06 19:26:51 +00:00
1 parent c047f37256
commit e01c47efcf
3 files changed
+125 -20

No files matched your search

@@ -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
}
@@ -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? {
@@ -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) {