11package com.enaboapps.switchify.components
22
33import androidx.compose.foundation.layout.Arrangement
4+ import androidx.compose.foundation.layout.Box
45import androidx.compose.foundation.layout.Column
56import androidx.compose.foundation.layout.Row
67import androidx.compose.foundation.layout.Spacer
@@ -12,17 +13,24 @@ import androidx.compose.foundation.lazy.LazyColumn
1213import androidx.compose.foundation.lazy.LazyListState
1314import androidx.compose.foundation.lazy.itemsIndexed
1415import androidx.compose.foundation.lazy.rememberLazyListState
16+ import androidx.compose.foundation.shape.RoundedCornerShape
1517import 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
1621import androidx.compose.material.icons.filled.DragHandle
1722import androidx.compose.material.icons.filled.KeyboardArrowDown
1823import androidx.compose.material.icons.filled.KeyboardArrowUp
24+ import androidx.compose.material.icons.filled.OpenWith
1925import androidx.compose.material3.Icon
2026import androidx.compose.material3.IconButton
2127import androidx.compose.material3.MaterialTheme
2228import androidx.compose.material3.SegmentedButton
2329import androidx.compose.material3.SegmentedButtonDefaults
2430import androidx.compose.material3.SingleChoiceSegmentedButtonRow
31+ import androidx.compose.material3.Surface
2532import androidx.compose.material3.Text
33+ import androidx.compose.material3.surfaceColorAtElevation
2634import androidx.compose.runtime.Composable
2735import androidx.compose.runtime.getValue
2836import 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 */
4355enum 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
103152private 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