Separate @Composables from state logic for actions editor

This commit is contained in:
Stypox 2026-01-06 21:31:25 +01:00
parent 1a42f300f2
commit 3d62b923c7
No known key found for this signature in database
GPG Key ID: 4BDF1B40A49FDD23
3 changed files with 430 additions and 379 deletions

View File

@ -0,0 +1,12 @@
package org.schabi.newpipe.ktx
/**
* Especially useful to apply some Compose Modifiers only if some condition is met. E.g.
* ```kt
* Modifier
* .padding(left = 4.dp)
* .letIf(someCondition) { padding(right = 4.dp) }
* ```
*/
inline fun <T> T.letIf(condition: Boolean, block: T.() -> T): T =
if (condition) block(this) else this

View File

@ -18,11 +18,9 @@
package org.schabi.newpipe.ui.components.menu
import android.util.Log
import androidx.annotation.StringRes
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
@ -33,8 +31,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyGridItemInfo
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
@ -50,25 +46,14 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusTarget
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
@ -76,28 +61,20 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toSize
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.schabi.newpipe.R
import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Companion.DefaultEnabledActions
import org.schabi.newpipe.ktx.letIf
import org.schabi.newpipe.ui.detectDragGestures
import org.schabi.newpipe.ui.theme.AppTheme
import org.schabi.newpipe.util.text.FixedHeightCenteredText
import kotlin.math.abs
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min
internal const val TAG = "LongPressMenuEditor"
/**
* When making changes to this composable, make sure to test the following use cases still work:
* When making changes to this composable and to [LongPressMenuEditorState], make sure to test the
* following use cases, and check that they still work:
* - both the actions and the header can be dragged around
* - the header can only be dragged to the first position in each section
* - when a section is empty the None marker will appear
@ -112,174 +89,15 @@ internal const val TAG = "LongPressMenuEditor"
*/
@Composable
fun LongPressMenuEditor(modifier: Modifier = Modifier) {
// We get the current arrangement once and do not observe on purpose
// TODO load from settings
val headerEnabled = remember { true }
val actionArrangement = remember { DefaultEnabledActions }
val items = remember(headerEnabled, actionArrangement) {
sequence {
yield(ItemInList.EnabledCaption)
if (headerEnabled) {
yield(ItemInList.HeaderBox)
}
yieldAll(
actionArrangement
.map { ItemInList.Action(it) }
.ifEmpty { if (headerEnabled) listOf() else listOf(ItemInList.NoneMarker) }
)
yield(ItemInList.HiddenCaption)
if (!headerEnabled) {
yield(ItemInList.HeaderBox)
}
yieldAll(
LongPressAction.Type.entries
.filter { !actionArrangement.contains(it) }
.map { ItemInList.Action(it) }
.ifEmpty { if (headerEnabled) listOf(ItemInList.NoneMarker) else listOf() }
)
}.toList().toMutableStateList()
}
// variables for handling drag, focus, and autoscrolling when finger is at top/bottom
val gridState = rememberLazyGridState()
var activeDragItem by remember { mutableStateOf<ItemInList?>(null) }
var activeDragPosition by remember { mutableStateOf(IntOffset.Zero) }
var activeDragSize by remember { mutableStateOf(IntSize.Zero) }
var currentlyFocusedItem by remember { mutableIntStateOf(-1) }
val coroutineScope = rememberCoroutineScope()
var autoScrollJob by remember { mutableStateOf<Job?>(null) }
var autoScrollSpeed by remember { mutableFloatStateOf(0f) } // -1, 0 or 1
fun findItemForOffsetOrClosestInRow(offset: IntOffset): LazyGridItemInfo? {
var closestItemInRow: LazyGridItemInfo? = null
// Using manual for loop with indices instead of firstOrNull() because this method gets
// called a lot and firstOrNull allocates an iterator for each call
for (index in gridState.layoutInfo.visibleItemsInfo.indices) {
val item = gridState.layoutInfo.visibleItemsInfo[index]
if (offset.y in item.offset.y..(item.offset.y + item.size.height)) {
if (offset.x in item.offset.x..(item.offset.x + item.size.width)) {
return item
}
closestItemInRow = item
}
}
return closestItemInRow
}
// called not just for drag gestures initiated by moving the finger, but also with DPAD's Enter
fun beginDragGesture(pos: IntOffset, rawItem: LazyGridItemInfo) {
if (activeDragItem != null) return
val item = items.getOrNull(rawItem.index) ?: return
if (item.isDraggable) {
items[rawItem.index] = ItemInList.DragMarker(item.columnSpan)
activeDragItem = item
activeDragPosition = pos
activeDragSize = rawItem.size
}
}
// this beginDragGesture() overload is only called when moving the finger (not on DPAD's Enter)
fun beginDragGesture(pos: IntOffset, wasLongPressed: Boolean) {
if (!wasLongPressed) {
// items can be dragged around only if they are long-pressed;
// use the drag as scroll otherwise
return
}
val rawItem = findItemForOffsetOrClosestInRow(pos) ?: return
beginDragGesture(pos, rawItem)
autoScrollSpeed = 0f
autoScrollJob = coroutineScope.launch {
while (isActive) {
if (autoScrollSpeed != 0f) {
gridState.scrollBy(autoScrollSpeed)
}
delay(16L) // roughly 60 FPS
}
}
}
// called not just for drag gestures by moving the finger, but also with DPAD's events
fun handleDragGestureChange(dragItem: ItemInList, rawItem: LazyGridItemInfo) {
val prevDragMarkerIndex = items.indexOfFirst { it is ItemInList.DragMarker }
.takeIf { it >= 0 } ?: return // impossible situation, DragMarker is always in the list
// compute where the DragMarker will go (we need to do special logic to make sure the
// HeaderBox always sticks right after EnabledCaption or HiddenCaption)
val nextDragMarkerIndex = if (dragItem == ItemInList.HeaderBox) {
val hiddenCaptionIndex = items.indexOf(ItemInList.HiddenCaption)
if (rawItem.index < hiddenCaptionIndex) {
1 // i.e. right after the EnabledCaption
} else if (prevDragMarkerIndex < hiddenCaptionIndex) {
hiddenCaptionIndex // i.e. right after the HiddenCaption
} else {
hiddenCaptionIndex + 1 // i.e. right after the HiddenCaption
}
} else {
var i = rawItem.index
// make sure it is not possible to move items in between a *Caption and a HeaderBox
if (items[i].isCaption) i += 1
if (i < items.size && items[i] == ItemInList.HeaderBox) i += 1
if (rawItem.index in (prevDragMarkerIndex + 1)..<i) i -= 1
i
}
// no need to do anything if the DragMarker is already at the right place
if (prevDragMarkerIndex == nextDragMarkerIndex) {
return
}
// adjust the position of the DragMarker
items.removeAt(prevDragMarkerIndex)
items.add(min(nextDragMarkerIndex, items.size), ItemInList.DragMarker(dragItem.columnSpan))
// add or remove NoneMarkers as needed
items.removeIf { it is ItemInList.NoneMarker }
val hiddenCaptionIndex = items.indexOf(ItemInList.HiddenCaption)
if (hiddenCaptionIndex == items.size - 1) {
items.add(ItemInList.NoneMarker)
} else if (hiddenCaptionIndex == 1) {
items.add(1, ItemInList.NoneMarker)
}
}
// this handleDragGestureChange() overload is only called when moving the finger
// (not on DPAD's events)
fun handleDragGestureChange(pos: IntOffset, posChangeForScrolling: Offset) {
val dragItem = activeDragItem
if (dragItem == null) {
// when the user clicks outside of any draggable item, or if the user did not long-press
// on an item to begin with, let the list be scrolled
gridState.dispatchRawDelta(-posChangeForScrolling.y)
return
}
autoScrollSpeed = autoScrollSpeedFromTouchPos(pos, gridState)
activeDragPosition = pos
val rawItem = findItemForOffsetOrClosestInRow(pos) ?: return
handleDragGestureChange(dragItem, rawItem)
}
// called in multiple places both, e.g. when the finger stops touching, or with DPAD events
fun completeDragGestureAndCleanUp() {
autoScrollJob?.cancel()
autoScrollJob = null
autoScrollSpeed = 0f
val dragItem = activeDragItem
if (dragItem != null) {
val dragMarkerIndex = items.indexOfFirst { it is ItemInList.DragMarker }
if (dragMarkerIndex >= 0) {
items[dragMarkerIndex] = dragItem
}
}
activeDragItem = null
activeDragPosition = IntOffset.Zero
activeDragSize = IntSize.Zero
val state = remember(gridState, coroutineScope) {
LongPressMenuEditorState(gridState, coroutineScope)
}
DisposableEffect(Unit) {
onDispose {
completeDragGestureAndCleanUp()
// TODO save to settings
state.onDispose()
}
}
@ -292,153 +110,26 @@ fun LongPressMenuEditor(modifier: Modifier = Modifier) {
modifier = Modifier
.safeDrawingPadding()
.detectDragGestures(
beginDragGesture = ::beginDragGesture,
handleDragGestureChange = ::handleDragGestureChange,
endDragGesture = ::completeDragGestureAndCleanUp,
beginDragGesture = state::beginDragGesture,
handleDragGestureChange = state::handleDragGestureChange,
endDragGesture = state::completeDragGestureAndCleanUp,
)
// this huge .focusTarget().onKeyEvent() block just handles DPAD on Android TVs
// `.focusTarget().onKeyEvent()` handles DPAD on Android TVs
.focusTarget()
.onKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) {
if (event.type == KeyEventType.KeyUp &&
event.key == Key.DirectionDown &&
currentlyFocusedItem < 0
) {
currentlyFocusedItem = 0
}
return@onKeyEvent false
}
var focusedItem = currentlyFocusedItem
when (event.key) {
Key.DirectionUp -> {
if (focusedItem < 0) {
return@onKeyEvent false
} else if (items[focusedItem].columnSpan == null) {
focusedItem -= 1
} else {
// go to the previous line
var remaining = columns
while (true) {
focusedItem -= 1
if (focusedItem < 0) {
break
}
remaining -= items[focusedItem].columnSpan ?: columns
if (remaining <= 0) {
break
}
}
}
}
Key.DirectionDown -> {
if (focusedItem >= items.size - 1) {
return@onKeyEvent false
} else if (focusedItem < 0 || items[focusedItem].columnSpan == null) {
focusedItem += 1
} else {
// go to the next line
var remaining = columns
while (true) {
focusedItem += 1
if (focusedItem >= items.size - 1) {
break
}
remaining -= items[focusedItem].columnSpan ?: columns
if (remaining <= 0) {
break
}
}
}
}
Key.DirectionLeft -> {
if (focusedItem < 0) {
return@onKeyEvent false
} else {
focusedItem -= 1
}
}
Key.DirectionRight -> {
if (focusedItem >= items.size - 1) {
return@onKeyEvent false
} else {
focusedItem += 1
}
}
Key.Enter, Key.NumPadEnter, Key.DirectionCenter -> if (activeDragItem == null) {
val rawItem = gridState.layoutInfo.visibleItemsInfo
.firstOrNull { it.index == focusedItem }
?: return@onKeyEvent false
beginDragGesture(rawItem.offset, rawItem)
return@onKeyEvent true
} else {
completeDragGestureAndCleanUp()
return@onKeyEvent true
}
else -> return@onKeyEvent false
}
currentlyFocusedItem = focusedItem
if (focusedItem < 0) {
// not checking for focusedItem>=items.size because it's impossible for it
// to reach that value, and that's because we assume that there is nothing
// else focusable *after* this view. This way we don't need to cleanup the
// drag gestures when the user reaches the end, which would be confusing as
// then there would be no indication of the current cursor position at all.
completeDragGestureAndCleanUp()
return@onKeyEvent false
} else if (focusedItem >= items.size) {
Log.w(TAG, "Invalid focusedItem $focusedItem: >= items size ${items.size}")
}
val rawItem = gridState.layoutInfo.visibleItemsInfo
.minByOrNull { abs(it.index - focusedItem) }
?: return@onKeyEvent false // no item is visible at all, impossible case
// If the item we are going to focus is not visible or is close to the boundary,
// scroll to it. Note that this will cause the "drag item" to appear misplaced,
// since the drag item's position is set to the position of the focused item
// before scrolling. However, it's not worth overcomplicating the logic just for
// correcting the position of a drag hint on Android TVs.
val h = rawItem.size.height
if (rawItem.index != focusedItem ||
rawItem.offset.y <= gridState.layoutInfo.viewportStartOffset + 0.8 * h ||
rawItem.offset.y + 1.8 * h >= gridState.layoutInfo.viewportEndOffset
) {
coroutineScope.launch {
gridState.scrollToItem(focusedItem, -(0.8 * h).toInt())
}
}
val dragItem = activeDragItem
if (dragItem != null) {
// This will mostly bring the drag item to the right position, but will
// misplace it if the view just scrolled (see above), or if the DragMarker's
// position is moved past HiddenCaption by handleDragGestureChange() below.
// However, it's not worth overcomplicating the logic just for correcting
// the position of a drag hint on Android TVs.
activeDragPosition = rawItem.offset
handleDragGestureChange(dragItem, rawItem)
}
return@onKeyEvent true
},
.onKeyEvent { event -> state.onKeyEvent(event, columns) },
// same width as the LongPressMenu
columns = GridCells.Adaptive(MinButtonWidth),
userScrollEnabled = false,
state = gridState,
) {
itemsIndexed(
items,
state.items,
key = { _, item -> item.stableUniqueKey() },
span = { _, item -> GridItemSpan(item.columnSpan ?: maxLineSpan) },
) { i, item ->
ItemInListUi(
item = item,
selected = currentlyFocusedItem == i,
selected = state.currentlyFocusedItem == i,
// We only want placement animations: fade in/out animations interfere with
// items being replaced by a drag marker while being dragged around, and a fade
// in/out animation there does not make sense as the item was just "picked up".
@ -448,17 +139,17 @@ fun LongPressMenuEditor(modifier: Modifier = Modifier) {
)
}
}
if (activeDragItem != null) {
state.activeDragItem?.let { activeDragItem ->
// draw it the same size as the selected item,
val size = with(LocalDensity.current) {
remember(activeDragSize) { activeDragSize.toSize().toDpSize() }
remember(state.activeDragSize) { state.activeDragSize.toSize().toDpSize() }
}
ItemInListUi(
item = activeDragItem!!,
item = activeDragItem,
selected = true,
modifier = Modifier
.size(size)
.offset { activeDragPosition }
.offset { state.activeDragPosition }
.offset(-size.width / 2, -size.height / 2)
.offset((-24).dp, (-24).dp),
)
@ -466,59 +157,6 @@ fun LongPressMenuEditor(modifier: Modifier = Modifier) {
}
}
fun autoScrollSpeedFromTouchPos(
touchPos: IntOffset,
gridState: LazyGridState,
maxSpeed: Float = 20f,
scrollIfCloseToBorderPercent: Float = 0.2f,
): Float {
val heightPosRatio = touchPos.y.toFloat() /
(gridState.layoutInfo.viewportEndOffset - gridState.layoutInfo.viewportStartOffset)
// just a linear piecewise function, sets higher speeds the closer the finger is to the border
return maxSpeed * max(
// proportionally positive speed when close to the bottom border
(heightPosRatio - 1) / scrollIfCloseToBorderPercent + 1,
min(
// proportionally negative speed when close to the top border
heightPosRatio / scrollIfCloseToBorderPercent - 1,
// don't scroll at all if not close to any border
0f
)
)
}
sealed class ItemInList(
val isDraggable: Boolean = false,
val isCaption: Boolean = false,
open val columnSpan: Int? = 1,
) {
// decoration items (i.e. text subheaders)
object EnabledCaption : ItemInList(isCaption = true, columnSpan = null /* i.e. all line */)
object HiddenCaption : ItemInList(isCaption = true, columnSpan = null /* i.e. all line */)
// actual draggable actions (+ a header)
object HeaderBox : ItemInList(isDraggable = true, columnSpan = 2)
data class Action(val type: LongPressAction.Type) : ItemInList(isDraggable = true)
// markers
object NoneMarker : ItemInList()
data class DragMarker(override val columnSpan: Int?) : ItemInList()
fun stableUniqueKey(): Int {
return when (this) {
is Action -> this.type.ordinal
NoneMarker -> LongPressAction.Type.entries.size + 0
HeaderBox -> LongPressAction.Type.entries.size + 1
EnabledCaption -> LongPressAction.Type.entries.size + 2
HiddenCaption -> LongPressAction.Type.entries.size + 3
is DragMarker -> LongPressAction.Type.entries.size + 4 + (this.columnSpan ?: 0)
}
}
}
inline fun <T> T.letIf(condition: Boolean, block: T.() -> T): T =
if (condition) block(this) else this
@Composable
private fun Subheader(
selected: Boolean,

View File

@ -0,0 +1,401 @@
package org.schabi.newpipe.ui.components.menu
import android.util.Log
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.lazy.grid.LazyGridItemInfo
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Companion.DefaultEnabledActions
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
/**
* This class is very tied to [LongPressMenuEditor] and interacts with the UI layer through
* [gridState]. Therefore it's not a view model but rather a state holder class, see
* https://developer.android.com/topic/architecture/ui-layer/stateholders#ui-logic.
*
* See the javadoc of [LongPressMenuEditor] to understand which behaviors you should test for when
* changing this class.
*/
@Stable
class LongPressMenuEditorState(
val gridState: LazyGridState,
val coroutineScope: CoroutineScope,
) {
// We get the current arrangement once and do not observe on purpose
val items = run {
// TODO load from settings
val headerEnabled = true
val actionArrangement = DefaultEnabledActions
sequence {
yield(ItemInList.EnabledCaption)
if (headerEnabled) {
yield(ItemInList.HeaderBox)
}
yieldAll(
actionArrangement
.map { ItemInList.Action(it) }
.ifEmpty { if (headerEnabled) listOf() else listOf(ItemInList.NoneMarker) }
)
yield(ItemInList.HiddenCaption)
if (!headerEnabled) {
yield(ItemInList.HeaderBox)
}
yieldAll(
LongPressAction.Type.entries
.filter { !actionArrangement.contains(it) }
.map { ItemInList.Action(it) }
.ifEmpty { if (headerEnabled) listOf(ItemInList.NoneMarker) else listOf() }
)
}.toList().toMutableStateList()
}
// variables for handling drag, focus, and autoscrolling when finger is at top/bottom
var activeDragItem by mutableStateOf<ItemInList?>(null)
var activeDragPosition by mutableStateOf(IntOffset.Zero)
var activeDragSize by mutableStateOf(IntSize.Zero)
var currentlyFocusedItem by mutableIntStateOf(-1)
var autoScrollJob by mutableStateOf<Job?>(null)
var autoScrollSpeed by mutableFloatStateOf(0f)
private fun findItemForOffsetOrClosestInRow(offset: IntOffset): LazyGridItemInfo? {
var closestItemInRow: LazyGridItemInfo? = null
// Using manual for loop with indices instead of firstOrNull() because this method gets
// called a lot and firstOrNull allocates an iterator for each call
for (index in gridState.layoutInfo.visibleItemsInfo.indices) {
val item = gridState.layoutInfo.visibleItemsInfo[index]
if (offset.y in item.offset.y..(item.offset.y + item.size.height)) {
if (offset.x in item.offset.x..(item.offset.x + item.size.width)) {
return item
}
closestItemInRow = item
}
}
return closestItemInRow
}
private fun autoScrollSpeedFromTouchPos(
touchPos: IntOffset,
maxSpeed: Float = 20f,
scrollIfCloseToBorderPercent: Float = 0.2f,
): Float {
val heightPosRatio = touchPos.y.toFloat() /
(gridState.layoutInfo.viewportEndOffset - gridState.layoutInfo.viewportStartOffset)
// just a linear piecewise function, sets higher speeds the closer the finger is to the border
return maxSpeed * max(
// proportionally positive speed when close to the bottom border
(heightPosRatio - 1) / scrollIfCloseToBorderPercent + 1,
min(
// proportionally negative speed when close to the top border
heightPosRatio / scrollIfCloseToBorderPercent - 1,
// don't scroll at all if not close to any border
0f
)
)
}
/**
* Called not just for drag gestures initiated by moving the finger, but also with DPAD's Enter.
*/
private fun beginDragGesture(pos: IntOffset, rawItem: LazyGridItemInfo) {
if (activeDragItem != null) return
val item = items.getOrNull(rawItem.index) ?: return
if (item.isDraggable) {
items[rawItem.index] = ItemInList.DragMarker(item.columnSpan)
activeDragItem = item
activeDragPosition = pos
activeDragSize = rawItem.size
}
}
/**
* This beginDragGesture() overload is only called when moving the finger (not on DPAD's Enter).
*/
fun beginDragGesture(pos: IntOffset, wasLongPressed: Boolean) {
if (!wasLongPressed) {
// items can be dragged around only if they are long-pressed;
// use the drag as scroll otherwise
return
}
val rawItem = findItemForOffsetOrClosestInRow(pos) ?: return
beginDragGesture(pos, rawItem)
autoScrollSpeed = 0f
autoScrollJob = coroutineScope.launch {
while (isActive) {
if (autoScrollSpeed != 0f) {
gridState.scrollBy(autoScrollSpeed)
}
delay(16L) // roughly 60 FPS
}
}
}
/**
* Called not just for drag gestures by moving the finger, but also with DPAD's events.
*/
private fun handleDragGestureChange(dragItem: ItemInList, rawItem: LazyGridItemInfo) {
val prevDragMarkerIndex = items.indexOfFirst { it is ItemInList.DragMarker }
.takeIf { it >= 0 } ?: return // impossible situation, DragMarker is always in the list
// compute where the DragMarker will go (we need to do special logic to make sure the
// HeaderBox always sticks right after EnabledCaption or HiddenCaption)
val nextDragMarkerIndex = if (dragItem == ItemInList.HeaderBox) {
val hiddenCaptionIndex = items.indexOf(ItemInList.HiddenCaption)
if (rawItem.index < hiddenCaptionIndex) {
1 // i.e. right after the EnabledCaption
} else if (prevDragMarkerIndex < hiddenCaptionIndex) {
hiddenCaptionIndex // i.e. right after the HiddenCaption
} else {
hiddenCaptionIndex + 1 // i.e. right after the HiddenCaption
}
} else {
var i = rawItem.index
// make sure it is not possible to move items in between a *Caption and a HeaderBox
if (items[i].isCaption) i += 1
if (i < items.size && items[i] == ItemInList.HeaderBox) i += 1
if (rawItem.index in (prevDragMarkerIndex + 1)..<i) i -= 1
i
}
// no need to do anything if the DragMarker is already at the right place
if (prevDragMarkerIndex == nextDragMarkerIndex) {
return
}
// adjust the position of the DragMarker
items.removeAt(prevDragMarkerIndex)
items.add(min(nextDragMarkerIndex, items.size), ItemInList.DragMarker(dragItem.columnSpan))
// add or remove NoneMarkers as needed
items.removeIf { it is ItemInList.NoneMarker }
val hiddenCaptionIndex = items.indexOf(ItemInList.HiddenCaption)
if (hiddenCaptionIndex == items.size - 1) {
items.add(ItemInList.NoneMarker)
} else if (hiddenCaptionIndex == 1) {
items.add(1, ItemInList.NoneMarker)
}
}
/**
* This handleDragGestureChange() overload is only called when moving the finger
* (not on DPAD's events).
*/
fun handleDragGestureChange(pos: IntOffset, posChangeForScrolling: Offset) {
val dragItem = activeDragItem
if (dragItem == null) {
// when the user clicks outside of any draggable item, or if the user did not long-press
// on an item to begin with, let the list be scrolled
gridState.dispatchRawDelta(-posChangeForScrolling.y)
return
}
autoScrollSpeed = autoScrollSpeedFromTouchPos(pos)
activeDragPosition = pos
val rawItem = findItemForOffsetOrClosestInRow(pos) ?: return
handleDragGestureChange(dragItem, rawItem)
}
/**
* Called in multiple places, e.g. when the finger stops touching, or with DPAD events.
*/
fun completeDragGestureAndCleanUp() {
autoScrollJob?.cancel()
autoScrollJob = null
autoScrollSpeed = 0f
activeDragItem?.let { dragItem ->
val dragMarkerIndex = items.indexOfFirst { it is ItemInList.DragMarker }
if (dragMarkerIndex >= 0) {
items[dragMarkerIndex] = dragItem
}
}
activeDragItem = null
activeDragPosition = IntOffset.Zero
activeDragSize = IntSize.Zero
}
/**
* Handles DPAD events on Android TVs.
*/
fun onKeyEvent(event: KeyEvent, columns: Int): Boolean {
if (event.type != KeyEventType.KeyDown) {
if (event.type == KeyEventType.KeyUp &&
event.key == Key.DirectionDown &&
currentlyFocusedItem < 0
) {
currentlyFocusedItem = 0
}
return false
}
var focusedItem = currentlyFocusedItem
when (event.key) {
Key.DirectionUp -> {
if (focusedItem < 0) {
return false
} else if (items[focusedItem].columnSpan == null) {
focusedItem -= 1
} else {
// go to the previous line
var remaining = columns
while (true) {
focusedItem -= 1
if (focusedItem < 0) {
break
}
remaining -= items[focusedItem].columnSpan ?: columns
if (remaining <= 0) {
break
}
}
}
}
Key.DirectionDown -> {
if (focusedItem >= items.size - 1) {
return false
} else if (focusedItem < 0 || items[focusedItem].columnSpan == null) {
focusedItem += 1
} else {
// go to the next line
var remaining = columns
while (true) {
focusedItem += 1
if (focusedItem >= items.size - 1) {
break
}
remaining -= items[focusedItem].columnSpan ?: columns
if (remaining <= 0) {
break
}
}
}
}
Key.DirectionLeft -> {
if (focusedItem < 0) {
return false
} else {
focusedItem -= 1
}
}
Key.DirectionRight -> {
if (focusedItem >= items.size - 1) {
return false
} else {
focusedItem += 1
}
}
Key.Enter, Key.NumPadEnter, Key.DirectionCenter -> if (activeDragItem == null) {
val rawItem = gridState.layoutInfo.visibleItemsInfo
.firstOrNull { it.index == focusedItem }
?: return false
beginDragGesture(rawItem.offset, rawItem)
return true
} else {
completeDragGestureAndCleanUp()
return true
}
else -> return false
}
currentlyFocusedItem = focusedItem
if (focusedItem < 0) {
// not checking for focusedItem>=items.size because it's impossible for it
// to reach that value, and that's because we assume that there is nothing
// else focusable *after* this view. This way we don't need to cleanup the
// drag gestures when the user reaches the end, which would be confusing as
// then there would be no indication of the current cursor position at all.
completeDragGestureAndCleanUp()
return false
} else if (focusedItem >= items.size) {
Log.w(TAG, "Invalid focusedItem $focusedItem: >= items size ${items.size}")
}
val rawItem = gridState.layoutInfo.visibleItemsInfo
.minByOrNull { abs(it.index - focusedItem) }
?: return false // no item is visible at all, impossible case
// If the item we are going to focus is not visible or is close to the boundary,
// scroll to it. Note that this will cause the "drag item" to appear misplaced,
// since the drag item's position is set to the position of the focused item
// before scrolling. However, it's not worth overcomplicating the logic just for
// correcting the position of a drag hint on Android TVs.
val h = rawItem.size.height
if (rawItem.index != focusedItem ||
rawItem.offset.y <= gridState.layoutInfo.viewportStartOffset + 0.8 * h ||
rawItem.offset.y + 1.8 * h >= gridState.layoutInfo.viewportEndOffset
) {
coroutineScope.launch {
gridState.scrollToItem(focusedItem, -(0.8 * h).toInt())
}
}
val dragItem = activeDragItem
if (dragItem != null) {
// This will mostly bring the drag item to the right position, but will
// misplace it if the view just scrolled (see above), or if the DragMarker's
// position is moved past HiddenCaption by handleDragGestureChange() below.
// However, it's not worth overcomplicating the logic just for correcting
// the position of a drag hint on Android TVs.
activeDragPosition = rawItem.offset
handleDragGestureChange(dragItem, rawItem)
}
return true
}
fun onDispose() {
completeDragGestureAndCleanUp()
// TODO save to settings
}
}
sealed class ItemInList(
val isDraggable: Boolean = false,
val isCaption: Boolean = false,
open val columnSpan: Int? = 1,
) {
// decoration items (i.e. text subheaders)
object EnabledCaption : ItemInList(isCaption = true, columnSpan = null /* i.e. all line */)
object HiddenCaption : ItemInList(isCaption = true, columnSpan = null /* i.e. all line */)
// actual draggable actions (+ a header)
object HeaderBox : ItemInList(isDraggable = true, columnSpan = 2)
data class Action(val type: LongPressAction.Type) : ItemInList(isDraggable = true)
// markers
object NoneMarker : ItemInList()
data class DragMarker(override val columnSpan: Int?) : ItemInList()
fun stableUniqueKey(): Int {
return when (this) {
is Action -> this.type.ordinal
NoneMarker -> LongPressAction.Type.entries.size + 0
HeaderBox -> LongPressAction.Type.entries.size + 1
EnabledCaption -> LongPressAction.Type.entries.size + 2
HiddenCaption -> LongPressAction.Type.entries.size + 3
is DragMarker -> LongPressAction.Type.entries.size + 4 + (this.columnSpan ?: 0)
}
}
}