diff --git a/CHANGELOG.md b/CHANGELOG.md
index 23d05d7c4f..3bbeb134f6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,9 @@
+## Unreleased
+
+## Changed
+
+- #1369 Add content descriptions to drag handles and custom "Move up"/"Move down" accessibility actions for trigger and action list items, improving TalkBack support for reordering.
+
## [4.1.1](https://github.com/sds100/KeyMapper/releases/tag/v4.1.1)
#### 15 May 2026
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionListItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionListItem.kt
index f842381aae..e4e00967d4 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionListItem.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionListItem.kt
@@ -39,6 +39,9 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.CustomAccessibilityAction
+import androidx.compose.ui.semantics.customActions
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -62,11 +65,16 @@ fun ActionListItem(
onRemoveClick: () -> Unit = {},
onFixClick: () -> Unit = {},
onTestClick: () -> Unit = {},
+ onMoveUp: (() -> Unit)? = null,
+ onMoveDown: (() -> Unit)? = null,
) {
val draggableState = rememberDraggableState {
dragDropState?.onDrag(Offset(0f, it))
}
+ val moveUpLabel = stringResource(R.string.accessibility_action_move_up)
+ val moveDownLabel = stringResource(R.string.accessibility_action_move_down)
+
Column(modifier = modifier.fillMaxWidth()) {
ElevatedCard(
modifier = Modifier
@@ -83,7 +91,19 @@ fun ActionListItem(
dragDropState?.onDragStart(index, offset)
},
onDragStopped = { dragDropState?.onDragInterrupted() },
- ),
+ )
+ .semantics {
+ if (isReorderingEnabled) {
+ customActions = buildList {
+ onMoveUp?.let { action ->
+ add(CustomAccessibilityAction(moveUpLabel) { action(); true })
+ }
+ onMoveDown?.let { action ->
+ add(CustomAccessibilityAction(moveDownLabel) { action(); true })
+ }
+ }
+ }
+ },
colors = CardDefaults.elevatedCardColors(
containerColor = if (isDragging) {
MaterialTheme.colorScheme.surfaceContainerHighest
@@ -102,7 +122,7 @@ fun ActionListItem(
Icon(
modifier = Modifier.size(24.dp),
imageVector = Icons.Rounded.DragHandle,
- contentDescription = null,
+ contentDescription = stringResource(R.string.drag_handle_for, model.text),
tint = MaterialTheme.colorScheme.onSurface,
)
}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionsScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionsScreen.kt
index b7fec30f2c..4198aa2287 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionsScreen.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionsScreen.kt
@@ -314,6 +314,16 @@ private fun ActionList(
onRemoveClick = { onRemoveClick(model.id) },
onFixClick = { onFixErrorClick(model.id) },
onTestClick = { onTestClick(model.id) },
+ onMoveUp = if (isReorderingEnabled && index > 0) {
+ { onMove(index, index - 1) }
+ } else {
+ null
+ },
+ onMoveDown = if (isReorderingEnabled && index < actionList.size - 1) {
+ { onMove(index, index + 1) }
+ } else {
+ null
+ },
)
}
}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseTriggerScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseTriggerScreen.kt
index 735ae05da8..2415f99184 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseTriggerScreen.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseTriggerScreen.kt
@@ -482,6 +482,16 @@ private fun TriggerList(
onEditClick = { onEditClick(model.id) },
onRemoveClick = { onRemoveClick(model.id) },
onFixClick = onFixErrorClick,
+ onMoveUp = if (isReorderingEnabled && index > 0) {
+ { onMove(index, index - 1) }
+ } else {
+ null
+ },
+ onMoveDown = if (isReorderingEnabled && index < triggerList.size - 1) {
+ { onMove(index, index + 1) }
+ } else {
+ null
+ },
)
}
}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyListItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyListItem.kt
index 8053721ef3..a1e4ee66a4 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyListItem.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyListItem.kt
@@ -39,6 +39,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.CustomAccessibilityAction
+import androidx.compose.ui.semantics.customActions
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -60,11 +63,67 @@ fun TriggerKeyListItem(
onEditClick: () -> Unit = {},
onRemoveClick: () -> Unit = {},
onFixClick: (TriggerError) -> Unit = {},
+ onMoveUp: (() -> Unit)? = null,
+ onMoveDown: (() -> Unit)? = null,
) {
val draggableState = rememberDraggableState {
dragDropState?.onDrag(Offset(0f, it))
}
+ val primaryText = when (model) {
+ is TriggerKeyListItemModel.Assistant -> when (model.assistantType) {
+ AssistantTriggerType.ANY -> stringResource(
+ R.string.assistant_any_trigger_name,
+ )
+
+ AssistantTriggerType.VOICE -> stringResource(
+ R.string.assistant_voice_trigger_name,
+ )
+
+ AssistantTriggerType.DEVICE -> stringResource(
+ R.string.assistant_device_trigger_name,
+ )
+ }
+
+ is TriggerKeyListItemModel.FloatingButton -> if (model.buttonName.isBlank()) {
+ stringResource(R.string.trigger_key_floating_button_description_empty)
+ } else {
+ stringResource(
+ R.string.trigger_key_floating_button_description,
+ model.buttonName,
+ )
+ }
+
+ is TriggerKeyListItemModel.KeyEvent -> model.keyName
+
+ is TriggerKeyListItemModel.EvdevEvent -> model.keyName
+
+ is TriggerKeyListItemModel.FloatingButtonDeleted -> stringResource(
+ R.string.trigger_error_floating_button_deleted_title,
+ )
+
+ is TriggerKeyListItemModel.FingerprintGesture -> when (model.gestureType) {
+ FingerprintGestureType.SWIPE_UP -> stringResource(
+ R.string.trigger_key_fingerprint_gesture_up,
+ )
+
+ FingerprintGestureType.SWIPE_DOWN -> stringResource(
+ R.string.trigger_key_fingerprint_gesture_down,
+ )
+
+ FingerprintGestureType.SWIPE_LEFT -> stringResource(
+ R.string.trigger_key_fingerprint_gesture_left,
+ )
+
+ FingerprintGestureType.SWIPE_RIGHT -> stringResource(
+ R.string.trigger_key_fingerprint_gesture_right,
+ )
+ }
+ }
+
+ val moveUpLabel = stringResource(R.string.accessibility_action_move_up)
+ val moveDownLabel = stringResource(R.string.accessibility_action_move_down)
+
Column(modifier = modifier.fillMaxWidth()) {
ElevatedCard(
modifier = Modifier
@@ -81,7 +140,19 @@ fun TriggerKeyListItem(
dragDropState?.onDragStart(index, offset)
},
onDragStopped = { dragDropState?.onDragInterrupted() },
- ),
+ )
+ .semantics {
+ if (isReorderingEnabled) {
+ customActions = buildList {
+ onMoveUp?.let { action ->
+ add(CustomAccessibilityAction(moveUpLabel) { action(); true })
+ }
+ onMoveDown?.let { action ->
+ add(CustomAccessibilityAction(moveDownLabel) { action(); true })
+ }
+ }
+ }
+ },
colors = CardDefaults.elevatedCardColors(
containerColor = if (isDragging) {
MaterialTheme.colorScheme.surfaceContainerHighest
@@ -100,7 +171,7 @@ fun TriggerKeyListItem(
Icon(
modifier = Modifier.size(24.dp),
imageVector = Icons.Rounded.DragHandle,
- contentDescription = null,
+ contentDescription = stringResource(R.string.drag_handle_for, primaryText),
tint = MaterialTheme.colorScheme.onSurface,
)
}
@@ -127,57 +198,6 @@ fun TriggerKeyListItem(
}
}
- val primaryText = when (model) {
- is TriggerKeyListItemModel.Assistant -> when (model.assistantType) {
- AssistantTriggerType.ANY -> stringResource(
- R.string.assistant_any_trigger_name,
- )
-
- AssistantTriggerType.VOICE -> stringResource(
- R.string.assistant_voice_trigger_name,
- )
-
- AssistantTriggerType.DEVICE -> stringResource(
- R.string.assistant_device_trigger_name,
- )
- }
-
- is TriggerKeyListItemModel.FloatingButton -> if (model.buttonName.isBlank()) {
- stringResource(R.string.trigger_key_floating_button_description_empty)
- } else {
- stringResource(
- R.string.trigger_key_floating_button_description,
- model.buttonName,
- )
- }
-
- is TriggerKeyListItemModel.KeyEvent -> model.keyName
-
- is TriggerKeyListItemModel.EvdevEvent -> model.keyName
-
- is TriggerKeyListItemModel.FloatingButtonDeleted -> stringResource(
- R.string.trigger_error_floating_button_deleted_title,
- )
-
- is TriggerKeyListItemModel.FingerprintGesture -> when (model.gestureType) {
- FingerprintGestureType.SWIPE_UP -> stringResource(
- R.string.trigger_key_fingerprint_gesture_up,
- )
-
- FingerprintGestureType.SWIPE_DOWN -> stringResource(
- R.string.trigger_key_fingerprint_gesture_down,
- )
-
- FingerprintGestureType.SWIPE_LEFT -> stringResource(
- R.string.trigger_key_fingerprint_gesture_left,
- )
-
- FingerprintGestureType.SWIPE_RIGHT -> stringResource(
- R.string.trigger_key_fingerprint_gesture_right,
- )
- }
- }
-
Spacer(Modifier.width(8.dp))
if (model.error == null) {
diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml
index a081813537..c7ff462120 100644
--- a/base/src/main/res/values/strings.xml
+++ b/base/src/main/res/values/strings.xml
@@ -486,6 +486,8 @@
Drag the handles to adjust priorities. The item at the top is the most important. You must tap the item to enable sorting and toggle ascending/descending.
Example: To sort by Actions (ascending) first and Triggers (descending) second: tap and drag Actions to the first position and set it to ascending, then tap and drag Triggers to the second position and set it to descending.
Drag handle for %1$s
+ Move up
+ Move down
Show example
Turn on notifications