Turn the app drawer into a one-column list with letter headers

PRD M2 (AD-1 to AD-3, AD-7, AD-8, HS-7). The drawer keeps Launcher3's adapter, sorting,
live updates, click, long-press menu and drag; only what differs from the grid is new.

- FlexeraDrawer (flexera/src): column count, row styling, and a letter-header adapter item
- flexera/res/layout/all_apps_icon.xml overrides Launcher3's grid cell with a horizontal
  row (46dp icon, name beside it, 64dp tall)
- AlphabeticalAppsList adds a header item at the start of each letter group and points
  the fast-scroll section at it; Private Space keeps its own framing
- BaseAllAppsAdapter creates and binds the header view type and styles rows
- DeviceProfileOverrides forces one drawer column
- DeviceProfile.getAllAppsIconStartMargin returns the row padding for a one-column
  drawer, which keeps the search bar full width
- Suggested apps row above the list is off by default

Tested on the Android 17 emulator: scrolling, tap to launch, long-press menu, drag to the
home screen, and live removal and return of an app all work. The right-edge fast
scroller did not respond to scripted drags and still needs a hand test.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
TMCandClaude Fable 5.2 committed 2026-11-11 21:25:41 +01:00
1 parent 9376781e8a
commit 76c3603693
9 files changed
+165 -2

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Flexera Launcher: an app row in the drawer list (PRD AD-2).
Overrides Launcher3's res/layout/all_apps_icon.xml, which is a grid cell with the icon above the
label. Here the icon sits at the start and the name beside it. It is still a BubbleTextView, so
click, long-press menu, drag and notification dots keep working unchanged.
Height, icon gap and text size are set in code by FlexeraDrawer.styleAppRow.
-->
<com.android.launcher3.BubbleTextView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:launcher="http://schemas.android.com/apk/res-auto"
style="@style/BaseIcon.AllApps"
android:id="@+id/icon"
android:ellipsize="end"
android:gravity="start|center_vertical"
android:paddingStart="@dimen/flexera_drawer_row_padding_start"
android:paddingEnd="@dimen/flexera_drawer_row_padding_end"
android:textAlignment="viewStart"
launcher:centerVertically="false"
launcher:iconDisplay="all_apps"
launcher:iconSizeOverride="@dimen/flexera_drawer_row_icon_size"
launcher:layoutHorizontal="true" />
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Flexera Launcher: a letter header that starts each group in the drawer list (PRD AD-3). -->
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="@dimen/flexera_drawer_header_height"
android:gravity="start|bottom"
android:importantForAccessibility="yes"
android:accessibilityHeading="true"
android:paddingStart="@dimen/flexera_drawer_row_padding_start"
android:paddingEnd="@dimen/flexera_drawer_row_padding_end"
android:paddingBottom="@dimen/flexera_drawer_header_padding_bottom"
android:textAlignment="viewStart"
android:textColor="?android:attr/textColorSecondary"
android:textSize="@dimen/flexera_drawer_header_text_size"
android:textStyle="bold" />
+3
View File
@@ -14,4 +14,7 @@
<!-- Lawnchair's own nightly updater does not apply to this app. -->
<bool name="config_default_enable_nightly_auto_updater">false</bool>
<!-- The drawer is a plain alphabetical list (PRD AD-2), so no row of suggested apps above it. -->
<bool name="config_default_show_suggested_apps_at_drawer_top">false</bool>
</resources>
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Drawer list (PRD AD-2, AD-3). Rows are sized for thumb taps; 48dp is Android's minimum target. -->
<dimen name="flexera_drawer_row_height">64dp</dimen>
<!-- Smaller than a home-screen icon, so rows have air between them. -->
<dimen name="flexera_drawer_row_icon_size">46dp</dimen>
<dimen name="flexera_drawer_row_icon_gap">20dp</dimen>
<dimen name="flexera_drawer_row_text_size">17sp</dimen>
<dimen name="flexera_drawer_row_padding_start">24dp</dimen>
<!-- Leaves the screen edge free for the scroller now and the wave alphabet later (M3). -->
<dimen name="flexera_drawer_row_padding_end">56dp</dimen>
<dimen name="flexera_drawer_header_height">44dp</dimen>
<dimen name="flexera_drawer_header_padding_bottom">6dp</dimen>
<dimen name="flexera_drawer_header_text_size">14sp</dimen>
</resources>
@@ -0,0 +1,77 @@
package app.flexera.drawer
import android.content.Context
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import com.android.launcher3.BubbleTextView
import com.android.launcher3.R
import com.android.launcher3.allapps.BaseAllAppsAdapter.AdapterItem
/**
* Flexera Launcher's app drawer: a single-column alphabetical list with letter headers
* (PRD AD-2 and AD-3), built on Launcher3's all-apps adapter rather than replacing it.
*
* Launcher3 keeps doing the work it already does well: loading apps, sorting them, live updates,
* click, long-press menu and drag. This object supplies only what differs from the stock grid:
* - the column count ([COLUMNS]),
* - the look of a row ([styleAppRow]; the row layout itself is flexera/res/layout/all_apps_icon.xml,
* which overrides Launcher3's layout of the same name), and
* - a letter header row ([VIEW_TYPE_SECTION_HEADER] and the functions that create and bind it).
*/
object FlexeraDrawer {
/** The drawer is one column wide. */
const val COLUMNS = 1
/**
* Adapter view type of a letter header row. Launcher3 uses bits 1 to 10 and Lawnchair's search
* results use bits 10 to 22, so this stays clear of both.
*/
const val VIEW_TYPE_SECTION_HEADER = 1 shl 24
/** Creates the adapter item for the header that starts the section named [sectionName]. */
@JvmStatic
fun sectionHeader(sectionName: String): AdapterItem = SectionHeaderItem(sectionName)
@JvmStatic
fun createSectionHeader(inflater: LayoutInflater, parent: ViewGroup): View =
inflater.inflate(R.layout.flexera_all_apps_section_header, parent, false)
@JvmStatic
fun bindSectionHeader(view: View, item: AdapterItem) {
(view as TextView).text = (item as? SectionHeaderItem)?.sectionName.orEmpty()
}
/**
* Finishes turning a freshly inflated all-apps icon into a list row. Launcher3 sizes the view
* as a grid cell (icon above a small label); a row needs its own height, a wider gap between
* icon and name, and a label large enough to read in a list.
*/
@JvmStatic
fun styleAppRow(icon: BubbleTextView) {
val res = icon.resources
icon.layoutParams.height = res.getDimensionPixelSize(R.dimen.flexera_drawer_row_height)
icon.compoundDrawablePadding = res.getDimensionPixelSize(R.dimen.flexera_drawer_row_icon_gap)
icon.setTextSize(TypedValue.COMPLEX_UNIT_PX, res.getDimension(R.dimen.flexera_drawer_row_text_size))
}
/**
* How far a row's icon sits from the edge of the list. Launcher3 uses its grid version of
* this number to line the search bar up with the first icon, so the list supplies its own.
*/
@JvmStatic
fun rowStartPadding(context: Context): Int =
context.resources.getDimensionPixelSize(R.dimen.flexera_drawer_row_padding_start)
/**
* A letter header. It carries its letter so that the adapter's diffing notices when the
* header at a position changes, for example after an app is installed or removed.
*/
class SectionHeaderItem(val sectionName: String) : AdapterItem(VIEW_TYPE_SECTION_HEADER) {
override fun isContentSame(other: AdapterItem): Boolean =
other is SectionHeaderItem && other.sectionName == sectionName
}
}
@@ -158,8 +158,9 @@ class DeviceProfileOverrides @Inject constructor(
fun applyUi(idp: InvariantDeviceProfile) {
// apply grid size
idp.numAllAppsColumns = numAllAppsColumns
idp.numDatabaseAllAppsColumns = numAllAppsColumns
// Flexera Launcher: the drawer is a one-column list, whatever the column preference says.
idp.numAllAppsColumns = app.flexera.drawer.FlexeraDrawer.COLUMNS
idp.numDatabaseAllAppsColumns = app.flexera.drawer.FlexeraDrawer.COLUMNS
idp.numFolderRows[INDEX_DEFAULT] = numFolderRows
idp.numFolderColumns[INDEX_DEFAULT] = numFolderColumns
@@ -1979,6 +1979,14 @@ public class DeviceProfile {
/** The margin between the edge of all apps and the edge of the first icon. */
public int getAllAppsIconStartMargin(Context context) {
// Flexera Launcher: in the one-column drawer list the first icon sits at the row's start
// padding. The grid maths below would instead centre it in a full-width cell, which
// squeezed the search bar down to a small pill.
if (numShownAllAppsColumns == app.flexera.drawer.FlexeraDrawer.COLUMNS) {
return (Utilities.isRtl(context.getResources()) ? allAppsPadding.right
: allAppsPadding.left)
+ app.flexera.drawer.FlexeraDrawer.rowStartPadding(context);
}
int allAppsSpacing;
if (isVerticalBarLayout()) {
// On phones, the landscape layout uses a different setup.
@@ -491,6 +491,17 @@ public class AlphabeticalAppsList<T extends Context & ActivityContext> implement
Log.d(TAG, "Adding apps with sections. HasPrivateApps: " + hasPrivateApps);
for (int i = 0; i < appList.size(); i++) {
AppInfo info = appList.get(i);
// Flexera Launcher: start each letter group with a header row (PRD AD-3). The header
// takes this position, so the fast-scroll section created below lands on the header.
// Private Space keeps Launcher3's own framing and gets no headers.
if (!hasPrivateApps && !info.sectionName.equals(lastSectionName)) {
mAdapterItems.add(app.flexera.drawer.FlexeraDrawer.sectionHeader(info.sectionName));
Log.d(TAG, "addAppsWithSections: adding sectionName: " + info.sectionName
+ " with header at position: " + position);
lastSectionName = info.sectionName;
mFastScrollerSections.add(new FastScrollSectionInfo(info.sectionName, position));
position++;
}
// Apply decorator to private apps.
if (hasPrivateApps) {
mAdapterItems.add(AdapterItem.asAppWithDecorationInfo(info,
@@ -243,7 +243,13 @@ public abstract class BaseAllAppsAdapter<T extends Context & ActivityContext> ex
// Ensure the all apps icon height matches the workspace icons in portrait mode.
icon.getLayoutParams().height =
mActivityContext.getDeviceProfile().getAllAppsProfile().getCellHeightPx();
// Flexera Launcher: the drawer is a list, so size and style this as a row.
app.flexera.drawer.FlexeraDrawer.styleAppRow(icon);
return new ViewHolder(icon);
case app.flexera.drawer.FlexeraDrawer.VIEW_TYPE_SECTION_HEADER:
// Flexera Launcher: letter header that starts each group in the drawer list.
return new ViewHolder(app.flexera.drawer.FlexeraDrawer.createSectionHeader(
mLayoutInflater, parent));
case VIEW_TYPE_EMPTY_SEARCH:
return new ViewHolder(mLayoutInflater.inflate(R.layout.all_apps_empty_search,
parent, false));
@@ -325,6 +331,11 @@ public abstract class BaseAllAppsAdapter<T extends Context & ActivityContext> ex
}
break;
}
case app.flexera.drawer.FlexeraDrawer.VIEW_TYPE_SECTION_HEADER:
// Flexera Launcher: letter header in the drawer list.
app.flexera.drawer.FlexeraDrawer.bindSectionHeader(
holder.itemView, mApps.getAdapterItems().get(position));
break;
case VIEW_TYPE_EMPTY_SEARCH: {
AppInfo info = mApps.getAdapterItems().get(position).itemInfo;
if (info != null) {