TvLongPress.kt
Each line, with the release that last changed it.
| 0.5.0 | 1 | package dev.stave.ui.components |
| 2 | ||
| 3 | import androidx.compose.runtime.Composable | |
| 4 | import androidx.compose.runtime.mutableStateOf | |
| 5 | import androidx.compose.runtime.remember | |
| 6 | import androidx.compose.ui.Modifier | |
| 7 | import androidx.compose.ui.input.key.Key | |
| 8 | import androidx.compose.ui.input.key.KeyEventType | |
| 9 | import androidx.compose.ui.input.key.key | |
| 10 | import androidx.compose.ui.input.key.onPreviewKeyEvent | |
| 11 | import androidx.compose.ui.input.key.type | |
| 12 | ||
| 13 | /** | |
| 14 | * D-pad long-press for TV remotes. | |
| 15 | * | |
| 16 | * `Modifier.combinedClickable(onLongClick = ...)` detects long-press via pointer input (touch). | |
| 17 | * On an Android TV remote the select button arrives as discrete KeyDown/KeyUp events, and the | |
| 18 | * gesture recognizer never promotes a held select into a long-press — the user gets a regular | |
| 19 | * click on release instead (exactly what they reported: "inadvertently selected the thing I | |
| 20 | * was trying to long-press on"). | |
| 21 | * | |
| 22 | * This modifier listens in the preview phase: | |
| 23 | * | |
| 24 | * 1. Android sends a synthetic KeyDown with `FLAG_LONG_PRESS` set once the key has been held | |
| 25 | * past the system long-press threshold (~500ms, configurable). We mark the press as | |
| 26 | * "ready to long-press on release" and consume the event so it doesn't cascade. | |
| 27 | * 2. The user releases. We see KeyUp; if the flag is set, NOW we fire onLongPress and consume | |
| 28 | * the KeyUp so the enclosing `clickable`/`combinedClickable` doesn't also fire `onClick`. | |
| 29 | * | |
| 30 | * Why fire on KeyUp rather than on the LONG_PRESS KeyDown: if onLongPress synchronously moves | |
| 31 | * focus (the common case — it opens an ActionSheet that auto-focuses its first item), the | |
| 32 | * subsequent KeyUp gets dispatched to whatever element is focused at that point. With the old | |
| 33 | * code that meant the freshly-opened sheet's first action received a synthetic select-press | |
| 34 | * and immediately fired its onClick — exactly the bug the user reported in the queue | |
| 35 | * (long-pressing an item would trip the first action of the sheet that opened). By deferring | |
| 36 | * the focus-changing call to KeyUp, the entire press cycle completes on the original element | |
| 37 | * before any focus moves. | |
| 38 | * | |
| 39 | * Apply BEFORE `clickable` in the modifier chain so the KeyUp we consume never reaches it. | |
| 40 | * Touch long-press (e.g. Android TV with a mouse/touch emulator) still works via combinedClickable. | |
| 41 | */ | |
| 42 | @Composable | |
| 43 | fun Modifier.onDpadLongPress(onLongPress: (() -> Unit)?): Modifier { | |
| 44 | if (onLongPress == null) return this | |
| 45 | // pendingLongPress tracks "we observed the LONG_PRESS KeyDown, fire onLongPress on the | |
| 46 | // next KeyUp". Reset to false after firing so a second held-press starts fresh. | |
| 47 | val pendingLongPress = remember { mutableStateOf(false) } | |
| 48 | return this.onPreviewKeyEvent { event -> | |
| 49 | val isSelect = event.key == Key.DirectionCenter || | |
| 50 | event.key == Key.Enter || | |
| 51 | event.key == Key.NumPadEnter | |
| 52 | if (!isSelect) return@onPreviewKeyEvent false | |
| 53 | when { | |
| 54 | // Long-press threshold reached. Don't fire yet — wait for KeyUp so any focus- | |
| 55 | // moving action (e.g. opening a sheet) happens AFTER the KeyUp is consumed and | |
| 56 | // can't be misdirected to the newly-focused element. | |
| 57 | event.type == KeyEventType.KeyDown && event.nativeKeyEvent.isLongPress -> { | |
| 58 | pendingLongPress.value = true | |
| 59 | true | |
| 60 | } | |
| 61 | // Release after a long-press KeyDown was observed: now fire onLongPress and | |
| 62 | // consume the KeyUp so the enclosing clickable doesn't ALSO fire its onClick. | |
| 63 | event.type == KeyEventType.KeyUp && pendingLongPress.value -> { | |
| 64 | pendingLongPress.value = false | |
| 65 | onLongPress() | |
| 66 | true | |
| 67 | } | |
| 68 | else -> false | |
| 69 | } | |
| 70 | } | |
| 71 | } |


