We're building ReClaim, a minimalist Android launcher that's on Google Play. The Home screen holds a handful of favorites. Everything else lives in one place: a text-only, alphabetical app drawer with a search field, a strip of folders, a "Recently Installed" section, and an A–Z scrubber down the right edge.

No icons is a product decision, not a shortcut. The drawer has to reach every installed app without making the distracting ones look appealing (we wrote about the thinking behind that in designing a minimal Android home screen). But once the icons are gone, the list has to be easy to move through, or people just stop using it. This post is about the small pieces that make a plain list fast: the scrubber, the section math, search, and the places where our code is still wrong.

Stack: Kotlin, Jetpack Compose, Hilt, Room, minSdk 26, targetSdk 36. Snippets are trimmed from the real code; // ... marks cuts. Our first post on building the launcher covered how we get the app list from LauncherApps and keep it fresh with a callbackFlow. This one starts where that list arrives in the UI.

1. One sorted list feeds everything

The repository combines the raw LauncherApps list with the user's own data (custom names, hidden apps, favorites, folders, active blocks) and emits one sorted list:

val allApps: Flow<List<AppInfo>> = combine( baseApps, favoritesDao.observeAll(), folderDao.observeFolderApps(), blockDao.observeAll(), ) { apps, favs, folderApps, blocks -> // ... build favKeys, folderMap, blockMap apps.map { a -> a.copy( isFavorite = a.key in favKeys, folderId = folderMap[a.key]?.folderId, blockedUntil = blockMap[a.packageName], ) }.sortedBy { it.label.lowercase() } }.flowOn(Dispatchers.Default) val visibleApps: Flow<List<AppInfo>> = allApps.map { list -> list.filterNot { it.isHidden } } 

Two details matter for the drawer. First, label is already the user's custom name if they renamed the app, so sorting, search and the letter index all agree on the same string. Second, hidden apps are filtered out here, before the UI sees anything. The drawer never has to know an app exists if the user hid it.

The sort key is label.lowercase(). Kotlin's String.lowercase() uses "Unicode mapping rules of the invariant locale", so the result doesn't change with the device language. That's predictable, but it isn't how people sort names. More on that in the open items.

2. The letter index is just "first index per bucket"

The scrubber needs to know where each letter starts. Since the list is already sorted, that's one pass:

/** Maps each first letter present in [apps] (A–Z, or '#' for anything else) to its first index. */ private fun buildLetterPositions(apps: List<AppInfo>): Map<Char, Int> { val map = LinkedHashMap<Char, Int>() apps.forEachIndexed { index, app -> val first = app.label.trim().firstOrNull()?.uppercaseChar() val bucket = if (first != null && first in 'A'..'Z') first else '#' if (bucket !in map) map[bucket] = index } return map } 

Anything that doesn't start with A–Z goes into #: digits, symbols, accented letters, and non-Latin scripts. The map's keys double as the set of letters that actually have apps, which the scrubber uses to dim the empty ones.

In the screen, it's built from the filtered list, so it stays right while you search:

val filtered = remember(apps, query) { if (query.isBlank()) apps else apps.filter { it.label.contains(query.trim(), ignoreCase = true) } } val letterPositions = remember(filtered) { buildLetterPositions(filtered) } 

3. The scrubber: one gesture loop, drag included

LazyColumn has no built-in fast-scroll index, so AlphabetIndexBar is a 28dp-wide Column of 27 letters with a pointerInput on top. Touch and drag are the same code path: wherever the finger is, map its Y position to a letter.

.onSizeChanged { containerHeightPx = it.height.toFloat() } .pointerInput(availableLetters) { fun handle(y: Float) { if (containerHeightPx <= 0f) return val raw = (y / containerHeightPx * letters.size).toInt().coerceIn(0, letters.size - 1) activeIndex = raw nearestAvailableLetter(letters[raw], letters, availableLetters)?.let(onSelect) } awaitEachGesture { val down = awaitFirstDown() handle(down.position.y) do { val event = awaitPointerEvent() val change = event.changes.firstOrNull { it.id == down.id } if (change != null && change.pressed) { handle(change.position.y) change.consume() } } while (event.changes.any { it.pressed }) activeIndex = null } } 

The Compose docs on understanding gestures recommend awaitEachGesture over a raw while (true) loop, because it "restarts the containing block when all pointers have been lifted." That's exactly the lifecycle we want: the bubble shows on down, follows the finger, and activeIndex = null hides it when the gesture ends.

We track the first pointer by id, so a second finger landing on the strip doesn't make the list jump around. And we consume the change, so the list underneath doesn't also try to scroll.

Two small choices make the strip feel solid:

Empty letters still work. If you touch "Q" and you have no Q apps, the strip doesn't go dead. It jumps to the nearest letter that does have apps, checking one step forward, then one step back, then widening:

private fun nearestAvailableLetter(letter: Char, all: List<Char>, available: Set<Char>): Char? { if (available.isEmpty()) return null if (letter in available) return letter val index = all.indexOf(letter) var offset = 1 while (index - offset >= 0 || index + offset < all.size) { if (index + offset < all.size && all[index + offset] in available) return all[index + offset] if (index - offset >= 0 && all[index - offset] in available) return all[index - offset] offset++ } return null } 

The bubble still shows the letter you're touching, not the one you landed on. That keeps the bubble in step with your finger, while the list goes as close as it can.

Landscape doesn't overlap. A short window can't fit 27 lines of 11sp text. Instead of letting the letters run into each other, the font scales down to fit the slot:

val letterSize = if (containerHeightPx <= 0f) { 11.sp } else { val fitted = with(density) { (containerHeightPx / letters.size * 0.72f).toSp() } if (fitted < 11.sp) fitted else 11.sp } 

The jump itself is a plain scrollToItem. The Compose lists guide describes it as a function that "immediately" snaps the scroll position, versus animateScrollToItem for a smooth scroll. While dragging across 27 letters, snapping is what you want. An animation would still be running toward "C" when your finger is already on "M".

4. The section above the list breaks the math

Then we added "Recently Installed": apps installed in the last 14 days, newest first, shown above the alphabetical list. It ages out on its own, so there's nothing to dismiss.

val recentlyInstalled = remember(apps, query) { if (query.isNotBlank()) { emptyList() } else { val cutoff = System.currentTimeMillis() - RECENTLY_INSTALLED_WINDOW_MILLIS apps.filter { it.firstInstallTime >= cutoff }.sortedByDescending { it.firstInstallTime } } } 

It hides while you search, because a name match is what you asked for, not recency.

The catch: the letter positions are indices into filtered, but the LazyColumn now has a header and N recent rows above the first alphabetical row. Tap "M" and you'd land N+1 rows early. The fix is an offset applied at the jump:

val letterIndexOffset = if (recentlyInstalled.isEmpty()) 0 else recentlyInstalled.size + 1 AlphabetIndexBar( availableLetters = letterPositions.keys, onSelect = { letter -> letterPositions[letter]?.let { index -> coroutineScope.launch { listState.scrollToItem(index + letterIndexOffset) } } }, // ... ) 

A recently installed app now appears twice: once in the recent section and once in its alphabetical spot. LazyColumn item keys must be unique, so the recent copy gets a prefix:

items(recentlyInstalled, key = { "recent:${it.key}" }) { app -> AppRow(/* ... */) } items(filtered, key = { it.key }) { app -> AppRow(/* ... */) } 

Without stable keys, the guide notes, "each item's state is keyed against the position of the item in the list," which goes wrong as soon as the list changes. In a launcher it changes all the time: installs, uninstalls, renames, hides.

firstInstallTime comes from LauncherActivityInfo, with a fallback to PackageManager.getPackageInfo() on older devices. More on that fallback below.

5. Search: two entry points, one rule

There are two search boxes. One sits at the top of the drawer and filters the list in place. The other is a full-screen overlay you open by swiping up on Home, with the keyboard already up. Both use the same rule, a case-insensitive contains on the label:

val searchResults: StateFlow<List<AppInfo>> = combine(appRepository.visibleApps, _query) { apps, q -> if (q.isBlank()) emptyList() else apps.filter { it.label.contains(q.trim(), ignoreCase = true) }.take(60) } // ... 

Both wire the keyboard's Search action to "launch the first result," so typing "map" and hitting enter opens Maps:

keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardActions = KeyboardActions(onSearch = { filtered.firstOrNull()?.let(onLaunch) }), 

And both search visibleApps, so hidden apps don't come back through search. If hiding an app were undone by typing its name, it wouldn't be much of a hide. (Opening a hidden app on purpose goes through its own challenge flow, which we covered in an earlier post on unlock challenges.)

When a query is active, the folder row and the recent section disappear, so the results are just names.

6. Folders that reorder by long-press

Folders sit in a horizontal row above the list. Reordering uses the Reorderable library on a LazyRow. We keep a local copy of the list so the drag feels instant, and only write the new order to Room when the drag stops:

var items by remember { mutableStateOf(folders) } LaunchedEffect(folders) { items = folders } val reorderState = rememberReorderableLazyListState(listState) { from, to -> items = items.toMutableList().apply { add(to.index, removeAt(from.index)) } } // on each chip: .longPressDraggableHandle(onDragStopped = { onReorderFolders(items) }) 

The LaunchedEffect(folders) resyncs the local copy when the database emits, for example after a folder is created or deleted somewhere else.

What's still open

Writing this up meant rereading the code against the docs. Four things we found:

  1. Sorting isn't locale-aware. sortedBy { it.label.lowercase() } compares by code point. An accented name like "Élan" sorts after every plain "z". Meanwhile the scrubber puts it in #, which is the last slot on the strip. Java's Collator exists for exactly this: it "performs locale-sensitive String comparison" for "searching and sorting routines for natural language text." Digits have the opposite problem: they sort to the top of the list, so the # at the bottom of the strip jumps you to the top. Switching the sort to Collator.getInstance() is the fix. The bucketing would then need to fold accents too, or "É" would sit among the E apps in the list but still jump from #.
  2. The scrubber can hold a stale callback. pointerInput(availableLetters) only restarts when the set of letters changes. The onSelect lambda it captured knows the old letter positions and the old recent-section offset. Install an app under a letter you already have, and the set is the same, but every position after it moved by one. The usual fix is rememberUpdatedState(onSelect) inside the bar, so the running gesture loop always calls the latest lambda.
  3. The pre-31 fallback isn't needed. Our comment says LauncherActivityInfo.getFirstInstallTime() is API 31+, so older devices fall back to an extra getPackageInfo() binder call per app. The reference page says it was "Added in API level 21," well below our minSdk 26. The branch is harmless but pointless, and it costs one binder call per app on every list refresh on Android 8–11 devices.
  4. "Recently Installed" only ages out on a change. The cutoff is computed inside remember(apps, query), so an app that passes the 14-day mark stays in the section until the app list or the query changes. In practice the list emits often (any install, rename or block), so this is cosmetic.

None of these break the drawer, but the first two are the kind of bug a user notices without being able to describe it ("the letters are a bit off sometimes").

What we'd tell another team

  • Build every derived thing (sections, letter index, search results) from one sorted, already-filtered list, and use the same label string everywhere.
  • Treat a fast-scroll strip as one gesture loop: touch and drag share a handler, track a single pointer, consume the event.
  • Never let a strip letter go dead. Jump to the nearest section instead.
  • The moment you put anything above an indexed list, apply an offset at the jump, and give duplicated rows their own keys.
  • Read your own comments against the API reference now and then. Ours said 31; the docs said 21.

If you want to see how the drawer feels, ReClaim is free on Google Play. For the "why" behind a text-only drawer, see how to stop opening apps on autopilot.

This post was drafted with AI assistance and reviewed by the ReClaim team.