Separate @Composables from state logic for actions editor
This commit is contained in:
parent
1a42f300f2
commit
3d62b923c7
12
app/src/main/java/org/schabi/newpipe/ktx/Scope.kt
Normal file
12
app/src/main/java/org/schabi/newpipe/ktx/Scope.kt
Normal 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
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user