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); } }