From cdb076bba5aa9cdb9a368cb783c0a728b5e9470f Mon Sep 17 00:00:00 2001 From: TMC Date: Wed, 11 Nov 2026 22:44:43 +0100 Subject: [PATCH] Add the alphabet side setting, screen-reader support and a trimmed drawer settings screen PRD AD-15 and SI-10, plus polish found while checking the M4 rows on the emulator. Wave alphabet - New setting "Alphabet on the left edge" for left-handed use. Rows keep clear of the alphabet on whichever edge it sits; a scrub from the left darkens the list under the wave, because there the letters cross the icons and names. - Screen readers see the strip as one slider: it reads the letter the list is at, and the adjust gesture moves one letter at a time. - Letters shrink to fit their slot when a large system font would make them overlap. Settings - FlexeraDrawerSettings replaces Lawnchair's App drawer screen, whose grid options (columns, row height, icon and label size, drawer folders, scroll bar, panel colour and opacity) do nothing in the list drawer or break its white-on-transparent look. - Settings home and search hint no longer mention column counts or web results. Search - The search field keeps its pill while typing; without a drawer panel behind it, the dark text sat directly on the wallpaper. Checked on the Android 17 emulator: drawer search, hiding and restoring an app, notification dots, swipe down for notifications, the left-edge alphabet and its scrub, and the slider node in the accessibility tree. Co-Authored-By: Claude Fable 5.2 --- flexera/res/values/flexera_dimens.xml | 4 +- .../src/app/flexera/drawer/FlexeraDrawer.kt | 36 +++- .../app/flexera/drawer/WaveAlphabetView.kt | 156 +++++++++++++++++- .../app/flexera/ui/FlexeraDrawerSettings.kt | 74 +++++++++ .../lawnchair/allapps/AllAppsSearchInput.kt | 4 +- .../preferences/PreferenceManager.kt | 3 + .../navigation/PreferenceNavigation.kt | 2 +- 7 files changed, 267 insertions(+), 12 deletions(-) create mode 100644 flexera/src/app/flexera/ui/FlexeraDrawerSettings.kt diff --git a/flexera/res/values/flexera_dimens.xml b/flexera/res/values/flexera_dimens.xml index d2f8afc2e7..56d5a282db 100644 --- a/flexera/res/values/flexera_dimens.xml +++ b/flexera/res/values/flexera_dimens.xml @@ -6,8 +6,10 @@ 46dp 20dp 17sp + 24dp - + 56dp diff --git a/flexera/src/app/flexera/drawer/FlexeraDrawer.kt b/flexera/src/app/flexera/drawer/FlexeraDrawer.kt index eb25676927..8c7a1e27e1 100644 --- a/flexera/src/app/flexera/drawer/FlexeraDrawer.kt +++ b/flexera/src/app/flexera/drawer/FlexeraDrawer.kt @@ -10,6 +10,7 @@ import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.widget.TextView +import app.lawnchair.preferences.PreferenceManager import com.android.launcher3.BubbleTextView import com.android.launcher3.R import com.android.launcher3.allapps.ActivityAllAppsContainerView @@ -44,7 +45,33 @@ object FlexeraDrawer { @JvmStatic fun createSectionHeader(inflater: LayoutInflater, parent: ViewGroup): View = - inflater.inflate(R.layout.flexera_all_apps_section_header, parent, false) + inflater.inflate(R.layout.flexera_all_apps_section_header, parent, false).also(::applySidePadding) + + /** + * True when the owner has moved the wave alphabet to the left edge (PRD AD-15). Changing the + * setting recreates the launcher, so views may read this once when they are created. + */ + @JvmStatic + fun alphabetOnLeft(context: Context): Boolean = + PreferenceManager.getInstance(context).flexeraAlphabetOnLeft.get() + + /** + * Keeps a row clear of the wave alphabet: wide padding on the alphabet's edge, normal padding + * on the other. These are physical sides, not start and end, because the alphabet's edge is + * chosen by hand and does not flip with the language's reading direction. + */ + private fun applySidePadding(row: View) { + val res = row.resources + val plain = res.getDimensionPixelSize(R.dimen.flexera_drawer_row_padding_start) + val besideAlphabet = res.getDimensionPixelSize(R.dimen.flexera_drawer_row_padding_end) + val onLeft = alphabetOnLeft(row.context) + row.setPadding( + if (onLeft) besideAlphabet else plain, + row.paddingTop, + if (onLeft) plain else besideAlphabet, + row.paddingBottom, + ) + } @JvmStatic fun bindSectionHeader(view: View, item: AdapterItem) { @@ -62,6 +89,7 @@ object FlexeraDrawer { 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)) + applySidePadding(icon) // The drawer has no background, so names sit directly on the dimmed wallpaper: white, // with a soft shadow to stay readable over light areas. icon.setTextColor(Color.WHITE) @@ -76,6 +104,12 @@ object FlexeraDrawer { /** Shadow behind white drawer text. */ const val TEXT_SHADOW_COLOR = 0x73000000 + /** + * Lawnchair fades the search field's pill out while the owner types, leaving the text on the + * drawer's panel. This drawer has no panel, so the pill stays. + */ + const val KEEP_SEARCH_FIELD_BACKGROUND = true + /** * 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 256b10e3c4..51c1a1a6a8 100644 --- a/flexera/src/app/flexera/drawer/WaveAlphabetView.kt +++ b/flexera/src/app/flexera/drawer/WaveAlphabetView.kt @@ -9,13 +9,21 @@ import android.graphics.Paint import android.graphics.Rect import android.graphics.Typeface import android.os.Build +import android.os.Bundle import android.view.HapticFeedbackConstants import android.view.MotionEvent import android.view.View import android.view.ViewTreeObserver import android.view.animation.DecelerateInterpolator +import android.widget.SeekBar import androidx.core.graphics.ColorUtils +import androidx.core.view.ViewCompat +import androidx.core.view.accessibility.AccessibilityEventCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.core.view.doOnNextLayout +import androidx.customview.widget.ExploreByTouchHelper import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import com.android.launcher3.R import com.android.launcher3.allapps.ActivityAllAppsContainerView import kotlin.math.abs @@ -25,7 +33,7 @@ import kotlin.math.min import kotlin.math.roundToInt /** - * The wave alphabet: a strip of letters down the end edge of the drawer (PRD AD-4 to AD-6). + * 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 * to the letter under the finger, with a haptic tick on every change. While the finger is down, @@ -33,11 +41,16 @@ import kotlin.math.roundToInt * further the finger moves in from the edge, so the current letter always sits beside the thumb * instead of under it. * + * The strip sits on the right edge unless the owner moves it to the left (PRD AD-15). + * * The view covers the whole drawer so it has room to draw the wave, but it only takes touches * that start on the strip; every other touch falls through to the list underneath. * * [beginScrub], [moveScrub] and [endScrub] are public so that another gesture, such as swipe up * and hold from the home screen (PRD AD-17), can hand its touch over to the alphabet. + * + * For screen readers the strip is one control that behaves like a slider (PRD SI-10): TalkBack + * reads the letter the list is at, and its adjust gesture moves the list one letter at a time. */ @SuppressLint("ViewConstructor") class WaveAlphabetView( @@ -48,6 +61,7 @@ class WaveAlphabetView( private data class Letter(val label: String, val adapterPosition: Int) private val res = context.resources + private val onLeft = FlexeraDrawer.alphabetOnLeft(context) private val touchWidth = res.getDimension(R.dimen.flexera_wave_touch_width) private val edgeInset = res.getDimension(R.dimen.flexera_wave_edge_inset) private val maxSpacing = res.getDimension(R.dimen.flexera_wave_max_spacing) @@ -78,6 +92,9 @@ class WaveAlphabetView( private var letterYs = FloatArray(0) private var stripTop = 0f private var stripBottom = 0f + + /** Letter size at rest. Smaller than [letterSize] when a large font would make letters collide. */ + private var restingLetterSize = letterSize private var layoutDirty = true private var wasSearching = false @@ -95,10 +112,12 @@ class WaveAlphabetView( private val otherLocation = IntArray(2) private val layoutListener = ViewTreeObserver.OnGlobalLayoutListener { refreshIfChanged() } + private val accessibility = StripAccessibility() init { id = R.id.flexera_wave_alphabet - importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO + importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_YES + ViewCompat.setAccessibilityDelegate(this, accessibility) } // ---- Letters and their positions --------------------------------------------------------- @@ -125,6 +144,7 @@ class WaveAlphabetView( letters = fresh wasSearching = searching layoutDirty = true + accessibility.invalidateRoot() invalidate() } } @@ -145,6 +165,7 @@ class WaveAlphabetView( val count = letters.size letterYs = FloatArray(count) + restingLetterSize = letterSize if (count == 0 || availableBottom <= availableTop) { stripTop = 0f stripBottom = 0f @@ -157,12 +178,19 @@ class WaveAlphabetView( for (i in 0 until count) { letterYs[i] = stripTop + spacing * (i + 0.5f) } + // With a large system font and a full alphabet, letters at their set size would + // overlap their neighbours; shrink them to fit their slot. + restingLetterSize = min(letterSize, spacing * MAX_LETTER_SHARE_OF_SLOT) } // The strip sits where Android's back swipe starts. A home app may reserve the whole // edge, so the scrub never turns into Back. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - exclusionRect.set((width - touchWidth).toInt(), stripTop.toInt(), width, stripBottom.toInt()) + if (onLeft) { + exclusionRect.set(0, stripTop.toInt(), touchWidth.toInt(), stripBottom.toInt()) + } else { + exclusionRect.set((width - touchWidth).toInt(), stripTop.toInt(), width, stripBottom.toInt()) + } systemGestureExclusionRects = listOf(exclusionRect) } } @@ -190,10 +218,19 @@ class WaveAlphabetView( if (layoutDirty) computeLayout() if (letters.isEmpty()) return - val baseX = width - edgeInset + // On the left edge the wave reaches out over the icons and names, so darken the list + // under it for as long as the finger is down. On the right it crosses empty space. + if (onLeft && press > 0f) { + canvas.drawColor(Color.argb((LEFT_SCRUB_DIM * press * 255).toInt(), 0, 0, 0)) + } + + // Where the letters rest, and which way "away from the edge" points. + val baseX = if (onLeft) edgeInset else width - edgeInset + val inward = if (onLeft) 1f else -1f // The wave's peak sits a fixed gap before the finger, so it reaches further out the // further the finger is from the edge. - val reach = (baseX - fingerX + fingerGap).coerceIn(minReach, width * 0.7f) * press + val fingerDistance = (fingerX - baseX) * inward + val reach = (fingerDistance + fingerGap).coerceIn(minReach, width * 0.7f) * press for (i in letters.indices) { val y = letterYs[i] @@ -201,7 +238,7 @@ class WaveAlphabetView( val isSelected = i == selected && press > 0f val scale = 1f + (if (isSelected) SELECTED_GROWTH else NEAR_GROWTH) * nearness * press - paint.textSize = letterSize * scale + paint.textSize = restingLetterSize * scale paint.typeface = if (isSelected) boldTypeface else Typeface.DEFAULT paint.color = if (isSelected) { activeColor @@ -209,7 +246,7 @@ class WaveAlphabetView( ColorUtils.blendARGB(idleColor, activeColor, nearness * press) } - val x = baseX - reach * nearness + 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) @@ -224,7 +261,8 @@ class WaveAlphabetView( fun isOnStrip(x: Float, y: Float): Boolean { if (layoutDirty) computeLayout() if (letters.isEmpty() || !appsView.isInAllApps || appsView.isSearching) return false - return x >= width - touchWidth && y >= stripTop - verticalMargin && y <= stripBottom + verticalMargin + val onEdge = if (onLeft) x <= touchWidth else x >= width - touchWidth + return onEdge && y >= stripTop - verticalMargin && y <= stripBottom + verticalMargin } @SuppressLint("ClickableViewAccessibility") @@ -311,6 +349,99 @@ class WaveAlphabetView( } } + // ---- Screen readers ---------------------------------------------------------------------- + + /** Hover is how a screen reader explores by touch; only the strip itself answers it. */ + override fun dispatchHoverEvent(event: MotionEvent): Boolean = accessibility.dispatchHoverEvent(event) + + private fun firstVisiblePosition(): Int = + (appsView.activeRecyclerView?.layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition() + ?: RecyclerView.NO_POSITION + + /** + * Presents the strip to accessibility services as a single slider whose value is the letter + * the list is at. One control with an adjust gesture is far quicker to use blind than two + * dozen separate letters. + */ + private inner class StripAccessibility : ExploreByTouchHelper(this@WaveAlphabetView) { + + /** + * The letter last chosen through the slider, and where the list stood right after. Near + * the end of the list several letters fit on one screen, so the list's position alone + * cannot tell them apart; this remembers the choice until the list is scrolled again. + */ + private var chosenIndex = -1 + private var chosenAtPosition = RecyclerView.NO_POSITION + + private fun stripVisible(): Boolean = + letters.isNotEmpty() && appsView.isInAllApps && !appsView.isSearching + + private fun currentIndex(): Int { + val first = firstVisiblePosition() + if (chosenIndex in letters.indices && first == chosenAtPosition) return chosenIndex + chosenIndex = -1 + var index = 0 + for (i in letters.indices) { + if (letters[i].adapterPosition <= first) index = i else break + } + return index + } + + private fun jumpTo(index: Int) { + val rv = appsView.activeRecyclerView ?: return + scrollListTo(letters[index].adapterPosition) + rv.doOnNextLayout { + chosenIndex = index + chosenAtPosition = firstVisiblePosition() + invalidateVirtualView(STRIP, AccessibilityEventCompat.CONTENT_CHANGE_TYPE_STATE_DESCRIPTION) + } + } + + override fun getVirtualViewAt(x: Float, y: Float): Int = + if (stripVisible() && isOnStrip(x, y)) STRIP else INVALID_ID + + override fun getVisibleVirtualViews(virtualViewIds: MutableList) { + if (stripVisible()) virtualViewIds.add(STRIP) + } + + override fun onPopulateNodeForVirtualView(virtualViewId: Int, node: AccessibilityNodeInfoCompat) { + if (layoutDirty) computeLayout() + node.className = SeekBar::class.java.name + node.contentDescription = context.getString(R.string.flexera_alphabet_description) + val left = if (onLeft) 0 else (width - touchWidth).toInt() + @Suppress("DEPRECATION") + node.setBoundsInParent(Rect(left, stripTop.toInt(), left + touchWidth.toInt(), max(stripBottom, stripTop + 1f).toInt())) + if (!stripVisible()) return + + val index = currentIndex() + node.stateDescription = letters[index].label + node.setRangeInfo( + AccessibilityNodeInfoCompat.RangeInfoCompat.obtain( + AccessibilityNodeInfoCompat.RangeInfoCompat.RANGE_TYPE_INT, + 0f, + (letters.size - 1).toFloat(), + index.toFloat(), + ), + ) + if (index < letters.size - 1) node.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_SCROLL_FORWARD) + if (index > 0) node.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_SCROLL_BACKWARD) + // Declared so that a double tap is answered here and not turned into a touch in the + // middle of the strip, which would jump the list to the middle of the alphabet. + node.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK) + } + + override fun onPerformActionForVirtualView(virtualViewId: Int, action: Int, arguments: Bundle?): Boolean { + if (!stripVisible()) return false + val index = currentIndex() + return when (action) { + AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD -> (index < letters.size - 1).also { if (it) jumpTo(index + 1) } + AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD -> (index > 0).also { if (it) jumpTo(index - 1) } + AccessibilityNodeInfoCompat.ACTION_CLICK -> true + else -> false + } + } + } + private companion object { /** How much larger than its resting size the selected letter grows. */ const val SELECTED_GROWTH = 1.5f @@ -318,7 +449,16 @@ class WaveAlphabetView( /** How much its neighbours grow, tapering with distance from the finger. */ const val NEAR_GROWTH = 0.5f + /** How dark the list gets under a scrub from the left edge (0 = unchanged, 1 = black). */ + const val LEFT_SCRUB_DIM = 0.55f + + /** The tallest a resting letter may be, as a share of the vertical room it has. */ + const val MAX_LETTER_SHARE_OF_SLOT = 0.85f + const val PRESS_IN_MS = 110L const val PRESS_OUT_MS = 220L + + /** Virtual view id of the strip for accessibility services. */ + const val STRIP = 1 } } diff --git a/flexera/src/app/flexera/ui/FlexeraDrawerSettings.kt b/flexera/src/app/flexera/ui/FlexeraDrawerSettings.kt new file mode 100644 index 0000000000..f776e15669 --- /dev/null +++ b/flexera/src/app/flexera/ui/FlexeraDrawerSettings.kt @@ -0,0 +1,74 @@ +package app.flexera.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import app.lawnchair.preferences.getAdapter +import app.lawnchair.preferences.preferenceManager +import app.lawnchair.preferences2.preferenceManager2 +import app.lawnchair.ui.preferences.LocalIsExpandedScreen +import app.lawnchair.ui.preferences.components.NavigationActionPreference +import app.lawnchair.ui.preferences.components.controls.SwitchPreference +import app.lawnchair.ui.preferences.components.layout.PreferenceGroup +import app.lawnchair.ui.preferences.components.layout.PreferenceLayout +import app.lawnchair.ui.preferences.destinations.SearchBarPreference +import app.lawnchair.ui.preferences.destinations.SearchRoute +import app.lawnchair.ui.preferences.navigation.AppDrawerHiddenApps +import app.lawnchair.ui.preferences.navigation.Predictions +import com.android.launcher3.R + +/** + * Flexera Launcher's "App drawer" settings screen. + * + * It replaces Lawnchair's screen of the same name, most of which tunes the app grid: columns, row + * height, icon and label size, drawer folders, the scroll bar, the panel's colour. The Flexera + * drawer is a transparent list with a wave alphabet, where those settings do nothing or break + * the look, so this screen offers only the ones that still act on it, plus the alphabet's own. + */ +@Composable +fun FlexeraDrawerSettings(modifier: Modifier = Modifier) { + val prefs = preferenceManager() + val prefs2 = preferenceManager2() + val resources = LocalContext.current.resources + + PreferenceLayout( + label = stringResource(id = R.string.app_drawer_label), + backArrowVisible = !LocalIsExpandedScreen.current, + modifier = modifier, + ) { + val hiddenApps = prefs2.hiddenApps.getAdapter().state.value + PreferenceGroup(heading = stringResource(id = R.string.general_label)) { + NavigationActionPreference( + label = stringResource(id = R.string.hidden_apps_label), + destination = AppDrawerHiddenApps, + subtitle = resources.getQuantityString(R.plurals.apps_count, hiddenApps.size, hiddenApps.size), + ) + SearchBarPreference(SearchRoute.DRAWER_SEARCH, showLabel = false) + NavigationActionPreference( + label = stringResource(id = R.string.suggestion_pref_screen_title), + destination = Predictions, + ) + SwitchPreference( + label = stringResource(id = R.string.pref_all_apps_remember_position_title), + description = stringResource(id = R.string.pref_all_apps_remember_position_description), + adapter = prefs2.rememberPosition.getAdapter(), + ) + } + PreferenceGroup(heading = stringResource(id = R.string.flexera_alphabet_heading)) { + SwitchPreference( + label = stringResource(id = R.string.flexera_alphabet_on_left), + description = stringResource(id = R.string.flexera_alphabet_on_left_description), + adapter = prefs.flexeraAlphabetOnLeft.getAdapter(), + ) + } + // No background colour or opacity here: the drawer is transparent with white text, and a + // light panel behind white text would make the list unreadable. + PreferenceGroup(heading = stringResource(id = R.string.style)) { + SwitchPreference( + label = stringResource(id = R.string.pref_all_apps_search_bar_background), + adapter = prefs2.appDrawerSearchBarBackground.getAdapter(), + ) + } + } +} diff --git a/lawnchair/src/app/lawnchair/allapps/AllAppsSearchInput.kt b/lawnchair/src/app/lawnchair/allapps/AllAppsSearchInput.kt index 8dc6a83204..79e1f3a6d5 100644 --- a/lawnchair/src/app/lawnchair/allapps/AllAppsSearchInput.kt +++ b/lawnchair/src/app/lawnchair/allapps/AllAppsSearchInput.kt @@ -190,8 +190,10 @@ class AllAppsSearchInput(context: Context, attrs: AttributeSet?) : ColorTokens.SearchboxHighlight.resolveColor(context) } + // Flexera Launcher: the drawer has no background panel, so the search field keeps + // its own pill while typing; without it the dark text sits on the wallpaper. val backgroundAlpha by animateIntAsState( - if (isFocused || !queryEmpty) 0 else 100, + if ((isFocused || !queryEmpty) && !app.flexera.drawer.FlexeraDrawer.KEEP_SEARCH_FIELD_BACKGROUND) 0 else 100, ) // Ignore other theme attributes to preserve existing behavior diff --git a/lawnchair/src/app/lawnchair/preferences/PreferenceManager.kt b/lawnchair/src/app/lawnchair/preferences/PreferenceManager.kt index 291e9161cd..c3bd6ea71d 100644 --- a/lawnchair/src/app/lawnchair/preferences/PreferenceManager.kt +++ b/lawnchair/src/app/lawnchair/preferences/PreferenceManager.kt @@ -214,6 +214,9 @@ class PreferenceManager @Inject constructor( val forceIconMonochrome = BoolPref("pref_forceIconMonochrome", false) + // Flexera Launcher: which screen edge the drawer's wave alphabet sits on (PRD AD-15). + val flexeraAlphabetOnLeft = BoolPref("pref_flexeraAlphabetOnLeft", false, recreate) + override fun close() { TODO("Not yet implemented") } diff --git a/lawnchair/src/app/lawnchair/ui/preferences/navigation/PreferenceNavigation.kt b/lawnchair/src/app/lawnchair/ui/preferences/navigation/PreferenceNavigation.kt index 7e257d3eaf..5d497bc309 100644 --- a/lawnchair/src/app/lawnchair/ui/preferences/navigation/PreferenceNavigation.kt +++ b/lawnchair/src/app/lawnchair/ui/preferences/navigation/PreferenceNavigation.kt @@ -164,7 +164,7 @@ fun PreferenceNavigation( composable( deepLinks = getDeepLink(AppDrawer), - ) { AppDrawerPreferences() } + ) { app.flexera.ui.FlexeraDrawerSettings() } // Flexera Launcher: replaces AppDrawerPreferences() composable( deepLinks = getDeepLink(AppDrawerHiddenApps), ) { HiddenAppsPreferences() }