Skip to content

Commit 5391a5b

Browse files
committed
Make LongPressMenuEditor work with DPAD / Android TV
1 parent 3e37912 commit 5391a5b

2 files changed

Lines changed: 201 additions & 60 deletions

File tree

app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenu.kt

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import android.content.Context
77
import android.content.res.Configuration
88
import android.view.ViewGroup
99
import android.view.ViewGroup.LayoutParams
10-
import androidx.activity.compose.BackHandler
1110
import androidx.compose.foundation.clickable
1211
import androidx.compose.foundation.isSystemInDarkTheme
1312
import androidx.compose.foundation.layout.Arrangement
@@ -69,6 +68,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
6968
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
7069
import androidx.compose.ui.tooling.preview.datasource.LoremIpsum
7170
import androidx.compose.ui.unit.dp
71+
import androidx.compose.ui.window.Dialog
72+
import androidx.compose.ui.window.DialogProperties
7273
import coil3.compose.AsyncImage
7374
import org.schabi.newpipe.R
7475
import org.schabi.newpipe.extractor.stream.StreamType
@@ -126,14 +127,16 @@ fun LongPressMenu(
126127

127128
if (showEditor) {
128129
// we can't put the editor in a bottom sheet, because it relies on dragging gestures
129-
ScaffoldWithToolbar(
130-
title = stringResource(R.string.long_press_menu_actions_editor),
131-
onBackClick = { showEditor = false },
132-
) { paddingValues ->
133-
Box(modifier = Modifier.padding(paddingValues)) {
134-
LongPressMenuEditor()
130+
Dialog(
131+
onDismissRequest = { showEditor = false },
132+
properties = DialogProperties(usePlatformDefaultWidth = false)
133+
) {
134+
ScaffoldWithToolbar(
135+
title = stringResource(R.string.long_press_menu_actions_editor),
136+
onBackClick = { showEditor = false },
137+
) { paddingValues ->
138+
LongPressMenuEditor(modifier = Modifier.padding(paddingValues))
135139
}
136-
BackHandler { showEditor = false }
137140
}
138141
} else {
139142
ModalBottomSheet(

app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt

Lines changed: 190 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ package org.schabi.newpipe.ui.components.menu
2121
import androidx.annotation.StringRes
2222
import androidx.compose.foundation.BorderStroke
2323
import androidx.compose.foundation.border
24+
import androidx.compose.foundation.layout.BoxWithConstraints
2425
import androidx.compose.foundation.layout.Column
2526
import androidx.compose.foundation.layout.fillMaxWidth
2627
import androidx.compose.foundation.layout.offset
@@ -54,9 +55,15 @@ import androidx.compose.runtime.setValue
5455
import androidx.compose.runtime.toMutableStateList
5556
import androidx.compose.ui.Alignment
5657
import androidx.compose.ui.Modifier
58+
import androidx.compose.ui.focus.focusTarget
5759
import androidx.compose.ui.geometry.Offset
5860
import androidx.compose.ui.graphics.Color
5961
import androidx.compose.ui.graphics.vector.ImageVector
62+
import androidx.compose.ui.input.key.Key
63+
import androidx.compose.ui.input.key.KeyEventType
64+
import androidx.compose.ui.input.key.key
65+
import androidx.compose.ui.input.key.onKeyEvent
66+
import androidx.compose.ui.input.key.type
6067
import androidx.compose.ui.platform.LocalDensity
6168
import androidx.compose.ui.res.stringResource
6269
import androidx.compose.ui.text.font.FontStyle
@@ -69,18 +76,21 @@ import androidx.compose.ui.unit.IntSize
6976
import androidx.compose.ui.unit.dp
7077
import androidx.compose.ui.unit.toSize
7178
import org.schabi.newpipe.R
79+
import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Companion.DefaultEnabledActions
7280
import org.schabi.newpipe.ui.detectDragGestures
7381
import org.schabi.newpipe.ui.theme.AppTheme
7482
import org.schabi.newpipe.util.text.FixedHeightCenteredText
83+
import kotlin.math.floor
7584
import kotlin.math.min
7685

77-
// TODO implement accessibility for this, to allow using this with a DPAD (e.g. Android TV)
86+
// TODO padding doesn't seem to work as expected when the list becomes scrollable?
87+
// TODO does Android TV auto-scroll to the selected item when the list becomes scrollable?
7888
@Composable
79-
fun LongPressMenuEditor() {
89+
fun LongPressMenuEditor(modifier: Modifier = Modifier) {
8090
// We get the current arrangement once and do not observe on purpose
8191
// TODO load from settings
82-
val headerEnabled = remember { false } // true }
83-
val actionArrangement = remember { LongPressAction.Type.entries } // DefaultEnabledActions }
92+
val headerEnabled = remember { true }
93+
val actionArrangement = remember { DefaultEnabledActions }
8494
val items = remember(headerEnabled, actionArrangement) {
8595
sequence {
8696
yield(ItemInList.EnabledCaption)
@@ -127,8 +137,8 @@ fun LongPressMenuEditor() {
127137
return closestItemInRow
128138
}
129139

130-
fun beginDragGesture(pos: IntOffset) {
131-
val rawItem = findItemForOffsetOrClosestInRow(pos) ?: return
140+
fun beginDragGesture(pos: IntOffset, rawItem: LazyGridItemInfo) {
141+
if (activeDragItem != null) return
132142
val item = items.getOrNull(rawItem.index) ?: return
133143
if (item.isDraggable) {
134144
items[rawItem.index] = ItemInList.DragMarker(item.columnSpan)
@@ -138,35 +148,42 @@ fun LongPressMenuEditor() {
138148
}
139149
}
140150

141-
fun handleDragGestureChange(pos: IntOffset, posChange: Offset) {
142-
val dragItem = activeDragItem
143-
if (dragItem == null) {
144-
// when the user clicks outside of any draggable item, let the list be scrolled
145-
gridState.dispatchRawDelta(-posChange.y)
146-
return
147-
}
148-
activeDragPosition = pos
151+
fun beginDragGesture(pos: IntOffset) {
149152
val rawItem = findItemForOffsetOrClosestInRow(pos) ?: return
153+
beginDragGesture(pos, rawItem)
154+
}
155+
156+
fun handleDragGestureChange(dragItem: ItemInList, rawItem: LazyGridItemInfo) {
157+
val prevDragMarkerIndex = items.indexOfFirst { it is ItemInList.DragMarker }
158+
.takeIf { it >= 0 } ?: return // impossible situation, DragMarker is always in the list
150159

151160
// compute where the DragMarker will go (we need to do special logic to make sure the
152161
// HeaderBox always sticks right after EnabledCaption or HiddenCaption)
153162
val nextDragMarkerIndex = if (dragItem == ItemInList.HeaderBox) {
154163
val hiddenCaptionIndex = items.indexOf(ItemInList.HiddenCaption)
155164
if (rawItem.index < hiddenCaptionIndex) {
156165
1 // i.e. right after the EnabledCaption
166+
} else if (prevDragMarkerIndex < hiddenCaptionIndex) {
167+
hiddenCaptionIndex // i.e. right after the HiddenCaption
157168
} else {
158169
hiddenCaptionIndex + 1 // i.e. right after the HiddenCaption
159170
}
160171
} else {
161172
var i = rawItem.index
162173
// make sure it is not possible to move items in between a *Caption and a HeaderBox
163-
if (!items[i].isDraggable) i += 1
164-
if (items[i] == ItemInList.HeaderBox) i += 1
174+
val offsetForRemovingPrev = if (prevDragMarkerIndex < rawItem.index) 1 else 0
175+
if (!items[i - offsetForRemovingPrev].isDraggable) i += 1
176+
if (items[i - offsetForRemovingPrev] == ItemInList.HeaderBox) i += 1
165177
i
166178
}
167179

180+
// no need to do anything if the DragMarker is already at the right place
181+
if (prevDragMarkerIndex == nextDragMarkerIndex) {
182+
return
183+
}
184+
168185
// adjust the position of the DragMarker
169-
items.removeIf { it is ItemInList.DragMarker }
186+
items.removeAt(prevDragMarkerIndex)
170187
items.add(min(nextDragMarkerIndex, items.size), ItemInList.DragMarker(dragItem.columnSpan))
171188

172189
// add or remove NoneMarkers as needed
@@ -179,6 +196,18 @@ fun LongPressMenuEditor() {
179196
}
180197
}
181198

199+
fun handleDragGestureChange(pos: IntOffset, posChangeForScrolling: Offset) {
200+
val dragItem = activeDragItem
201+
if (dragItem == null) {
202+
// when the user clicks outside of any draggable item, let the list be scrolled
203+
gridState.dispatchRawDelta(-posChangeForScrolling.y)
204+
return
205+
}
206+
activeDragPosition = pos
207+
val rawItem = findItemForOffsetOrClosestInRow(pos) ?: return
208+
handleDragGestureChange(dragItem, rawItem)
209+
}
210+
182211
fun completeDragGestureAndCleanUp() {
183212
val dragItem = activeDragItem
184213
if (dragItem != null) {
@@ -199,44 +228,153 @@ fun LongPressMenuEditor() {
199228
}
200229
}
201230

202-
LazyVerticalGrid(
203-
modifier = Modifier
204-
.safeDrawingPadding()
205-
.detectDragGestures(
206-
beginDragGesture = ::beginDragGesture,
207-
handleDragGestureChange = ::handleDragGestureChange,
208-
endDragGesture = ::completeDragGestureAndCleanUp,
209-
),
210-
// same width as the LongPressMenu
211-
columns = GridCells.Adaptive(MinButtonWidth),
212-
userScrollEnabled = false,
213-
state = gridState,
214-
) {
215-
itemsIndexed(
216-
items,
217-
key = { _, item -> item.stableUniqueKey() },
218-
span = { _, item -> GridItemSpan(item.columnSpan ?: maxLineSpan) },
219-
) { i, item ->
231+
BoxWithConstraints(modifier) {
232+
// otherwise we wouldn't know the amount of columns to handle the Up/Down key events
233+
val columns = maxOf(1, floor(this.maxWidth / MinButtonWidth).toInt())
234+
LazyVerticalGrid(
235+
modifier = Modifier
236+
.safeDrawingPadding()
237+
.detectDragGestures(
238+
beginDragGesture = ::beginDragGesture,
239+
handleDragGestureChange = ::handleDragGestureChange,
240+
endDragGesture = ::completeDragGestureAndCleanUp,
241+
)
242+
.focusTarget()
243+
.onKeyEvent { event ->
244+
if (event.type != KeyEventType.KeyDown) {
245+
if (event.type == KeyEventType.KeyUp &&
246+
event.key == Key.DirectionDown &&
247+
currentlyFocusedItem < 0
248+
) {
249+
//
250+
currentlyFocusedItem = 0
251+
}
252+
return@onKeyEvent false
253+
}
254+
var focusedItem = currentlyFocusedItem
255+
when (event.key) {
256+
Key.DirectionUp -> {
257+
if (focusedItem < 0) {
258+
return@onKeyEvent false
259+
} else if (items[focusedItem].columnSpan == null) {
260+
focusedItem -= 1
261+
} else {
262+
var remaining = columns
263+
while (true) {
264+
focusedItem -= 1
265+
if (focusedItem < 0) {
266+
break
267+
}
268+
remaining -= items[focusedItem].columnSpan ?: columns
269+
if (remaining <= 0) {
270+
break
271+
}
272+
}
273+
}
274+
}
275+
276+
Key.DirectionDown -> {
277+
if (focusedItem >= items.size - 1) {
278+
return@onKeyEvent false
279+
} else if (items[focusedItem].columnSpan == null) {
280+
focusedItem += 1
281+
} else {
282+
var remaining = columns
283+
while (true) {
284+
focusedItem += 1
285+
if (focusedItem >= items.size - 1) {
286+
break
287+
}
288+
remaining -= items[focusedItem].columnSpan ?: columns
289+
if (remaining <= 0) {
290+
break
291+
}
292+
}
293+
}
294+
}
295+
296+
Key.DirectionLeft -> {
297+
if (focusedItem < 0) {
298+
return@onKeyEvent false
299+
} else {
300+
focusedItem -= 1
301+
}
302+
}
303+
304+
Key.DirectionRight -> {
305+
if (focusedItem >= items.size - 1) {
306+
return@onKeyEvent false
307+
} else {
308+
focusedItem += 1
309+
}
310+
}
311+
312+
Key.Enter, Key.NumPadEnter, Key.DirectionCenter -> if (activeDragItem == null) {
313+
val rawItem = gridState.layoutInfo.visibleItemsInfo
314+
.firstOrNull { it.index == focusedItem }
315+
?: return@onKeyEvent false
316+
beginDragGesture(rawItem.offset, rawItem)
317+
return@onKeyEvent true
318+
} else {
319+
completeDragGestureAndCleanUp()
320+
return@onKeyEvent true
321+
}
322+
323+
else -> return@onKeyEvent false
324+
}
325+
326+
currentlyFocusedItem = focusedItem
327+
if (focusedItem < 0) {
328+
// not checking for focusedItem>=items.size because it's impossible for it
329+
// to reach that value, and that's because we assume that there is nothing
330+
// else focusable *after* this view. This way we don't need to cleanup the
331+
// drag gestures when the user reaches the end, which would be confusing as
332+
// then there would be no indication of the current cursor position at all.
333+
completeDragGestureAndCleanUp()
334+
return@onKeyEvent false
335+
}
336+
337+
val dragItem = activeDragItem
338+
if (dragItem != null) {
339+
val rawItem = gridState.layoutInfo.visibleItemsInfo
340+
.firstOrNull { it.index == focusedItem }
341+
?: return@onKeyEvent false
342+
activeDragPosition = rawItem.offset
343+
handleDragGestureChange(dragItem, rawItem)
344+
}
345+
return@onKeyEvent true
346+
},
347+
// same width as the LongPressMenu
348+
columns = GridCells.Adaptive(MinButtonWidth),
349+
userScrollEnabled = false,
350+
state = gridState,
351+
) {
352+
itemsIndexed(
353+
items,
354+
key = { _, item -> item.stableUniqueKey() },
355+
span = { _, item -> GridItemSpan(item.columnSpan ?: maxLineSpan) },
356+
) { i, item ->
357+
ItemInListUi(
358+
item = item,
359+
selected = currentlyFocusedItem == i,
360+
modifier = Modifier.animateItem()
361+
)
362+
}
363+
}
364+
if (activeDragItem != null) {
365+
val size = with(LocalDensity.current) {
366+
remember(activeDragSize) { activeDragSize.toSize().toDpSize() }
367+
}
220368
ItemInListUi(
221-
item = item,
222-
selected = currentlyFocusedItem == i,
223-
modifier = Modifier.animateItem()
369+
item = activeDragItem!!,
370+
selected = false,
371+
modifier = Modifier
372+
.size(size)
373+
.offset { activeDragPosition }
374+
.offset(-size.width / 2, -size.height / 2),
224375
)
225376
}
226377
}
227-
if (activeDragItem != null) {
228-
val size = with(LocalDensity.current) {
229-
remember(activeDragSize) { activeDragSize.toSize().toDpSize() }
230-
}
231-
ItemInListUi(
232-
item = activeDragItem!!,
233-
selected = true,
234-
modifier = Modifier
235-
.size(size)
236-
.offset { activeDragPosition }
237-
.offset(-size.width / 2, -size.height / 2),
238-
)
239-
}
240378
}
241379

242380
sealed class ItemInList(val isDraggable: Boolean, open val columnSpan: Int? = 1) {
@@ -306,7 +444,7 @@ private fun ActionOrHeaderBox(
306444
color = backgroundColor,
307445
contentColor = contentColor,
308446
shape = MaterialTheme.shapes.large,
309-
border = BorderStroke(2.dp, contentColor).takeIf { selected },
447+
border = BorderStroke(2.dp, contentColor.copy(alpha = 1f)).takeIf { selected },
310448
modifier = modifier.padding(
311449
horizontal = horizontalPadding,
312450
vertical = 5.dp,

0 commit comments

Comments
 (0)