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