From ce82ac124869eab724e14eab9d3ef9fa8c1ae06a Mon Sep 17 00:00:00 2001 From: B Vishal Chandrakanth <88307957+Longbatman09@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:29:50 +0530 Subject: [PATCH] feat: add tilt to swipe pages for home screen rearrangement and fix app drawer gap (#7500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat and fix : Add Tilt to swipe pages setting Replace the multi-level Motion controls with a single Home screen toggle named “Tilt to swipe pages,” positioned below Infinite scrolling. Update the description to explain that tilting the phone moves between pages while rearranging apps or widgets, and make the pan controller use the toggle directly while retaining the existing preference key for compatibility. Fix for "[BUG] Large empty gap between app drawer search bar and app grid when app suggestions are disabled #7490" * EDITS : Coderabbit suggested edits for PR #7500 * Coderabbit suggested changes (2) #7500 * Update gradle-daemon-jvm.properties * Remove unrelated Gradle toolchain changes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add docstrings for pan-to-move and all apps header changes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add a tilt sensitivity control --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- lawnchair/res/values/dimens.xml | 2 +- lawnchair/res/values/strings.xml | 4 + .../preferences/PreferenceManager.kt | 9 + .../destinations/HomeScreenPreferences.kt | 32 ++++ .../lawnchair/gestures/PanToMoveController.kt | 169 ++++++++++++++++++ src/com/android/launcher3/Workspace.java | 25 +++ .../allapps/ActivityAllAppsContainerView.java | 23 ++- .../launcher3/allapps/FloatingHeaderView.java | 12 ++ .../gestures/PanToMoveControllerTest.kt | 30 ++++ 9 files changed, 299 insertions(+), 7 deletions(-) create mode 100644 src/app/lawnchair/gestures/PanToMoveController.kt create mode 100644 tests/multivalentTests/src/app/lawnchair/gestures/PanToMoveControllerTest.kt diff --git a/lawnchair/res/values/dimens.xml b/lawnchair/res/values/dimens.xml index 32e3bbba57..49de50a614 100644 --- a/lawnchair/res/values/dimens.xml +++ b/lawnchair/res/values/dimens.xml @@ -34,7 +34,7 @@ 8dp - 36dp + 0dp 30dp 16dp 10dp diff --git a/lawnchair/res/values/strings.xml b/lawnchair/res/values/strings.xml index 85342e735e..da40cfb980 100644 --- a/lawnchair/res/values/strings.xml +++ b/lawnchair/res/values/strings.xml @@ -719,6 +719,10 @@ Home screen rotation Allow home screen rotation when the device is rotated + Tilt to swipe pages + Tilt your phone left or right to move between pages during rearrangement of apps/widgets + Tilt sensitivity + Pan to move is unavailable because no motion sensor could be registered. Automatically check for updates Checks for Nightly updates when opening the About screen diff --git a/lawnchair/src/app/lawnchair/preferences/PreferenceManager.kt b/lawnchair/src/app/lawnchair/preferences/PreferenceManager.kt index e51f233340..5e7eda167d 100644 --- a/lawnchair/src/app/lawnchair/preferences/PreferenceManager.kt +++ b/lawnchair/src/app/lawnchair/preferences/PreferenceManager.kt @@ -73,6 +73,15 @@ class PreferenceManager @Inject constructor( val iconPackPackage = StringPref("pref_iconPackPackage", "", reloadIcons) val themedIconPackPackage = StringPref("pref_themedIconPackPackage", "", reloadIcons) val allowRotation = BoolPref("pref_allowRotation", false) + + /** Enables motion-driven interactions that use the device's motion sensors. */ + val motionEnabled = BoolPref("pref_motionEnabled", false) + + /** Enables tilt-to-page gestures while rearranging home-screen items. */ + val panToMoveEnabled = BoolPref("pref_panToMoveEnabled", false) + + /** Controls how far a motion reading must move toward the gesture threshold. */ + val panToMoveSensitivity = FloatPref("pref_panToMoveSensitivity", 0.5f) val wrapAdaptiveIcons = BoolPref("prefs_wrapAdaptive", true) val transparentIconBackground = BoolPref("prefs_transparentIconBackground", false) val shadowBGIcons = BoolPref("pref_shadowBGIcons", true) diff --git a/lawnchair/src/app/lawnchair/ui/preferences/destinations/HomeScreenPreferences.kt b/lawnchair/src/app/lawnchair/ui/preferences/destinations/HomeScreenPreferences.kt index 6bfb43d376..875c987381 100644 --- a/lawnchair/src/app/lawnchair/ui/preferences/destinations/HomeScreenPreferences.kt +++ b/lawnchair/src/app/lawnchair/ui/preferences/destinations/HomeScreenPreferences.kt @@ -17,6 +17,8 @@ package app.lawnchair.ui.preferences.destinations import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorManager import android.widget.Toast import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -59,6 +61,9 @@ object HomeScreenRoutes { const val POPUP_EDITOR = "popup_editor" } +/** + * Shows home-screen settings and exposes tilt-to-page only when a motion sensor is available. + */ @Composable fun HomeScreenPreferences( modifier: Modifier = Modifier, @@ -67,6 +72,15 @@ fun HomeScreenPreferences( val prefs2 = preferenceManager2() val scope = rememberCoroutineScope() val context = LocalContext.current + val hasMotionSensor = remember { + context.getSystemService(SensorManager::class.java) + ?.let { manager -> + manager.getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null || + manager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null + } == true + } + val panToMoveEnabledAdapter = prefs.panToMoveEnabled.getAdapter() + val panToMoveSensitivityAdapter = prefs.panToMoveSensitivity.getAdapter() PreferenceLayout( label = stringResource(id = R.string.home_screen_label), backArrowVisible = !LocalIsExpandedScreen.current, @@ -101,6 +115,24 @@ fun HomeScreenPreferences( description = stringResource(id = R.string.infinite_scrolling_description), ) } + if (hasMotionSensor) { + PreferenceGroup { + SwitchPreference( + adapter = panToMoveEnabledAdapter, + label = stringResource(id = R.string.pan_to_move_icon_label), + description = stringResource(id = R.string.pan_to_move_icon_description), + ) + ExpandAndShrink(visible = panToMoveEnabledAdapter.state.value) { + SliderPreference( + adapter = panToMoveSensitivityAdapter, + label = stringResource(id = R.string.pan_to_move_sensitivity_label), + valueRange = 0f..1f, + step = 0.05f, + showAsPercentage = true, + ) + } + } + } PreferenceGroup(heading = stringResource(id = R.string.home_screen_actions)) { ClickablePreference( label = stringResource(id = R.string.remove_all_views_from_home_screen), diff --git a/src/app/lawnchair/gestures/PanToMoveController.kt b/src/app/lawnchair/gestures/PanToMoveController.kt new file mode 100644 index 0000000000..ea5c333544 --- /dev/null +++ b/src/app/lawnchair/gestures/PanToMoveController.kt @@ -0,0 +1,169 @@ +/* + * Copyright 2026, Lawnchair + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ + +package app.lawnchair.gestures + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.os.SystemClock +import android.view.Surface +import android.widget.Toast +import com.android.launcher3.PagedView +import com.android.launcher3.R +import app.lawnchair.preferences.PreferenceManager +import kotlin.math.abs + +/** + * Converts deliberate motion while dragging into one workspace page change at a time. + * + * Gyroscope input detects angular-velocity flicks, while accelerometer input calibrates a + * drag-start tilt baseline and rearms after the device returns near that baseline. + */ +class PanToMoveController( + private val context: Context, + private val pager: PagedView<*>, +) : SensorEventListener { + + private val preferences = PreferenceManager.getInstance(context) + private val sensorManager = context.getSystemService(SensorManager::class.java) + private val gyroscope = sensorManager?.getDefaultSensor(Sensor.TYPE_GYROSCOPE) + private val accelerometer = sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) + private val sensor = gyroscope ?: accelerometer + + private var filteredRate = 0f + private var baselineTilt: Float? = null + private var tiltTriggered = false + private var lastPageChangeMs = 0L + + /** Returns whether a motion sensor and manager are available for this controller. */ + val isSupported: Boolean + get() = sensor != null && sensorManager != null + + /** + * Resets motion state and registers the preferred sensor for the duration of a drag. + * + * A gyroscope is preferred; devices without one use the accelerometer fallback and calibrate + * its neutral tilt from the first reading. + */ + fun onDragStarted() { + if (!preferences.panToMoveEnabled.get()) return + val manager = sensorManager ?: run { + Toast.makeText(context, R.string.pan_to_move_sensor_unavailable, Toast.LENGTH_SHORT).show() + return + } + + filteredRate = 0f + baselineTilt = null + tiltTriggered = false + lastPageChangeMs = 0L + manager.unregisterListener(this) + + val registered = gyroscope?.let { + manager.registerListener(this, it, SensorManager.SENSOR_DELAY_GAME) + } == true || accelerometer?.let { + manager.registerListener(this, it, SensorManager.SENSOR_DELAY_GAME) + } == true + if (!registered) { + Toast.makeText(context, R.string.pan_to_move_sensor_unavailable, Toast.LENGTH_SHORT).show() + } + } + + /** Unregisters motion updates so sensor callbacks do not outlive the drag. */ + fun onDragEnded() { + sensorManager?.unregisterListener(this) + } + + /** + * Filters a screen-relative motion reading and pages left or right after crossing a + * sensitivity-adjusted threshold, enforcing a cooldown and accelerometer rearm. + */ + override fun onSensorChanged(event: SensorEvent) { + if (!preferences.panToMoveEnabled.get()) { + onDragEnded() + return + } + + val isGyroscope = event.sensor.type == Sensor.TYPE_GYROSCOPE + val sensitivity = preferences.panToMoveSensitivity.get().coerceIn(0f, 1f) + val reading = screenRelativeReading(event, isGyroscope) ?: return + if (isGyroscope) { + // Gyroscope values are angular velocity. A flick is brief, so use a responsive + // filter rather than smoothing it over several sensor frames. + filteredRate = FILTER_WEIGHT * filteredRate + (1f - FILTER_WEIGHT) * reading + } else { + // Accelerometer fallback for devices without a gyroscope. Calibrate the neutral + // position at drag start, then detect a deliberate change in tilt. + val tilt = reading / SensorManager.GRAVITY_EARTH + if (baselineTilt == null) { + baselineTilt = tilt + filteredRate = 0f + return + } + val tiltDelta = tilt - baselineTilt!! + if (abs(tiltDelta) < TILT_REARM_THRESHOLD) { + tiltTriggered = false + } + filteredRate = FILTER_WEIGHT * filteredRate + (1f - FILTER_WEIGHT) * tiltDelta + } + val threshold = thresholdForSensitivity(sensitivity, isGyroscope) + if (abs(filteredRate) < threshold) return + if (!isGyroscope && tiltTriggered) return + + val now = SystemClock.elapsedRealtime() + if (now - lastPageChangeMs < PAGE_CHANGE_COOLDOWN_MS) return + + val moved = if (filteredRate > 0f) { + pager.scrollLeft() + } else { + pager.scrollRight() + } + if (moved) { + lastPageChangeMs = now + filteredRate = 0f + if (!isGyroscope) tiltTriggered = true + } + } + + /** Accuracy changes do not alter the calibrated gesture state. */ + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit + + /** + * Maps sensor axes into the current display orientation and returns the signed gesture axis. + */ + private fun screenRelativeReading(event: SensorEvent, isGyroscope: Boolean): Float? { + val x = event.values[0] + val y = event.values[1] + return when (pager.display?.rotation ?: return null) { + Surface.ROTATION_90 -> if (isGyroscope) -x else y + Surface.ROTATION_180 -> if (isGyroscope) -y else -x + Surface.ROTATION_270 -> if (isGyroscope) x else -y + else -> if (isGyroscope) y else x + } + } + + private companion object { + const val FILTER_WEIGHT = 0.35f + const val MAX_RATE_THRESHOLD = 1.5f + const val MIN_RATE_THRESHOLD = 0.35f + const val MAX_TILT_THRESHOLD = 0.5f + const val MIN_TILT_THRESHOLD = 0.2f + const val TILT_REARM_THRESHOLD = 0.1f + const val PAGE_CHANGE_COOLDOWN_MS = 700L + } +} + +internal fun thresholdForSensitivity(sensitivity: Float, isGyroscope: Boolean): Float { + val start = if (isGyroscope) 1.5f else 0.5f + val end = if (isGyroscope) 0.35f else 0.2f + return start + (end - start) * sensitivity.coerceIn(0f, 1f) +} diff --git a/src/com/android/launcher3/Workspace.java b/src/com/android/launcher3/Workspace.java index 1c018b979b..343c943988 100644 --- a/src/com/android/launcher3/Workspace.java +++ b/src/com/android/launcher3/Workspace.java @@ -147,6 +147,7 @@ import java.util.function.Predicate; import java.util.stream.Collectors; import app.lawnchair.hotseat.HotseatPagedView; +import app.lawnchair.gestures.PanToMoveController; import app.lawnchair.preferences2.PreferenceCacheExtensionsKt; import static app.lawnchair.util.LawnchairUtilsKt.toBitmap; import app.lawnchair.LawnchairApp; @@ -338,6 +339,7 @@ public class Workspace extends PagedView private DragController.DragListener mAccessibilityDragListener; PreferenceManager2 mPreferenceManager2; + private PanToMoveController mPanToMoveController; PreferenceManager mPreferenceManger; /** @@ -364,6 +366,7 @@ public class Workspace extends PagedView mWallpaperManager = WallpaperManager.getInstance(context); mAllAppsIconSize = mLauncher.getDeviceProfile().getAllAppsProfile().getIconSizePx(); mPreferenceManager2 = PreferenceManager2.getInstance(context); + mPanToMoveController = new PanToMoveController(context, this); mPreferenceManger = PreferenceManager.getInstance(context); mWallpaperOffset = new WallpaperOffsetInterpolator(this); @@ -509,6 +512,10 @@ public class Workspace extends PagedView return r; } + /** + * Starts workspace drag handling and motion sensing, except for accessibility drags confined + * to a folder. + */ @Override public void onDragStart(DragObject dragObject, DragOptions options) { if (ENFORCE_DRAG_EVENT_ORDER) { @@ -557,6 +564,9 @@ public class Workspace extends PagedView if (mAccessibilityDragListener != null) { mAccessibilityDragListener.onDragStart(dragObject, options); } + if (!(options.isAccessibleDrag && dragObject.dragSource instanceof Folder)) { + mPanToMoveController.onDragStarted(); + } if (!mLauncher.isInState(EDIT_MODE)) { mLauncher.getStateManager().goToState(SPRING_LOADED); } @@ -573,6 +583,9 @@ public class Workspace extends PagedView mDeferRemoveExtraEmptyScreen = true; } + /** + * Ends workspace drag handling and unregisters motion sensors before clearing drag state. + */ @Override public void onDragEnd() { if (ENFORCE_DRAG_EVENT_ORDER) { @@ -596,6 +609,7 @@ public class Workspace extends PagedView if (mAccessibilityDragListener != null) { mAccessibilityDragListener.onDragEnd(); } + mPanToMoveController.onDragEnded(); mDragInfo = null; mDragSourceInternal = null; } @@ -1672,8 +1686,12 @@ public class Workspace extends PagedView mLauncher.getStateManager().addStateListener(mAccessibilityDropListener); } + /** + * Detaches the workspace and ensures motion sensors are unregistered with the view. + */ protected void onDetachedFromWindow() { super.onDetachedFromWindow(); + mPanToMoveController.onDragEnded(); mWallpaperOffset.setWindowToken(null); mLauncher.getStateManager().removeStateListener(mAccessibilityDropListener); } @@ -2245,8 +2263,12 @@ public class Workspace extends PagedView @Override public void prepareAccessibilityDrop() {} + /** + * Stops motion sensing before processing the final drop on the workspace. + */ @Override public void onDrop(final DragObject d, DragOptions options) { + mPanToMoveController.onDragEnded(); mDragViewVisualCenter = d.getVisualCenter(mDragViewVisualCenter); CellLayout dropTargetLayout = mDropToLayout; @@ -2841,6 +2863,9 @@ public class Workspace extends PagedView if (layout == null) { // Check visible pages IntSet visiblePageIndices = getVisiblePageIndices(); + if (isPageInTransition()) { + visiblePageIndices.add(getNextPage()); + } for (int visiblePageIndex : visiblePageIndices) { layout = verifyInsidePage(visiblePageIndex, d.x, d.y); if (layout != null) break; diff --git a/src/com/android/launcher3/allapps/ActivityAllAppsContainerView.java b/src/com/android/launcher3/allapps/ActivityAllAppsContainerView.java index 4cf79facb5..22b8008b20 100644 --- a/src/com/android/launcher3/allapps/ActivityAllAppsContainerView.java +++ b/src/com/android/launcher3/allapps/ActivityAllAppsContainerView.java @@ -844,12 +844,14 @@ public class ActivityAllAppsContainerView updateSearchResultsVisibility(); } + /** + * Sets up header rows and hides the header shell when neither tabs nor visible row content + * requires it, keeping adapter top padding consistent with that visibility. + */ void setupHeader() { mAdditionalHeaderRows.forEach(row -> mHeader.onPluginDisconnected(row)); boolean hideSearchBar = isAppDrawerSearchBarHidden(); - // Keep personal/work tabs visible when search is off; only hide the empty header shell. - mHeader.setVisibility((hideSearchBar && !mUsingTabs) ? View.GONE : View.VISIBLE); boolean tabsHidden = !mUsingTabs; mHeader.setup( mAH.get(AdapterHolder.MAIN).mRecyclerView, @@ -858,7 +860,11 @@ public class ActivityAllAppsContainerView getCurrentPage(), tabsHidden); - int padding = (hideSearchBar && !mUsingTabs) ? 0 : mHeader.getMaxTranslation(); + // Keep personal/work tabs or visible rows active; hide the empty header shell. + boolean hasHeaderContent = mUsingTabs || (!hideSearchBar && mHeader.hasVisibleContent()); + mHeader.setVisibility(hasHeaderContent ? View.VISIBLE : View.GONE); + + int padding = hasHeaderContent ? mHeader.getMaxTranslation() : 0; mAH.forEach(adapterHolder -> { adapterHolder.mPadding.top = padding; adapterHolder.applyPadding(); @@ -1442,6 +1448,10 @@ public class ActivityAllAppsContainerView } } + /** + * Shows search results without the header, or restores the header only when tabs or visible + * header rows provide content. + */ protected void updateSearchResultsVisibility() { if (isSearching()) { getSearchRecyclerView().setVisibility(VISIBLE); @@ -1450,9 +1460,10 @@ public class ActivityAllAppsContainerView } else { getSearchRecyclerView().setVisibility(GONE); getAppsRecyclerViewContainer().setVisibility(VISIBLE); - // Keep the empty header shell hidden when search is off and there are no tabs. - mHeader.setVisibility( - (isAppDrawerSearchBarHidden() && !mUsingTabs) ? View.GONE : View.VISIBLE); + // Keep personal/work tabs or visible rows active; hide the empty header shell. + boolean hasHeaderContent = mUsingTabs + || (!isAppDrawerSearchBarHidden() && mHeader.hasVisibleContent()); + mHeader.setVisibility(hasHeaderContent ? View.VISIBLE : View.GONE); } if (mHeader.isSetUp()) { mHeader.setActiveRV(getCurrentPage()); diff --git a/src/com/android/launcher3/allapps/FloatingHeaderView.java b/src/com/android/launcher3/allapps/FloatingHeaderView.java index 5dee5c4eb1..1a6fdd8407 100644 --- a/src/com/android/launcher3/allapps/FloatingHeaderView.java +++ b/src/com/android/launcher3/allapps/FloatingHeaderView.java @@ -243,6 +243,18 @@ public class FloatingHeaderView extends LinearLayout implements return null; } + /** + * Reports whether any header row both has content and is currently visible. + */ + public boolean hasVisibleContent() { + for (FloatingHeaderRow row : mAllRows) { + if (row.hasVisibleContent() && row.shouldDraw()) { + return true; + } + } + return false; + } + void setup(AllAppsRecyclerView mainRV, AllAppsRecyclerView workRV, SearchRecyclerView searchRV, int activeRV, boolean tabsHidden) { for (FloatingHeaderRow row : mAllRows) { diff --git a/tests/multivalentTests/src/app/lawnchair/gestures/PanToMoveControllerTest.kt b/tests/multivalentTests/src/app/lawnchair/gestures/PanToMoveControllerTest.kt new file mode 100644 index 0000000000..fe482c60cd --- /dev/null +++ b/tests/multivalentTests/src/app/lawnchair/gestures/PanToMoveControllerTest.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2026, Lawnchair + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ + +package app.lawnchair.gestures + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class PanToMoveControllerTest { + + @Test + fun tiltThreshold_decreasesAsSensitivityIncreases() { + assertThat(thresholdForSensitivity(0f, isGyroscope = false)).isEqualTo(0.5f) + assertThat(thresholdForSensitivity(0.5f, isGyroscope = false)).isEqualTo(0.35f) + assertThat(thresholdForSensitivity(1f, isGyroscope = false)).isEqualTo(0.2f) + } + + @Test + fun tiltThreshold_clampsSensitivityToSupportedRange() { + assertThat(thresholdForSensitivity(-1f, isGyroscope = false)).isEqualTo(0.5f) + assertThat(thresholdForSensitivity(2f, isGyroscope = false)).isEqualTo(0.2f) + } +}