Skip to content

Commit 3c7b4bf

Browse files
feat: add Select reorder mode to menu customization (#2290) (#2291)
Adds a third reorder mode alongside Drag and Arrows. Select is a two-step "pick an item, then pick its destination" flow: tap Move on any row to pick it up, then tap any other row to insert it there (or tap "Insert at the end"). Switch-access users could previously only reorder via Arrows, which takes one tap per position — moving an item from position 1 to 12 took 11 taps and 11 scan passes. Select moves any item to any position in two scan passes regardless of distance. UI shell over the existing moveItem(from, to) primitive on the view model — no database, repository, or data-flow changes. Closes #2290 Co-authored-by: Owen McGirr <o.a.mcgirr@gmail.com>
1 parent bc6f446 commit 3c7b4bf

4 files changed

Lines changed: 384 additions & 34 deletions

File tree

app/src/main/java/com/enaboapps/switchify/components/ReorderableList.kt

Lines changed: 207 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.enaboapps.switchify.components
22

33
import androidx.compose.foundation.layout.Arrangement
4+
import androidx.compose.foundation.layout.Box
45
import androidx.compose.foundation.layout.Column
56
import androidx.compose.foundation.layout.Row
67
import androidx.compose.foundation.layout.Spacer
@@ -12,17 +13,24 @@ import androidx.compose.foundation.lazy.LazyColumn
1213
import androidx.compose.foundation.lazy.LazyListState
1314
import androidx.compose.foundation.lazy.itemsIndexed
1415
import androidx.compose.foundation.lazy.rememberLazyListState
16+
import androidx.compose.foundation.shape.RoundedCornerShape
1517
import androidx.compose.material.icons.Icons
18+
import androidx.compose.material.icons.filled.ArrowDownward
19+
import androidx.compose.material.icons.filled.ArrowUpward
20+
import androidx.compose.material.icons.filled.Close
1621
import androidx.compose.material.icons.filled.DragHandle
1722
import androidx.compose.material.icons.filled.KeyboardArrowDown
1823
import androidx.compose.material.icons.filled.KeyboardArrowUp
24+
import androidx.compose.material.icons.filled.OpenWith
1925
import androidx.compose.material3.Icon
2026
import androidx.compose.material3.IconButton
2127
import androidx.compose.material3.MaterialTheme
2228
import androidx.compose.material3.SegmentedButton
2329
import androidx.compose.material3.SegmentedButtonDefaults
2430
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
31+
import androidx.compose.material3.Surface
2532
import androidx.compose.material3.Text
33+
import androidx.compose.material3.surfaceColorAtElevation
2634
import androidx.compose.runtime.Composable
2735
import androidx.compose.runtime.getValue
2836
import androidx.compose.runtime.mutableStateOf
@@ -39,21 +47,41 @@ import sh.calvin.reorderable.rememberReorderableLazyListState
3947

4048
/**
4149
* Reorder mode for the reorderable list.
50+
*
51+
* SELECT is a switch-access-friendly two-step flow: pick an item, then pick
52+
* a destination. It moves any item to any position in two taps regardless of
53+
* distance, where ARROWS needs one tap per position.
4254
*/
4355
enum class ReorderMode {
4456
DRAG,
45-
ARROWS
57+
ARROWS,
58+
SELECT
4659
}
4760

61+
/**
62+
* Selection state and callbacks for [ReorderMode.SELECT]. Passing null to
63+
* [ReorderableList] hides the Select segmented button entirely.
64+
*/
65+
class SelectModeState<T>(
66+
val selectedKey: Any?,
67+
val getLabel: (T) -> String,
68+
val onPickUp: (T) -> Unit,
69+
val onCancel: () -> Unit,
70+
val onInsertBefore: (T) -> Unit,
71+
val onInsertAtEnd: () -> Unit
72+
)
73+
4874
/**
4975
* A generic reorderable list component that allows users to choose between
50-
* drag-and-drop or up/down arrow buttons for reordering items.
76+
* drag-and-drop, up/down arrow buttons, or a switch-friendly select-then-
77+
* insert flow for reordering items.
5178
*
5279
* @param T The type of items in the list
5380
* @param items The list of items to display
5481
* @param onMove Callback invoked when an item is moved from one position to another
5582
* @param key Function to extract a unique key from each item
56-
* @param defaultMode The default reorder mode (DRAG or ARROWS)
83+
* @param defaultMode The default reorder mode (DRAG, ARROWS, or SELECT)
84+
* @param selectModeState Selection state for SELECT mode. Null hides the Select toggle.
5785
* @param modifier Modifier for the component
5886
* @param itemContent Composable function to render each item. Receives the item,
5987
* whether it's being dragged, and the reorder controls composable
@@ -64,6 +92,7 @@ fun <T> ReorderableList(
6492
onMove: (fromIndex: Int, toIndex: Int) -> Unit,
6593
key: (T) -> Any,
6694
defaultMode: ReorderMode = ReorderMode.DRAG,
95+
selectModeState: SelectModeState<T>? = null,
6796
modifier: Modifier = Modifier,
6897
itemContent: @Composable (item: T, isDragging: Boolean, reorderControls: @Composable () -> Unit) -> Unit
6998
) {
@@ -73,7 +102,13 @@ fun <T> ReorderableList(
73102
// Mode toggle
74103
ReorderModeToggle(
75104
currentMode = currentMode,
76-
onModeChange = { currentMode = it },
105+
showSelect = selectModeState != null,
106+
onModeChange = { newMode ->
107+
if (currentMode == ReorderMode.SELECT && newMode != ReorderMode.SELECT) {
108+
selectModeState?.onCancel?.invoke()
109+
}
110+
currentMode = newMode
111+
},
77112
modifier = Modifier
78113
.fillMaxWidth()
79114
.padding(horizontal = 16.dp, vertical = 8.dp)
@@ -92,6 +127,20 @@ fun <T> ReorderableList(
92127
key = key,
93128
itemContent = itemContent
94129
)
130+
ReorderMode.SELECT -> {
131+
if (selectModeState != null) {
132+
SelectReorderableList(
133+
items = items,
134+
key = key,
135+
selectModeState = selectModeState,
136+
itemContent = itemContent
137+
)
138+
} else {
139+
// Defensive: if Select was the default but no state was supplied,
140+
// fall back to drag rather than rendering nothing.
141+
DragReorderableList(items, onMove, key, itemContent)
142+
}
143+
}
95144
}
96145
}
97146
}
@@ -102,6 +151,7 @@ fun <T> ReorderableList(
102151
@Composable
103152
private fun ReorderModeToggle(
104153
currentMode: ReorderMode,
154+
showSelect: Boolean,
105155
onModeChange: (ReorderMode) -> Unit,
106156
modifier: Modifier = Modifier
107157
) {
@@ -114,23 +164,35 @@ private fun ReorderModeToggle(
114164
inactiveBorderColor = MaterialTheme.colorScheme.outline
115165
)
116166

167+
val totalCount = if (showSelect) 3 else 2
168+
117169
SingleChoiceSegmentedButtonRow(modifier = modifier) {
118170
SegmentedButton(
119171
selected = currentMode == ReorderMode.DRAG,
120172
onClick = { onModeChange(ReorderMode.DRAG) },
121-
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
173+
shape = SegmentedButtonDefaults.itemShape(index = 0, count = totalCount),
122174
colors = colors
123175
) {
124176
Text(stringResource(R.string.reorder_mode_drag))
125177
}
126178
SegmentedButton(
127179
selected = currentMode == ReorderMode.ARROWS,
128180
onClick = { onModeChange(ReorderMode.ARROWS) },
129-
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
181+
shape = SegmentedButtonDefaults.itemShape(index = 1, count = totalCount),
130182
colors = colors
131183
) {
132184
Text(stringResource(R.string.reorder_mode_arrows))
133185
}
186+
if (showSelect) {
187+
SegmentedButton(
188+
selected = currentMode == ReorderMode.SELECT,
189+
onClick = { onModeChange(ReorderMode.SELECT) },
190+
shape = SegmentedButtonDefaults.itemShape(index = 2, count = totalCount),
191+
colors = colors
192+
) {
193+
Text(stringResource(R.string.reorder_mode_select))
194+
}
195+
}
134196
}
135197
}
136198

@@ -190,6 +252,62 @@ private fun <T> ArrowReorderableList(
190252
}
191253
}
192254

255+
/**
256+
* Select-then-insert reorderable list implementation. The reorder-controls
257+
* slot renders one of three buttons per row depending on selection state:
258+
*
259+
* - no selection → Move (picks the item up)
260+
* - this row is selected → Cancel (clears the selection)
261+
* - another row is selected → Insert above (places the selection before this row)
262+
*
263+
* A pinned "Insert at end" row is appended while a selection is active.
264+
*/
265+
@Composable
266+
private fun <T> SelectReorderableList(
267+
items: List<T>,
268+
key: (T) -> Any,
269+
selectModeState: SelectModeState<T>,
270+
itemContent: @Composable (item: T, isDragging: Boolean, reorderControls: @Composable () -> Unit) -> Unit
271+
) {
272+
val lazyListState: LazyListState = rememberLazyListState()
273+
val selectedKey = selectModeState.selectedKey
274+
val selectedLabel = remember(items, selectedKey) {
275+
items.firstOrNull { key(it) == selectedKey }?.let { selectModeState.getLabel(it) }
276+
}
277+
278+
LazyColumn(state = lazyListState) {
279+
itemsIndexed(items, key = { _, item -> key(item) }) { _, item ->
280+
val isSelected = key(item) == selectedKey
281+
val hasSelection = selectedKey != null
282+
itemContent(item, false) {
283+
when {
284+
isSelected -> SelectControlCancel(
285+
itemLabel = selectModeState.getLabel(item),
286+
onClick = selectModeState.onCancel
287+
)
288+
hasSelection -> SelectControlInsertAbove(
289+
selectedLabel = selectedLabel.orEmpty(),
290+
targetLabel = selectModeState.getLabel(item),
291+
onClick = { selectModeState.onInsertBefore(item) }
292+
)
293+
else -> SelectControlMove(
294+
itemLabel = selectModeState.getLabel(item),
295+
onClick = { selectModeState.onPickUp(item) }
296+
)
297+
}
298+
}
299+
}
300+
if (selectedKey != null) {
301+
item(key = "__select_insert_at_end__") {
302+
InsertAtEndRow(
303+
selectedLabel = selectedLabel.orEmpty(),
304+
onClick = selectModeState.onInsertAtEnd
305+
)
306+
}
307+
}
308+
}
309+
}
310+
193311
/**
194312
* Drag handle icon for drag mode.
195313
*/
@@ -254,3 +372,86 @@ private fun ArrowControls(
254372
}
255373
}
256374
}
375+
376+
@Composable
377+
private fun SelectControlMove(itemLabel: String, onClick: () -> Unit) {
378+
IconButton(onClick = onClick, modifier = Modifier.size(40.dp)) {
379+
Icon(
380+
imageVector = Icons.Default.OpenWith,
381+
contentDescription = stringResource(
382+
R.string.menu_customization_select_move_action,
383+
itemLabel
384+
),
385+
tint = MaterialTheme.colorScheme.onSurfaceVariant
386+
)
387+
}
388+
}
389+
390+
@Composable
391+
private fun SelectControlCancel(itemLabel: String, onClick: () -> Unit) {
392+
IconButton(onClick = onClick, modifier = Modifier.size(40.dp)) {
393+
Icon(
394+
imageVector = Icons.Default.Close,
395+
contentDescription = stringResource(
396+
R.string.menu_customization_select_cancel_action,
397+
itemLabel
398+
),
399+
tint = MaterialTheme.colorScheme.primary
400+
)
401+
}
402+
}
403+
404+
@Composable
405+
private fun SelectControlInsertAbove(
406+
selectedLabel: String,
407+
targetLabel: String,
408+
onClick: () -> Unit
409+
) {
410+
IconButton(onClick = onClick, modifier = Modifier.size(40.dp)) {
411+
Icon(
412+
imageVector = Icons.Default.ArrowUpward,
413+
contentDescription = stringResource(
414+
R.string.menu_customization_select_insert_above_action,
415+
selectedLabel,
416+
targetLabel
417+
),
418+
tint = MaterialTheme.colorScheme.primary
419+
)
420+
}
421+
}
422+
423+
@Composable
424+
private fun InsertAtEndRow(selectedLabel: String, onClick: () -> Unit) {
425+
Surface(
426+
onClick = onClick,
427+
modifier = Modifier
428+
.fillMaxWidth()
429+
.padding(vertical = 4.dp),
430+
color = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp),
431+
shape = RoundedCornerShape(8.dp)
432+
) {
433+
Row(
434+
modifier = Modifier
435+
.fillMaxWidth()
436+
.padding(16.dp),
437+
verticalAlignment = Alignment.CenterVertically,
438+
horizontalArrangement = Arrangement.spacedBy(12.dp)
439+
) {
440+
Icon(
441+
imageVector = Icons.Default.ArrowDownward,
442+
contentDescription = null,
443+
tint = MaterialTheme.colorScheme.primary,
444+
modifier = Modifier.size(24.dp)
445+
)
446+
Text(
447+
text = stringResource(
448+
R.string.menu_customization_select_insert_at_end_action,
449+
selectedLabel
450+
),
451+
style = MaterialTheme.typography.bodyLarge,
452+
color = MaterialTheme.colorScheme.primary,
453+
modifier = Modifier.weight(1f)
454+
)
455+
}
456+
}
457+
}

0 commit comments

Comments
 (0)