From 2623e42dad2dd083351aa38b263ec6fa4f196fd3 Mon Sep 17 00:00:00 2001 From: John Andrew Camu Date: Mon, 17 Feb 2025 11:45:04 +0800 Subject: [PATCH] feat : initial implementation icon gesture support (#5266) - closes : #2787 - closes : #5259 --- lawnchair/res/values/config.xml | 7 ++ lawnchair/res/values/strings.xml | 2 + .../gestures/DirectionalGestureListener.kt | 68 +++++++++------- .../lawnchair/gestures/IconGestureListener.kt | 42 ++++++++++ .../lawnchair/gestures/type/GestureType.kt | 19 +++++ .../app/lawnchair/override/CustomizeDialog.kt | 20 ++++- .../preferences2/PreferenceManager2.kt | 25 ++++++ .../components/GestureHandlerPreference.kt | 79 +++++++++++++++++++ src/com/android/launcher3/BubbleTextView.java | 31 ++++++-- src/com/android/launcher3/Workspace.java | 39 +++++++++ 10 files changed, 297 insertions(+), 35 deletions(-) create mode 100644 lawnchair/src/app/lawnchair/gestures/IconGestureListener.kt create mode 100644 lawnchair/src/app/lawnchair/gestures/type/GestureType.kt diff --git a/lawnchair/res/values/config.xml b/lawnchair/res/values/config.xml index b712c206df..ad744e4acb 100644 --- a/lawnchair/res/values/config.xml +++ b/lawnchair/res/values/config.xml @@ -99,6 +99,13 @@ suck_in + + + pref_swipe_up + pref_swipe_down + pref_swipe_right + pref_swipe_left + true true false diff --git a/lawnchair/res/values/strings.xml b/lawnchair/res/values/strings.xml index 2ded34e55d..ea28a89048 100644 --- a/lawnchair/res/values/strings.xml +++ b/lawnchair/res/values/strings.xml @@ -466,6 +466,8 @@ Swipe down Home button Back button + Swipe left + Swipe right Do nothing Sleep diff --git a/lawnchair/src/app/lawnchair/gestures/DirectionalGestureListener.kt b/lawnchair/src/app/lawnchair/gestures/DirectionalGestureListener.kt index 6c79898a56..94eb65ddad 100644 --- a/lawnchair/src/app/lawnchair/gestures/DirectionalGestureListener.kt +++ b/lawnchair/src/app/lawnchair/gestures/DirectionalGestureListener.kt @@ -2,6 +2,7 @@ package app.lawnchair.gestures import android.annotation.SuppressLint import android.content.Context +import android.util.Log import android.view.GestureDetector import android.view.GestureDetector.SimpleOnGestureListener import android.view.MotionEvent @@ -9,53 +10,64 @@ import android.view.View import android.view.View.OnTouchListener import kotlin.math.abs -open class DirectionalGestureListener(ctx: Context?) : OnTouchListener { - private val mGestureDetector: GestureDetector +abstract class DirectionalGestureListener(ctx: Context?) : OnTouchListener { + private val mGestureDetector = GestureDetector(ctx, GestureListener()) @SuppressLint("ClickableViewAccessibility") override fun onTouch(v: View, event: MotionEvent): Boolean { return mGestureDetector.onTouchEvent(event) } - private inner class GestureListener : SimpleOnGestureListener() { + inner class GestureListener : SimpleOnGestureListener() { + + private fun shouldReactToSwipe(diff: Float, velocity: Float): Boolean = abs(diff) > SWIPE_THRESHOLD && abs(velocity) > SWIPE_VELOCITY_THRESHOLD override fun onDown(e: MotionEvent): Boolean { return true } - private fun shouldReactToSwipe(diff: Float, velocity: Float): Boolean = abs(diff) > SWIPE_THRESHOLD && abs(velocity) > SWIPE_VELOCITY_THRESHOLD + override fun onFling( + e1: MotionEvent?, + e2: MotionEvent, + velocityX: Float, + velocityY: Float, + ): Boolean { + val diffY = e2.y - (e1?.y ?: 0f) + val diffX = e2.x - (e1?.x ?: 0f) - override fun onFling(e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean { - return try { - val diffY = e2.y - (e1?.y ?: 0f) - val diffX = e2.x - (e1?.x ?: 0f) + Log.d("GESTURE_DETECTION", "onFling: y " + shouldReactToSwipe(diffY, velocityY)) + Log.d("GESTURE_DETECTION", "onFling: X " + shouldReactToSwipe(diffX, velocityX)) - when { - abs(diffX) > abs(diffY) && shouldReactToSwipe(diffX, velocityX) -> { - if (diffX > 0) onSwipeRight() else onSwipeLeft() - true + return when { + shouldReactToSwipe(diffY, velocityY) -> { + if (diffY < 0) { + Log.d("GESTURE_DETECTION", "Swipe Up Detected") + onSwipeTop() + } else { + Log.d("GESTURE_DETECTION", "Swipe Down Detected") + onSwipeDown() } - shouldReactToSwipe(diffY, velocityY) -> { - if (diffY > 0) onSwipeBottom() else onSwipeTop() - true - } - else -> false + true } - } catch (e: Exception) { - e.printStackTrace() - false + shouldReactToSwipe(diffX, velocityX) -> { + if (diffX > 0) { + Log.d("GESTURE_DETECTION", "Swipe Right Detected") + onSwipeRight() + } else { + Log.d("GESTURE_DETECTION", "Swipe Left Detected") + onSwipeLeft() + } + true + } + else -> false } } } - fun onSwipeRight() {} - fun onSwipeLeft() {} - fun onSwipeTop() {} - open fun onSwipeBottom() {} - - init { - mGestureDetector = GestureDetector(ctx, GestureListener()) - } + abstract fun onSwipeRight() + abstract fun onSwipeLeft() + abstract fun onSwipeTop() + abstract fun onSwipeDown() companion object { private const val SWIPE_THRESHOLD = 100 diff --git a/lawnchair/src/app/lawnchair/gestures/IconGestureListener.kt b/lawnchair/src/app/lawnchair/gestures/IconGestureListener.kt new file mode 100644 index 0000000000..70873db0f5 --- /dev/null +++ b/lawnchair/src/app/lawnchair/gestures/IconGestureListener.kt @@ -0,0 +1,42 @@ +package app.lawnchair.gestures + +import android.content.Context +import android.util.Log +import androidx.lifecycle.lifecycleScope +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.model.data.ItemInfo +import com.android.launcher3.util.VibratorWrapper +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch + +class IconGestureListener( + private val context: Context, + private val prefs: PreferenceManager2, + private val cmp: ItemInfo?, +) : DirectionalGestureListener(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 fun handleGesture(gestureType: GestureType) { + Log.d("GESTURE_HANDLER", "Handling gesture: ${gestureType.name}") + + cmp?.componentKey?.let { + context.launcher.lifecycleScope.launch { + val gesture = prefs.getGestureForApp(it, gestureType).firstOrNull() + if (gesture !is GestureHandlerConfig.NoOp) { + Log.d("GESTURE_HANDLER", "Triggering gesture: ${gestureType.name}") + VibratorWrapper.INSTANCE.get(context.launcher).vibrate(VibratorWrapper.OVERVIEW_HAPTIC) + gesture?.createHandler(context)?.onTrigger(context.launcher) + } else { + Log.d("GESTURE_HANDLER", "NoOp gesture, ignoring") + } + } + } + } +} diff --git a/lawnchair/src/app/lawnchair/gestures/type/GestureType.kt b/lawnchair/src/app/lawnchair/gestures/type/GestureType.kt new file mode 100644 index 0000000000..cd212a127b --- /dev/null +++ b/lawnchair/src/app/lawnchair/gestures/type/GestureType.kt @@ -0,0 +1,19 @@ +package app.lawnchair.gestures.type + +import android.annotation.StringRes +import android.content.Context +import com.android.launcher3.R + +enum class GestureType(@StringRes val keyResId: Int, @StringRes val labelResId: Int) { + SWIPE_UP(R.string.pref_key_swipe_up, R.string.gesture_swipe_up), + SWIPE_DOWN(R.string.pref_key_swipe_down, R.string.gesture_swipe_down), + SWIPE_LEFT(R.string.pref_key_swipe_left, R.string.gesture_swipe_left), + SWIPE_RIGHT(R.string.pref_key_swipe_right, R.string.gesture_swipe_right), + ; + + companion object { + fun fromKey(key: String, context: Context): GestureType? { + return entries.find { context.getString(it.keyResId) == key } + } + } +} diff --git a/lawnchair/src/app/lawnchair/override/CustomizeDialog.kt b/lawnchair/src/app/lawnchair/override/CustomizeDialog.kt index 9b4facf167..834f22103b 100644 --- a/lawnchair/src/app/lawnchair/override/CustomizeDialog.kt +++ b/lawnchair/src/app/lawnchair/override/CustomizeDialog.kt @@ -29,11 +29,14 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import app.lawnchair.gestures.type.GestureType +import app.lawnchair.launcher import app.lawnchair.preferences.getAdapter import app.lawnchair.preferences.preferenceManager import app.lawnchair.preferences2.asState import app.lawnchair.preferences2.preferenceManager2 import app.lawnchair.ui.preferences.PreferenceActivity +import app.lawnchair.ui.preferences.components.AppGesturePreference import app.lawnchair.ui.preferences.components.controls.SwitchPreference import app.lawnchair.ui.preferences.components.layout.ClickableIcon import app.lawnchair.ui.preferences.components.layout.PreferenceGroup @@ -41,10 +44,10 @@ import app.lawnchair.ui.preferences.navigation.Routes import app.lawnchair.ui.util.addIfNotNull import app.lawnchair.util.navigationBarsOrDisplayCutoutPadding import com.android.launcher3.LauncherAppState +import com.android.launcher3.LauncherState import com.android.launcher3.R import com.android.launcher3.util.ComponentKey import com.google.accompanist.drawablepainter.rememberDrawablePainter -import kotlinx.coroutines.launch @Composable fun CustomizeDialog( @@ -168,5 +171,20 @@ fun CustomizeAppDialog( }, ) } + + if (context.launcher.stateManager.state != LauncherState.ALL_APPS) { + PreferenceGroup(heading = stringResource(R.string.gestures_label)) { + listOf( + GestureType.SWIPE_LEFT, + GestureType.SWIPE_RIGHT, + ).map { gestureType -> + AppGesturePreference( + componentKey, + gestureType, + stringResource(id = gestureType.labelResId), + ) + } + } + } } } diff --git a/lawnchair/src/app/lawnchair/preferences2/PreferenceManager2.kt b/lawnchair/src/app/lawnchair/preferences2/PreferenceManager2.kt index f49e5e58cc..38ea269770 100644 --- a/lawnchair/src/app/lawnchair/preferences2/PreferenceManager2.kt +++ b/lawnchair/src/app/lawnchair/preferences2/PreferenceManager2.kt @@ -21,13 +21,16 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.floatPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.preferencesDataStore +import app.lawnchair.data.Converters import app.lawnchair.font.FontCache import app.lawnchair.gestures.config.GestureHandlerConfig +import app.lawnchair.gestures.type.GestureType import app.lawnchair.hotseat.HotseatMode import app.lawnchair.icons.CustomAdaptiveIconDrawable import app.lawnchair.icons.shape.IconShape @@ -51,6 +54,7 @@ import com.android.launcher3.InvariantDeviceProfile.INDEX_DEFAULT import com.android.launcher3.LauncherAppState import com.android.launcher3.R import com.android.launcher3.graphics.IconShape as L3IconShape +import com.android.launcher3.util.ComponentKey import com.android.launcher3.util.DynamicResource import com.android.launcher3.util.MainThreadInitializedObject import com.android.launcher3.util.SafeCloseable @@ -58,9 +62,11 @@ import com.patrykmichalik.opto.core.PreferenceManager import com.patrykmichalik.opto.core.firstBlocking import com.patrykmichalik.opto.core.setBlocking import kotlinx.coroutines.MainScope +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach class PreferenceManager2 private constructor(private val context: Context) : @@ -700,6 +706,25 @@ class PreferenceManager2 private constructor(private val context: Context) : .launchIn(scope) } + suspend fun setGestureForApp(key: ComponentKey, gestureType: GestureType, gesture: GestureHandlerConfig) { + val cmp = Converters().fromComponentKey(key) + val key = stringPreferencesKey("$cmp:${gestureType.name}") + preferencesDataStore.edit { prefs -> + prefs[key] = kotlinxJson.encodeToString(gesture) + } + } + + fun getGestureForApp(key: ComponentKey, gestureType: GestureType): Flow { + val cmp = Converters().fromComponentKey(key) + val key = stringPreferencesKey("$cmp:${gestureType.name}") + return preferencesDataStore.data.map { prefs -> + prefs[key]?.let { + runCatching { kotlinxJson.decodeFromString(it) } + .getOrDefault(GestureHandlerConfig.NoOp) + } ?: GestureHandlerConfig.NoOp + } + } + private fun initializeIconShape(shape: IconShape) { CustomAdaptiveIconDrawable.sInitialized = true CustomAdaptiveIconDrawable.sMaskId = shape.getHashString() diff --git a/lawnchair/src/app/lawnchair/ui/preferences/components/GestureHandlerPreference.kt b/lawnchair/src/app/lawnchair/ui/preferences/components/GestureHandlerPreference.kt index 3c335538fe..63320dce80 100644 --- a/lawnchair/src/app/lawnchair/ui/preferences/components/GestureHandlerPreference.kt +++ b/lawnchair/src/app/lawnchair/ui/preferences/components/GestureHandlerPreference.kt @@ -2,26 +2,38 @@ package app.lawnchair.ui.preferences.components import android.R as AndroidR import android.app.Activity +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.OutlinedButton import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import app.lawnchair.gestures.config.GestureHandlerConfig import app.lawnchair.gestures.config.GestureHandlerOption +import app.lawnchair.gestures.type.GestureType import app.lawnchair.preferences.PreferenceAdapter import app.lawnchair.preferences2.preferenceManager2 import app.lawnchair.ui.ModalBottomSheetContent import app.lawnchair.ui.preferences.components.layout.PreferenceDivider import app.lawnchair.ui.preferences.components.layout.PreferenceTemplate import app.lawnchair.ui.util.LocalBottomSheetHandler +import com.android.launcher3.util.ComponentKey import com.patrykmichalik.opto.core.firstBlocking import kotlinx.coroutines.launch @@ -104,3 +116,70 @@ fun GestureHandlerPreference( }, ) } + +@Composable +fun AppGesturePreference( + cmp: ComponentKey, + gestureType: GestureType, + label: String, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val prefs = preferenceManager2() + + var isExpanded by remember { mutableStateOf(false) } + + val currentConfig by produceState(initialValue = GestureHandlerConfig.NoOp) { + prefs.getGestureForApp(cmp, gestureType).collect { value = it } + } + + fun onSelect(option: GestureHandlerOption) { + scope.launch { + val config = option.buildConfig(context as Activity) ?: return@launch + prefs.setGestureForApp(cmp, gestureType, config) + isExpanded = false + } + } + + Column(modifier = modifier.fillMaxWidth()) { + PreferenceTemplate( + title = { Text(text = label) }, + description = { Text(text = currentConfig.getLabel(context)) }, + modifier = Modifier + .clickable { isExpanded = !isExpanded } + .fillMaxWidth(), + ) + + AnimatedVisibility(visible = isExpanded) { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 100.dp, max = 300.dp), + ) { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + ) { + itemsIndexed(options) { index, option -> + if (index > 0) { + PreferenceDivider(startIndent = 40.dp) + } + val selected = currentConfig::class.java == option.configClass + PreferenceTemplate( + title = { Text(option.getLabel(context)) }, + modifier = Modifier.clickable { + onSelect(option) + }, + startWidget = { + RadioButton( + selected = selected, + onClick = null, + ) + }, + ) + } + } + } + } + } +} diff --git a/src/com/android/launcher3/BubbleTextView.java b/src/com/android/launcher3/BubbleTextView.java index bd891aa6eb..cc29cab3df 100644 --- a/src/com/android/launcher3/BubbleTextView.java +++ b/src/com/android/launcher3/BubbleTextView.java @@ -95,6 +95,7 @@ import java.util.Locale; 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; @@ -227,6 +228,7 @@ public class BubbleTextView extends TextView implements ItemInfoUpdateReceiver, private boolean mEnableIconUpdateAnimation = false; private final PreferenceManager2 pref2; + private IconGestureListener mGestureListener; public BubbleTextView(Context context) { this(context, null, 0); @@ -378,6 +380,9 @@ public class BubbleTextView extends TextView implements ItemInfoUpdateReceiver, @UiThread public void applyFromWorkspaceItem(WorkspaceItemInfo info) { applyFromWorkspaceItem(info, /* animate = */ false, /* staggerIndex = */ 0); + if (info != null) { + mGestureListener = new IconGestureListener(mContext, pref2, info); + } } @UiThread @@ -542,6 +547,12 @@ public class BubbleTextView extends TextView implements ItemInfoUpdateReceiver, @Override public boolean onTouchEvent(MotionEvent event) { + + if (mGestureListener != null) { + mGestureListener.onTouch(this, event); + resetIconScale(true); + } + // ignore events if they happen in padding area if (event.getAction() == MotionEvent.ACTION_DOWN && shouldIgnoreTouchDown(event.getX(), event.getY())) { @@ -846,14 +857,13 @@ public class BubbleTextView extends TextView implements ItemInfoUpdateReceiver, } public boolean shouldTextBeVisible() { - // Text should be visible everywhere but the hotseat. + // Text should be visible everywhere, and in hotseat if getEnableLabelInDock is enabled. Object tag = getParent() instanceof FolderIcon ? ((View) getParent()).getTag() : getTag(); ItemInfo info = tag instanceof ItemInfo ? (ItemInfo) tag : null; - if (info != null && (info.container != LauncherSettings.Favorites.CONTAINER_HOTSEAT - && info.container != LauncherSettings.Favorites.CONTAINER_HOTSEAT_PREDICTION)) { - return !PreferenceExtensionsKt.firstBlocking(pref2.getEnableLabelInDock()); - } - return true; + + return info == null || info.container != LauncherSettings.Favorites.CONTAINER_HOTSEAT + && info.container != LauncherSettings.Favorites.CONTAINER_HOTSEAT_PREDICTION + || PreferenceExtensionsKt.firstBlocking(pref2.getEnableLabelInDock()); } public void setTextVisibility(boolean visible) { @@ -1347,4 +1357,13 @@ public class BubbleTextView extends TextView implements ItemInfoUpdateReceiver, } return null; } + + /** Returns the ItemInfo of the app this icon represents. */ + public ItemInfo getItemInfo() { + Object tag = getTag(); + if (tag instanceof ItemInfo itemInfo) { + return itemInfo; + } + return null; + } } diff --git a/src/com/android/launcher3/Workspace.java b/src/com/android/launcher3/Workspace.java index 38c793ee26..523f99dfeb 100644 --- a/src/com/android/launcher3/Workspace.java +++ b/src/com/android/launcher3/Workspace.java @@ -146,6 +146,7 @@ import app.lawnchair.LawnchairApp; import app.lawnchair.LawnchairAppKt; import app.lawnchair.preferences.PreferenceManager; import app.lawnchair.preferences2.PreferenceManager2; +import app.lawnchair.smartspace.DoubleShadowTextView; import app.lawnchair.smartspace.SmartspaceAppWidgetProvider; import app.lawnchair.smartspace.model.LawnchairSmartspace; import app.lawnchair.smartspace.model.SmartspaceMode; @@ -1205,12 +1206,50 @@ public class Workspace extends PagedView */ @Override public boolean onInterceptTouchEvent(MotionEvent ev) { + if (ev.getAction() == MotionEvent.ACTION_DOWN) { + View touchedView = findViewAtPosition(ev.getX(), ev.getY()); + if (touchedView instanceof ShortcutAndWidgetContainer container) { + container.onTouchEvent(ev); + return false; + } + } + if (isTrackpadMultiFingerSwipe(ev)) { return false; } return super.onInterceptTouchEvent(ev); } + private View findViewAtPosition(float x, float y) { + for (int i = 0; i < getChildCount(); i++) { + View child = getChildAt(i); + if (child instanceof CellLayout) { + CellLayout cellLayout = (CellLayout) child; + View foundView = findViewInCellLayout(cellLayout, x - child.getLeft(), y - child.getTop()); + if (foundView != null) { + return foundView; + } + } + } + return null; + } + + private View findViewInCellLayout(CellLayout cellLayout, float x, float y) { + final int count = cellLayout.getChildCount(); + for (int i = count - 1; i >= 0; i--) { + View child = cellLayout.getChildAt(i); + if (child.getVisibility() == VISIBLE && isPointInsideView(x, y, child)) { + return child; + } + } + return null; + } + + private boolean isPointInsideView(float x, float y, View view) { + return x >= view.getLeft() && x <= view.getRight() && + y >= view.getTop() && y <= view.getBottom(); + } + /** * Needed here because launcher has a fullscreen exclusion rect and doesn't * pilfer the pointers.