2 Commits
Author SHA1 Message Date
TMC 8894271daa Release 0.5.0 2026-11-12 09:25:56 +01:00
TMCandClaude Fable 5.2 3cb262617c Search field scrolls with the list; magnifier on the alphabet; a chosen letter's group goes to the middle
Three more requests from the owner on 12 November 2026.

Search field
- The field scrolls away with the list and comes back at the list's top, so the list has
  the whole height once it is scrolled. The field is not turned into a list row. The list
  now starts at the top of the drawer instead of under the field and keeps the field's room
  as top padding (ActivityAllAppsContainerView.replaceAppsRVContainer and
  AdapterHolder.applyPadding); FlexeraDrawer.moveSearchFieldWithList shifts the field by as
  much as the list has scrolled, measured from the first row, and cuts it at the same line
  the rows are cut at, the lower edge of the status bar.
- With work tabs, a hidden or a floating search field, and during a search, nothing changes.

Alphabet
- A magnifier above the letters stands for the search field. Scrubbing to it jumps the list
  to its top; the bubble shows the magnifier. The stretch of edge kept for the strip grows
  by one place for it.
- The strip measures its room from where the list's rows start, not from the list's top.

Chosen letter
- A scrub brings the letter's group to the middle of the screen, where the thumb is,
  instead of to the top: the group's middle on the list's middle, a long group's header no
  higher than 22% of the height. At either end of the list the list stops at its end, so
  the first groups stay at the top and the last ones rest at the bottom.

Checked on the Android 17 emulator: scrolling by hand, scrubs to a middle letter, the last
letter and the magnifier, typing a search and leaving it, reopening after a scrub, the hold
and edge gestures, and a row's long-press menu.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 09:25:48 +01:00
5 changed files with 205 additions and 21 deletions

No files matched your search

+1
View File
@@ -42,6 +42,7 @@
<!-- The drawer's wave alphabet -->
<!-- Read by screen readers as the name of the alphabet strip, followed by the current letter -->
<string name="flexera_alphabet_description">Alphabet</string>
<string name="flexera_alphabet_search">Search</string>
<!-- App drawer settings (app.flexera.ui.FlexeraDrawerSettings) -->
<string name="flexera_notification_previews">Notification previews</string>
<string name="flexera_notification_previews_description">Show an app\'s latest notification under its name in the app list. Works when notification dots are on (Settings, General).</string>
@@ -16,8 +16,10 @@ import com.android.launcher3.BubbleTextView
import com.android.launcher3.R
import com.android.launcher3.allapps.ActivityAllAppsContainerView
import com.android.launcher3.allapps.BaseAllAppsAdapter.AdapterItem
import androidx.recyclerview.widget.RecyclerView
import com.android.launcher3.views.BaseDragLayer
import kotlin.math.max
import kotlin.math.min
/**
* Flexera Launcher's app drawer: a single-column alphabetical list with letter headers
@@ -92,12 +94,7 @@ object FlexeraDrawer {
// tells list rows apart from them.
icon.setTag(R.id.flexera_drawer_row, true)
val textSize = res.getDimension(R.dimen.flexera_drawer_row_text_size)
// The row has to hold the name with a notification preview under it (PRD AD-11). At the
// usual font size the set height does; with a large system font the row grows instead.
icon.layoutParams.height = max(
res.getDimensionPixelSize(R.dimen.flexera_drawer_row_height),
(textSize * ROW_HEIGHT_IN_TEXT_SIZES).toInt(),
)
icon.layoutParams.height = rowHeight(icon.context)
icon.compoundDrawablePadding = res.getDimensionPixelSize(R.dimen.flexera_drawer_row_icon_gap)
icon.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
applySidePadding(icon)
@@ -115,6 +112,20 @@ object FlexeraDrawer {
/** Shadow behind white drawer text. */
const val TEXT_SHADOW_COLOR = 0x73000000
/**
* How tall an app row is. The row has to hold the name with a notification preview under
* it (PRD AD-11). At the usual font size the set height does; with a large system font
* the row grows instead.
*/
@JvmStatic
fun rowHeight(context: Context): Int {
val res = context.resources
return max(
res.getDimensionPixelSize(R.dimen.flexera_drawer_row_height),
(res.getDimension(R.dimen.flexera_drawer_row_text_size) * ROW_HEIGHT_IN_TEXT_SIZES).toInt(),
)
}
/** The least a row's height may be, in name text sizes: two lines of text and some air. */
private const val ROW_HEIGHT_IN_TEXT_SIZES = 2.4f
@@ -131,6 +142,55 @@ object FlexeraDrawer {
*/
const val KEEP_SEARCH_FIELD_BACKGROUND = true
/**
* Moves the search field up with the list, so that it scrolls away like the list's first
* row and comes back when the list is at its top. The field is not a row of the list: it
* lies over the list's top padding and is shifted by as much as the list has scrolled.
* While a search is on, and where the drawer does not use this (work tabs, a hidden or a
* floating search field), the field stays in place.
*/
@JvmStatic
fun moveSearchFieldWithList(appsView: ActivityAllAppsContainerView<*>) {
val field = appsView.searchView ?: return
val list = appsView.activeRecyclerView
val scrolled = if (list == null || appsView.isSearching || !appsView.flexeraSearchFieldScrolls()) {
0
} else {
scrolledFromTop(list)
}
// The field and the list's rows vanish at the same line, the top of the drawer's
// content, which is the lower edge of the status bar. The rows are cut there by the
// list's own top edge; the field is cut here, and travels no further than it takes to
// be all above that line.
val line = appsView.paddingTop
val moved = min(scrolled, max(0, field.bottom - line))
field.translationY = -moved.toFloat()
val hidden = line - (field.top - moved)
if (hidden > 0) {
// The view copies the rectangle, so one scratch rectangle serves every call.
fieldClip.set(0, hidden, field.width, max(hidden, field.height))
field.clipBounds = fieldClip
} else {
field.clipBounds = null
}
}
private val fieldClip = Rect()
/**
* How far [list] has scrolled from its top, in pixels, for as long as its first row is
* still attached; after that, simply "far". Counting rows is exact, which the list's own
* scroll offset, an estimate from row heights, is not.
*/
private fun scrolledFromTop(list: RecyclerView): Int {
val first = list.getChildAt(0) ?: return 0
return if (list.getChildAdapterPosition(first) == 0) {
max(0, list.paddingTop - first.top)
} else {
Int.MAX_VALUE
}
}
/**
* Where the drawer panel sits for a given all-apps progress (0 = open, 1 = closed). Launcher3
* slides the panel up from the bottom; here it stays in place and fades (see
@@ -37,7 +37,8 @@ import kotlin.math.roundToInt
/**
* The wave alphabet: a strip of letters down one edge of the drawer (PRD AD-4 to AD-6).
*
* Only letters that have apps are shown. Touching the strip and sliding along it jumps the list
* Only letters that have apps are shown, under a magnifier that stands for the search field at
* the top of the list. Touching the strip and sliding along it jumps the list
* to the letter under the finger, with a haptic tick on every change. While the finger is down,
* the letters near it bulge away from the edge in a wide wave that curves around the thumb.
* The wave's peak, where the current letter is, stays a thumb's width beyond the finger however
@@ -63,7 +64,8 @@ class WaveAlphabetView(
private val appsView: ActivityAllAppsContainerView<*>,
) : View(context) {
private data class Letter(val label: String, val adapterPosition: Int)
/** One stop on the strip: a letter, or the search field at the top of the list ([isSearch]). */
private data class Letter(val label: String, val adapterPosition: Int, val isSearch: Boolean = false)
private val res = context.resources
private val onLeft = FlexeraDrawer.alphabetOnLeft(context)
@@ -77,6 +79,7 @@ class WaveAlphabetView(
private val waveWidth = res.getDimension(R.dimen.flexera_wave_width)
private val farMargin = res.getDimension(R.dimen.flexera_wave_far_margin)
private val letterSize = res.getDimension(R.dimen.flexera_wave_letter_size)
private val headerHeight = res.getDimensionPixelSize(R.dimen.flexera_drawer_header_height)
/** The section name Launcher3 gives the work profile's info card, an "i" in a circle. */
private val workInfoSection = res.getString(R.string.work_profile_edu_section)
@@ -98,6 +101,22 @@ class WaveAlphabetView(
}
private val boldTypeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
// For the magnifier: one stroke with the letters' shadow, one without for the bubble.
private val markPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
setShadowLayer(
res.getDimension(R.dimen.flexera_drawer_text_shadow_radius),
0f,
res.getDimension(R.dimen.flexera_drawer_text_shadow_dy),
FlexeraDrawer.TEXT_SHADOW_COLOR,
)
}
private val plainMarkPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
}
// The bubble that shows the selected letter large, beyond the wave's peak: a light disc
// with a dark letter, the one thing on the dark drawer that is not white on dark.
private val bubbleRadius = res.getDimension(R.dimen.flexera_wave_bubble_radius)
@@ -166,10 +185,18 @@ class WaveAlphabetView(
if (result.isNotEmpty() && result.last().label == label) continue
result.add(Letter(label, section.position))
}
// The search field is the list's first row in all but name, and scrolls away with it.
// A magnifier above the letters says it is there and jumps back to it.
if (result.isNotEmpty() && hasSearchField()) result.add(0, Letter("", 0, isSearch = true))
return result
}
private fun hasSearchField(): Boolean =
!PreferenceManager2.getInstance(context).hideAppDrawerSearchBar.firstCached()
private fun refreshIfChanged() {
// Any new layout may have moved the list; keep the search field riding on it.
FlexeraDrawer.moveSearchFieldWithList(appsView)
val searching = appsView.isSearching
val fresh = readLetters()
if (fresh != letters || searching != wasSearching) {
@@ -189,7 +216,8 @@ class WaveAlphabetView(
appsView.activeRecyclerView?.let { rv ->
getLocationInWindow(location)
rv.getLocationInWindow(otherLocation)
top = (otherLocation[1] - location[1]).toFloat().coerceAtLeast(0f)
// The list's rows start below its top padding, which holds the search field.
top = (otherLocation[1] - location[1] + rv.paddingTop).toFloat().coerceAtLeast(0f)
}
val bottomInset = rootWindowInsets?.systemWindowInsetBottom ?: 0
val availableTop = top + verticalMargin
@@ -209,7 +237,8 @@ class WaveAlphabetView(
// wherever that puts it. The letters there are sit in the middle of that stretch,
// so that a short alphabet is in the middle of the thumb's reach and not at its
// lower end. Only letters that need the whole edge reach up to where the list starts.
val zoneHeight = max(blockHeight, min(availableBottom - availableTop, spacing * FULL_ALPHABET))
val fullCount = FULL_ALPHABET + if (letters.first().isSearch) 1 else 0
val zoneHeight = max(blockHeight, min(availableBottom - availableTop, spacing * fullCount))
val zoneBottom = availableBottom - min(bottomOffset, availableBottom - availableTop - zoneHeight)
stripTop = zoneBottom - zoneHeight + (zoneHeight - blockHeight) / 2f
stripBottom = stripTop + blockHeight
@@ -298,8 +327,12 @@ class WaveAlphabetView(
val x = baseX + inward * reach * nearness
// Centre the glyph on the letter's line.
val baseline = y - (paint.descent() + paint.ascent()) / 2f
canvas.drawText(letters[i].label, x, baseline, paint)
if (letters[i].isSearch) {
drawSearchMark(canvas, x, y, paint.textSize * SEARCH_MARK_IN_LETTER_SIZES, paint.color, true)
} else {
val baseline = y - (paint.descent() + paint.ascent()) / 2f
canvas.drawText(letters[i].label, x, baseline, paint)
}
}
// The selected letter, large, in a bubble just beyond the wave's peak and level with
@@ -312,11 +345,33 @@ class WaveAlphabetView(
canvas.drawCircle(cx, fingerY, bubbleRadius * press, bubblePaint)
bubbleTextPaint.alpha = alpha
bubbleTextPaint.textSize = bubbleTextSize * press
val baseline = fingerY - (bubbleTextPaint.descent() + bubbleTextPaint.ascent()) / 2f
canvas.drawText(letters[selected].label, cx, baseline, bubbleTextPaint)
if (letters[selected].isSearch) {
drawSearchMark(canvas, cx, fingerY, bubbleTextPaint.textSize, bubbleTextPaint.color, false)
} else {
val baseline = fingerY - (bubbleTextPaint.descent() + bubbleTextPaint.ascent()) / 2f
canvas.drawText(letters[selected].label, cx, baseline, bubbleTextPaint)
}
}
}
/**
* Draws a magnifier, the mark for the search field, [size] across and centred on
* ([cx], [cy]): a ring with a short handle. Drawn by hand so that its line weight
* matches letters of any size.
*/
private fun drawSearchMark(canvas: Canvas, cx: Float, cy: Float, size: Float, color: Int, shadow: Boolean) {
val stroke = if (shadow) markPaint else plainMarkPaint
stroke.color = color
stroke.strokeWidth = size * 0.12f
val radius = size * 0.29f
val ringX = cx - size * 0.09f
val ringY = cy - size * 0.09f
canvas.drawCircle(ringX, ringY, radius, stroke)
val rim = radius * 0.7071f
val handle = size * 0.27f
canvas.drawLine(ringX + rim, ringY + rim, ringX + rim + handle, ringY + rim + handle, stroke)
}
private fun bell(distance: Float): Float = exp(-distance * distance)
// ---- Touch -------------------------------------------------------------------------------
@@ -403,7 +458,7 @@ class WaveAlphabetView(
if (index != selected) {
selected = index
performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
scrollListTo(letters[index].adapterPosition)
scrollListTo(index)
}
invalidate()
}
@@ -417,10 +472,31 @@ class WaveAlphabetView(
val isScrubbing: Boolean get() = scrubbing
private fun scrollListTo(adapterPosition: Int) {
/**
* Brings the group of the letter at [index] to the middle of the screen, where the thumb
* is, instead of to the top: the group's middle on the list's middle, though a long group
* keeps its header in view. Near either end of the list the list simply stops at its end.
* The magnifier goes to the very top, where the search field is.
*/
private fun scrollListTo(index: Int) {
val rv = appsView.activeRecyclerView ?: return
val layoutManager = rv.layoutManager as? LinearLayoutManager ?: return
rv.stopScroll()
(rv.layoutManager as? LinearLayoutManager)?.scrollToPositionWithOffset(adapterPosition, 0)
val letter = letters[index]
if (letter.isSearch) {
layoutManager.scrollToPositionWithOffset(0, 0)
return
}
val groupEnd = letters.getOrNull(index + 1)?.adapterPosition
?: rv.adapter?.itemCount
?: (letter.adapterPosition + 2)
val rows = (groupEnd - letter.adapterPosition - 1).coerceAtLeast(1)
val groupHeight = headerHeight + rows * FlexeraDrawer.rowHeight(context)
val top = max(rv.height * GROUP_HIGHEST_TOP, rv.height * GROUP_MIDDLE - groupHeight / 2f)
layoutManager.scrollToPositionWithOffset(
letter.adapterPosition,
(top - rv.paddingTop).toInt().coerceAtLeast(0),
)
}
private fun animatePress(target: Float, durationMs: Long) {
@@ -481,7 +557,7 @@ class WaveAlphabetView(
private fun jumpTo(index: Int) {
val rv = appsView.activeRecyclerView ?: return
scrollListTo(letters[index].adapterPosition)
scrollListTo(index)
rv.doOnNextLayout {
chosenIndex = index
chosenAtPosition = firstVisiblePosition()
@@ -506,7 +582,9 @@ class WaveAlphabetView(
if (!stripVisible()) return
val index = currentIndex()
node.stateDescription = letters[index].label
node.stateDescription = letters[index].let {
if (it.isSearch) context.getString(R.string.flexera_alphabet_search) else it.label
}
node.setRangeInfo(
AccessibilityNodeInfoCompat.RangeInfoCompat.obtain(
AccessibilityNodeInfoCompat.RangeInfoCompat.RANGE_TYPE_INT,
@@ -535,6 +613,13 @@ class WaveAlphabetView(
}
private companion object {
/** Where a chosen letter's group is centred, and the highest its header may go, as shares of the list's height. */
const val GROUP_MIDDLE = 0.5f
const val GROUP_HIGHEST_TOP = 0.22f
/** The magnifier's size next to a letter of the same text size. */
const val SEARCH_MARK_IN_LETTER_SIZES = 1.1f
/** How many letters a whole alphabet has; the stretch of edge kept for the strip is sized for it. */
const val FULL_ALPHABET = 26
+1 -1
View File
@@ -2,4 +2,4 @@
# build.gradle reads this and derives the Android version code from it
# (major * 10000 + minor * 100 + patch), so the name and the code can never disagree.
# flexera/release.ps1 raises it for every release.
versionName=0.4.0
versionName=0.5.0
@@ -192,6 +192,8 @@ public class ActivityAllAppsContainerView<T extends Context & ActivityContext>
protected View mSearchContainer;
protected SearchUiManager mSearchUiManager;
protected boolean mUsingTabs;
// Flexera Launcher: see replaceAppsRVContainer.
private boolean mFlexeraSearchFieldScrolls;
protected RecyclerViewFastScroller mTouchHandler;
/** {@code true} when rendered view is in search state instead of the scroll state. */
@@ -849,9 +851,28 @@ public class ActivityAllAppsContainerView<T extends Context & ActivityContext>
layoutBelowSearchContainer(getSearchRecyclerView(), /* tabs= */ false);
}
// Flexera Launcher: the search field scrolls away with the list. For that the list
// starts at the top of the drawer, not under the field, and keeps the field's room as
// top padding (AdapterHolder.applyPadding); FlexeraDrawer.moveSearchFieldWithList then
// moves the field as the list scrolls. Work tabs sit between the two and stay put, so
// with tabs the field stays where it is, as before.
mFlexeraSearchFieldScrolls =
!showTabs && !isAppDrawerSearchBarHidden() && !isSearchBarFloating();
if (mFlexeraSearchFieldScrolls
&& rvContainer.getLayoutParams() instanceof RelativeLayout.LayoutParams lp) {
lp.removeRule(RelativeLayout.ALIGN_TOP);
lp.addRule(RelativeLayout.ALIGN_PARENT_TOP);
lp.topMargin = 0;
}
updateSearchResultsVisibility();
}
/** Flexera Launcher: true while the search field scrolls away with the list. */
public boolean flexeraSearchFieldScrolls() {
return mFlexeraSearchFieldScrolls;
}
/**
* 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.
@@ -917,6 +938,8 @@ public class ActivityAllAppsContainerView<T extends Context & ActivityContext>
}
protected void updateHeaderScroll(int scrolledOffset) {
// Flexera Launcher: the search field moves up with the list.
app.flexera.drawer.FlexeraDrawer.moveSearchFieldWithList(this);
if (isAppDrawerSearchBarHidden() && !mUsingTabs)
return;
@@ -1421,6 +1444,11 @@ public class ActivityAllAppsContainerView<T extends Context & ActivityContext>
setPadding(grid.allAppsLeftRightMargin, topPadding, grid.allAppsLeftRightMargin, 0);
}
InsettableFrameLayout.dispatchInsets(this, insets);
// Flexera Launcher: the list's top padding follows the search field's place, which the
// line above may just have changed.
if (mFlexeraSearchFieldScrolls) {
mAH.get(AdapterHolder.MAIN).applyPadding();
}
}
/**
@@ -1905,7 +1933,17 @@ public class ActivityAllAppsContainerView<T extends Context & ActivityContext>
if (isSearchBarFloating()) {
bottomOffset += mSearchContainer.getHeight();
}
mRecyclerView.setPadding(mPadding.left, mPadding.top, mPadding.right,
int top = mPadding.top;
// Flexera Launcher: the room the search field takes at the top of the list, now
// that the list starts above the field (see replaceAppsRVContainer): the
// field's own distance from the top, and what used to be kept under its top edge.
if (mFlexeraSearchFieldScrolls && isMain()) {
top += getResources().getDimensionPixelSize(R.dimen.all_apps_header_top_margin);
if (mSearchContainer.getLayoutParams() instanceof MarginLayoutParams field) {
top += field.topMargin;
}
}
mRecyclerView.setPadding(mPadding.left, top, mPadding.right,
mPadding.bottom + bottomOffset);
}
}