feat: add tilt to swipe pages for home screen rearrangement and fix app drawer gap (#7500)

* 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>
This commit is contained in:
B Vishal ChandrakanthandCopilot authored and GitHub committed 2026-11-09 20:59:50 +08:00
1 parent d180feb55f
commit ce82ac1248
9 files changed
+299 -7

No files matched your search

+1 -1
View File
@@ -34,7 +34,7 @@
<dimen name="edit_mode_page_strip_tip_margin">8dp</dimen>
<!-- qsb -->
<dimen name="all_apps_search_bar_bottom_padding">36dp</dimen>
<dimen name="all_apps_search_bar_bottom_padding">0dp</dimen>
<dimen name="all_apps_search_vertical_offset">30dp</dimen>
<dimen name="qsb_g_icon_marginStart">16dp</dimen>
<dimen name="qsb_icon_padding">10dp</dimen>
+4
View File
@@ -719,6 +719,10 @@
<!-- General and home screen settings -->
<string name="home_screen_rotation_label">Home screen rotation</string>
<string name="home_screen_rotation_description">Allow home screen rotation when the device is rotated</string>
<string name="pan_to_move_icon_label">Tilt to swipe pages</string>
<string name="pan_to_move_icon_description">Tilt your phone left or right to move between pages during rearrangement of apps/widgets</string>
<string name="pan_to_move_sensitivity_label">Tilt sensitivity</string>
<string name="pan_to_move_sensor_unavailable">Pan to move is unavailable because no motion sensor could be registered.</string>
<string name="auto_updater_label">Automatically check for updates</string>
<string name="auto_updater_description">Checks for Nightly updates when opening the About screen</string>
@@ -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)
@@ -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),
@@ -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)
}
+25
View File
@@ -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<T extends View & PageIndicator> extends PagedView<T>
private DragController.DragListener mAccessibilityDragListener;
PreferenceManager2 mPreferenceManager2;
private PanToMoveController mPanToMoveController;
PreferenceManager mPreferenceManger;
/**
@@ -364,6 +366,7 @@ public class Workspace<T extends View & PageIndicator> extends PagedView<T>
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<T extends View & PageIndicator> extends PagedView<T>
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<T extends View & PageIndicator> extends PagedView<T>
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<T extends View & PageIndicator> extends PagedView<T>
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<T extends View & PageIndicator> extends PagedView<T>
if (mAccessibilityDragListener != null) {
mAccessibilityDragListener.onDragEnd();
}
mPanToMoveController.onDragEnded();
mDragInfo = null;
mDragSourceInternal = null;
}
@@ -1672,8 +1686,12 @@ public class Workspace<T extends View & PageIndicator> extends PagedView<T>
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<T extends View & PageIndicator> extends PagedView<T>
@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<T extends View & PageIndicator> extends PagedView<T>
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;
@@ -844,12 +844,14 @@ public class ActivityAllAppsContainerView<T extends Context & ActivityContext>
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<T extends Context & ActivityContext>
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<T extends Context & ActivityContext>
}
}
/**
* 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<T extends Context & ActivityContext>
} 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());
@@ -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) {
@@ -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)
}
}