diff --git a/flexera/res/values/flexera_strings.xml b/flexera/res/values/flexera_strings.xml
index f07f4bf204..9f9b6b7804 100644
--- a/flexera/res/values/flexera_strings.xml
+++ b/flexera/res/values/flexera_strings.xml
@@ -42,6 +42,7 @@
Alphabet
+ Search
Notification previews
Show an app\'s latest notification under its name in the app list. Works when notification dots are on (Settings, General).
diff --git a/flexera/src/app/flexera/drawer/FlexeraDrawer.kt b/flexera/src/app/flexera/drawer/FlexeraDrawer.kt
index db104b334c..2c24236ed5 100644
--- a/flexera/src/app/flexera/drawer/FlexeraDrawer.kt
+++ b/flexera/src/app/flexera/drawer/FlexeraDrawer.kt
@@ -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
diff --git a/flexera/src/app/flexera/drawer/WaveAlphabetView.kt b/flexera/src/app/flexera/drawer/WaveAlphabetView.kt
index 876d3835de..f4bfda6865 100644
--- a/flexera/src/app/flexera/drawer/WaveAlphabetView.kt
+++ b/flexera/src/app/flexera/drawer/WaveAlphabetView.kt
@@ -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
diff --git a/src/com/android/launcher3/allapps/ActivityAllAppsContainerView.java b/src/com/android/launcher3/allapps/ActivityAllAppsContainerView.java
index 3e2abd4737..ad304540f8 100644
--- a/src/com/android/launcher3/allapps/ActivityAllAppsContainerView.java
+++ b/src/com/android/launcher3/allapps/ActivityAllAppsContainerView.java
@@ -192,6 +192,8 @@ public class ActivityAllAppsContainerView
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
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
}
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
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
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);
}
}