Skip to content

Commit 1636cca

Browse files
authored
Merge pull request #2839 from switchifyapp/feature/grid-scanning-menus-2838
Replace scanning-menu lists with responsive grids
2 parents 19d71a5 + 2dcb850 commit 1636cca

23 files changed

Lines changed: 982 additions & 1195 deletions

app/src/main/java/com/enaboapps/switchify/screens/settings/menu/MenuCustomizationScreen.kt

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,7 @@ import com.enaboapps.switchify.components.switchifyTextFieldColors
3434
import com.enaboapps.switchify.screens.settings.menu.models.MenuCustomizationScreenModel
3535
import com.enaboapps.switchify.screens.settings.menu.models.PaletteItem
3636
import com.enaboapps.switchify.service.menu.MenuItem
37-
import com.enaboapps.switchify.service.menu.MenuSizeManager
38-
import com.enaboapps.switchify.service.menu.MenuSurfaceBudget
37+
import com.enaboapps.switchify.service.menu.MenuGridMeasurer
3938
import com.enaboapps.switchify.service.menu.structure.MenuConstants
4039

4140
/**
@@ -166,15 +165,13 @@ fun MenuCustomizationContent(screenModel: MenuCustomizationScreenModel, menuId:
166165
// budget the runtime uses so the headers match what the user
167166
// will actually see. Hidden items are skipped when numbering
168167
// pages.
169-
val itemSize = MenuSizeManager.getItemSize(context)
170-
val smallItemSize = MenuSizeManager.getSmallItemSize(context)
171-
val pageSize = MenuSurfaceBudget.rowsPerPage(
168+
val pageSize = MenuGridMeasurer.measure(
172169
context = context,
173-
itemSize = itemSize,
174-
smallItemSize = smallItemSize,
175-
hasTitle = MenuConstants.getTitleResource(menuId) != null,
176-
willShowNavRow = true
177-
)
170+
items = menuItems.filter { visibilityMap[it.id] ?: true },
171+
contextualCount = 0,
172+
hasTitle = MenuGridMeasurer.showsTitle(context, menuId),
173+
hasNavigation = true
174+
).grid.pageCapacity
178175
val visibleIndexById = remember(menuItems, visibilityMap) {
179176
var idx = 0
180177
buildMap {

app/src/main/java/com/enaboapps/switchify/service/core/ServiceCore.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ object ServiceCore {
117117
* Cleans up the service core.
118118
*/
119119
fun cleanup() {
120-
MenuManager.getInstance().cleanupAccessibilityActions()
120+
MenuManager.getInstance().cleanup()
121121
getSwitchProfileActivationCoordinator()?.cancel(showMessage = false)
122122
SwitchifyRemoteBridgeCoordinator.detach()
123123
gestureTargetIndicator?.release()

app/src/main/java/com/enaboapps/switchify/service/gestures/AutoScrollManager.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import kotlinx.coroutines.Dispatchers
1111
import kotlinx.coroutines.Job
1212
import kotlinx.coroutines.delay
1313
import kotlinx.coroutines.launch
14+
import kotlinx.coroutines.cancelAndJoin
15+
import kotlinx.coroutines.runBlocking
1416

1517
/**
1618
* Manages auto-scrolling functionality.
@@ -155,9 +157,9 @@ class AutoScrollManager private constructor() {
155157
}
156158

157159
internal fun resetForTesting() {
158-
scrollJob?.cancel()
159-
scrollJob = null
160160
isAutoScrolling = false
161+
runBlocking { scrollJob?.cancelAndJoin() }
162+
scrollJob = null
161163
autoScrollEnabledProviderForTesting = null
162164
autoScrollDelayProviderForTesting = null
163165
autoScrollPerformerForTesting = null
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package com.enaboapps.switchify.service.menu
2+
3+
internal data class MenuGridLayout(
4+
val columns: Int,
5+
val rows: Int,
6+
val cellWidthPx: Int,
7+
val cellHeightPx: Int
8+
) {
9+
val pageCapacity: Int get() = columns * rows
10+
11+
fun <T> pages(items: List<T>): List<List<T>> =
12+
items.chunked(pageCapacity).ifEmpty { listOf(emptyList()) }
13+
14+
fun <T> rows(items: List<T>): List<List<T>> = items.chunked(columns)
15+
16+
fun clampPage(page: Int, itemCount: Int): Int =
17+
page.coerceIn(0, ((itemCount - 1).coerceAtLeast(0) / pageCapacity))
18+
}
19+
20+
internal object MenuGridLayoutPolicy {
21+
/**
22+
* Upper bound on content columns. Width-limited screens land well below
23+
* this; it only engages on landscape phones and tablets, where rows are
24+
* the scarce dimension.
25+
*/
26+
const val MAX_COLUMNS = 5
27+
28+
fun calculate(
29+
widthPx: Int,
30+
heightPx: Int,
31+
itemCount: Int,
32+
minimumCellWidthPx: Int,
33+
preferredCellHeightPx: Int,
34+
minimumTouchPx: Int,
35+
labelsFit: (cellWidthPx: Int) -> Boolean
36+
): MenuGridLayout {
37+
val width = widthPx.coerceAtLeast(1)
38+
val minimumWidth = maxOf(minimumCellWidthPx, minimumTouchPx, 1)
39+
var columns = minOf(MAX_COLUMNS, itemCount.coerceAtLeast(1), (width / minimumWidth).coerceAtLeast(1))
40+
while (columns > 1 && !labelsFit(width / columns)) columns--
41+
val cellHeight = preferredCellHeightPx.coerceAtLeast(minimumTouchPx)
42+
.coerceAtMost(heightPx.coerceAtLeast(minimumTouchPx))
43+
val rows = (heightPx / cellHeight.coerceAtLeast(1)).coerceAtLeast(1)
44+
return MenuGridLayout(columns, rows, width / columns, cellHeight)
45+
}
46+
}
47+
48+
internal object MenuLabelBreaks {
49+
/**
50+
* True when a line break placed at [end] splits a word in two, i.e. there
51+
* is a word character on both sides of the break. Breaks at whitespace,
52+
* punctuation, hyphens, or between characters of scripts that legitimately
53+
* break anywhere (CJK, Thai, and similar) are not mid-word.
54+
*/
55+
fun isMidWordBreak(text: CharSequence, end: Int): Boolean {
56+
if (end <= 0 || end >= text.length) return false
57+
return isWordChar(text[end - 1]) && isWordChar(text[end])
58+
}
59+
60+
private fun isWordChar(c: Char): Boolean {
61+
if (!c.isLetterOrDigit()) return false
62+
return when (Character.UnicodeScript.of(c.code)) {
63+
Character.UnicodeScript.HAN,
64+
Character.UnicodeScript.HIRAGANA,
65+
Character.UnicodeScript.KATAKANA,
66+
Character.UnicodeScript.HANGUL,
67+
Character.UnicodeScript.THAI,
68+
Character.UnicodeScript.LAO,
69+
Character.UnicodeScript.KHMER,
70+
Character.UnicodeScript.MYANMAR -> false
71+
else -> true
72+
}
73+
}
74+
}
75+
76+
internal data class MenuPageSections<T>(
77+
val contextual: List<T>,
78+
val content: List<T>,
79+
val navigationSlots: List<T?>
80+
) {
81+
fun rows(columns: Int, navigationColumns: Int): List<List<T>> =
82+
contextual.map { listOf(it) } + content.chunked(columns) +
83+
navigationSlots.chunked(navigationColumns).map { it.filterNotNull() }.filter { it.isNotEmpty() }
84+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package com.enaboapps.switchify.service.menu
2+
3+
import android.content.Context
4+
import android.graphics.Paint
5+
import android.graphics.Typeface
6+
import android.text.StaticLayout
7+
import android.text.TextPaint
8+
import android.util.TypedValue
9+
import com.enaboapps.switchify.service.menu.structure.MenuConstants
10+
import com.enaboapps.switchify.service.window.MenuHighlightHud
11+
import kotlin.math.ceil
12+
13+
internal data class MenuGridMetrics(
14+
val grid: MenuGridLayout,
15+
val widthPx: Int,
16+
val titleHeightPx: Int,
17+
val contextualHeightPx: Int,
18+
val navigationColumns: Int,
19+
val navigationHeightPx: Int,
20+
val pageCountHeightPx: Int
21+
)
22+
23+
internal object MenuGridMeasurer {
24+
fun measure(
25+
context: Context,
26+
items: List<MenuItem>,
27+
contextualCount: Int,
28+
hasTitle: Boolean,
29+
hasNavigation: Boolean
30+
): MenuGridMetrics {
31+
val size = MenuSizeManager.getItemSize(context)
32+
fun dp(value: Float) = ceil(value * context.resources.displayMetrics.density).toInt()
33+
fun sp(value: Float) = ceil(TypedValue.applyDimension(
34+
TypedValue.COMPLEX_UNIT_SP, value, context.resources.displayMetrics
35+
)).toInt()
36+
val width = MenuSurfaceBudget.contentMaxWidthPx(context).coerceAtLeast(1)
37+
val minimumTouch = dp(52f)
38+
val titleHeight = if (hasTitle) sp(24f) + dp(12f) else 0
39+
// Page indicator is a row of dots, not text: 8 dp dots plus breathing room.
40+
val pageCountHeight = dp(16f)
41+
// Keep navigation on one row whenever four cells can each meet the
42+
// touch minimum; a second nav row costs more height than a content row
43+
// on narrow phones.
44+
val navigationColumns = if (width >= 4 * minimumTouch) 4 else 2
45+
val navHeight = maxOf(minimumTouch, dp(28f) + sp(size.labelTextSize.value * 2.6f) + dp(12f))
46+
val contextualHeight = maxOf(minimumTouch, sp(size.labelTextSize.value * 2.6f) + dp(16f))
47+
val chrome = titleHeight + contextualCount * contextualHeight +
48+
if (hasNavigation) (4 / navigationColumns) * navHeight + dp(8f) else 0
49+
val bodyHeight = MenuSurfaceBudget.surfaceMaxHeightPx(context) - dp(36f) - chrome - pageCountHeight
50+
val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
51+
textSize = sp(size.labelTextSize.value).toFloat()
52+
typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)
53+
letterSpacing = 0.5f / size.labelTextSize.value
54+
}
55+
val preferredHeight = dp(size.iconSize.value + 24f) + sp(size.labelTextSize.value * 3.9f)
56+
val grid = MenuGridLayoutPolicy.calculate(
57+
width, bodyHeight, items.size, dp(maxOf(80f, size.width.value)),
58+
preferredHeight, minimumTouch
59+
) { cellWidth ->
60+
items.all { item ->
61+
val text = item.displayText().take(4096)
62+
val layout = StaticLayout.Builder.obtain(text, 0, text.length, paint,
63+
(cellWidth - dp(16f)).coerceAtLeast(1))
64+
.setIncludePad(false).setMaxLines(4).build()
65+
layout.lineCount <= 3 && (0 until layout.lineCount).none { line ->
66+
MenuLabelBreaks.isMidWordBreak(text, layout.getLineEnd(line))
67+
}
68+
}
69+
}
70+
return MenuGridMetrics(grid, width, titleHeight, contextualHeight,
71+
navigationColumns, navHeight, pageCountHeight)
72+
}
73+
74+
/**
75+
* Whether the menu should render its title row. On short screens the
76+
* highlight HUD reserves no top space and floats over the top of the menu,
77+
* exactly where the title sits, so the row is dropped to give that height
78+
* back to content.
79+
*/
80+
fun showsTitle(context: Context, menuId: String?): Boolean =
81+
MenuConstants.getTitleResource(menuId) != null && MenuHighlightHud.reservedTopPx(context) > 0
82+
}

app/src/main/java/com/enaboapps/switchify/service/menu/MenuHierarchy.kt

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ class MenuHierarchy(
1515
private val TAG = "SwitchifyMenuHierarchy"
1616

1717
private var tree: List<MenuView> = mutableListOf()
18+
private var openGeneration = 0L
1819

1920
private fun addMenu(menu: MenuView) {
2021
tree += menu
@@ -48,13 +49,7 @@ class MenuHierarchy(
4849
// Notify observers of menu closure
4950
closedMenu?.let { MenuManager.getInstance().notifyMenuClosed(it) }
5051

51-
Handler(Looper.getMainLooper()).postDelayed(100) {
52-
tree.lastOrNull()?.let {
53-
it.menuViewListener = this
54-
it.open(scanningManager)
55-
// MenuView will handle nodes change notification after inflating
56-
}
57-
}
52+
tree.lastOrNull()?.let { openReplacement(it, notifyOpened = false) }
5853
}
5954
}
6055

@@ -70,13 +65,7 @@ class MenuHierarchy(
7065
StatsCollector.getInstance().recordMenuOpen(menuId)
7166
}
7267

73-
menu.menuViewListener = this
74-
Handler(Looper.getMainLooper()).postDelayed(100) {
75-
menu.open(scanningManager)
76-
// Notify observers that menu was opened
77-
MenuManager.getInstance().notifyMenuOpened(menu)
78-
// MenuView will handle nodes change notification after inflating
79-
}
68+
openReplacement(menu)
8069
}
8170

8271
fun replaceTopMenu(menu: MenuView) {
@@ -100,25 +89,21 @@ class MenuHierarchy(
10089
openReplacement(menu)
10190
}
10291

103-
private fun openReplacement(menu: MenuView) {
92+
private fun openReplacement(menu: MenuView, notifyOpened: Boolean = true) {
93+
val generation = ++openGeneration
10494
menu.menuViewListener = this
10595
Handler(Looper.getMainLooper()).postDelayed(100) {
106-
if (getTopMenu() !== menu) return@postDelayed
96+
if (generation != openGeneration || getTopMenu() !== menu) return@postDelayed
10797
menu.open(scanningManager)
108-
MenuManager.getInstance().notifyMenuOpened(menu)
98+
if (notifyOpened) MenuManager.getInstance().notifyMenuOpened(menu)
10999
}
110100
}
111101

112102
fun removeAllMenus() {
113103
val depthBefore = tree.size
114-
// close the top menu
115-
getTopMenu()?.close()
116-
tree = mutableListOf()
104+
dispose()
117105
logStackChange("clear", depthBefore, tree.size)
118106

119-
// remove the menu view
120-
MenuViewHandler.instance.kill()
121-
122107
// Notify observers that all menus were closed
123108
MenuManager.getInstance().notifyAllMenusClosed()
124109

@@ -130,6 +115,18 @@ class MenuHierarchy(
130115
return tree.lastOrNull()
131116
}
132117

118+
/**
119+
* Invalidates pending opens, closes the top menu, clears the stack, and
120+
* releases the menu container. Does not notify observers or reload the
121+
* access technique; [removeAllMenus] layers those on top for normal closes.
122+
*/
123+
fun dispose() {
124+
openGeneration++
125+
getTopMenu()?.close()
126+
tree = emptyList()
127+
MenuViewHandler.instance.kill()
128+
}
129+
133130
fun isAtFirstMenu(): Boolean {
134131
return tree.size == 1
135132
}

0 commit comments

Comments
 (0)