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