feat : initial implementation icon gesture support (#5266)

- closes : #2787
- closes : #5259
This commit is contained in:
John Andrew Camu
2025-02-17 11:45:04 +08:00
committed by GitHub
parent 0ab1dee1c6
commit 2623e42dad
10 changed files with 297 additions and 35 deletions

View File

@@ -99,6 +99,13 @@
<!-- which overlay to use by default -->
<string name="config_default_overlay" translatable="false">suck_in</string>
<!-- swipe gesture key -->
<string name="pref_key_swipe_up" translatable="false">pref_swipe_up</string>
<string name="pref_key_swipe_down" translatable="false">pref_swipe_down</string>
<string name="pref_key_swipe_right" translatable="false">pref_swipe_right</string>
<string name="pref_key_swipe_left" translatable="false">pref_swipe_left</string>
<bool name="config_default_show_hotseat">true</bool>
<bool name="config_default_always_reload_icons">true</bool>
<bool name="config_default_dark_status_bar">false</bool>

View File

@@ -466,6 +466,8 @@
<string name="gesture_swipe_down">Swipe down</string>
<string name="gesture_home_tap">Home button</string>
<string name="gesture_back_tap">Back button</string>
<string name="gesture_swipe_left">Swipe left</string>
<string name="gesture_swipe_right">Swipe right</string>
<string name="gesture_handler_no_op">Do nothing</string>
<string name="gesture_handler_sleep">Sleep</string>

View File

@@ -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

View File

@@ -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")
}
}
}
}
}

View File

@@ -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 }
}
}
}

View File

@@ -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),
)
}
}
}
}
}

View File

@@ -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<GestureHandlerConfig> {
val cmp = Converters().fromComponentKey(key)
val key = stringPreferencesKey("$cmp:${gestureType.name}")
return preferencesDataStore.data.map { prefs ->
prefs[key]?.let {
runCatching { kotlinxJson.decodeFromString<GestureHandlerConfig>(it) }
.getOrDefault(GestureHandlerConfig.NoOp)
} ?: GestureHandlerConfig.NoOp
}
}
private fun initializeIconShape(shape: IconShape) {
CustomAdaptiveIconDrawable.sInitialized = true
CustomAdaptiveIconDrawable.sMaskId = shape.getHashString()

View File

@@ -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<GestureHandlerConfig>(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,
)
},
)
}
}
}
}
}
}

View File

@@ -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;
}
}

View File

@@ -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<T extends View & PageIndicator> extends PagedView<T>
*/
@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.