track the finger position while dragging queue rows

This commit is contained in:
austinried
2026-10-04 00:29:52 +00:00
parent c2ecc63fe5
commit 5492a57e09
@@ -1,11 +1,12 @@
package com.subtracks.ui.playback package com.subtracks.ui.playback
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -14,6 +15,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.ArrowBack
@@ -38,6 +40,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -68,10 +71,12 @@ import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.library.SongRow import com.subtracks.ui.library.SongRow
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import kotlin.math.roundToInt import kotlin.math.floor
class QueueViewModel( class QueueViewModel(
private val queueRepository: QueueRepository, private val queueRepository: QueueRepository,
@@ -138,6 +143,13 @@ fun QueueRoute(
private enum class DropIndicator { Above, Below } private enum class DropIndicator { Above, Below }
private data class Settle(
val from: Long,
val to: Long,
val songId: String,
val fingerY: Float,
)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun QueueScreen( fun QueueScreen(
@@ -157,8 +169,11 @@ fun QueueScreen(
val density = LocalDensity.current val density = LocalDensity.current
var rowHeight by remember { mutableFloatStateOf(0f) } var rowHeight by remember { mutableFloatStateOf(0f) }
var dragging by remember { mutableStateOf<Long?>(null) } var dragging by remember { mutableStateOf<Long?>(null) }
var dragOffset by remember { mutableFloatStateOf(0f) } var fingerY by remember { mutableFloatStateOf(0f) }
var target by remember { mutableStateOf<Long?>(null) } var target by remember { mutableStateOf<Long?>(null) }
var settle by remember { mutableStateOf<Settle?>(null) }
var settleProgress by remember { mutableFloatStateOf(1f) }
var settleReady by remember { mutableStateOf(false) }
var centered by remember { mutableStateOf(false) } var centered by remember { mutableStateOf(false) }
val firstPosition = if (items.itemCount > 0) items.peek(0)?.position else null val firstPosition = if (items.itemCount > 0) items.peek(0)?.position else null
@@ -175,28 +190,48 @@ fun QueueScreen(
LaunchedEffect(dragging) { LaunchedEffect(dragging) {
if (dragging == null) return@LaunchedEffect if (dragging == null) return@LaunchedEffect
val threshold = with(density) { 72.dp.toPx() } val threshold = with(density) { 72.dp.toPx() }
val step = with(density) { 10.dp.toPx() } val step = with(density) { 12.dp.toPx() }
while (true) { while (true) {
val from = dragging ?: break val start = listState.layoutInfo.viewportStartOffset.toFloat()
val info = listState.layoutInfo.visibleItemsInfo.firstOrNull { items.peek(it.index)?.position == from } val end = listState.layoutInfo.viewportEndOffset.toFloat()
if (info != null) {
val top = info.offset + dragOffset
val edge = val edge =
when { when {
top < listState.layoutInfo.viewportStartOffset + threshold -> -step fingerY < start + threshold -> -step
top + info.size > listState.layoutInfo.viewportEndOffset - threshold -> step fingerY > end - threshold -> step
else -> 0f else -> 0f
} }
if (edge != 0f) { if (edge != 0f) listState.scrollBy(edge)
val consumed = listState.scrollBy(edge)
dragOffset += consumed
target = dropTarget(items, from, dragOffset, rowHeight)
}
}
delay(16) delay(16)
} }
} }
LaunchedEffect(settle) {
val current = settle ?: return@LaunchedEffect
val first = if (items.itemCount > 0) items.peek(0)?.position else null
if (first != null) {
val index = (current.to - first).toInt()
withTimeoutOrNull(SETTLE_TIMEOUT_MS) {
snapshotFlow {
if (index in 0 until items.itemCount) {
items
.peek(index)
?.item
?.song
?.id
} else {
null
}
}.first { it == current.songId }
}
}
settleReady = true
settleProgress = 0f
animate(0f, 1f, animationSpec = tween(SETTLE_DURATION_MS)) { value, _ -> settleProgress = value }
settle = null
settleReady = false
settleProgress = 1f
}
val showUndo: (String) -> Unit = { message -> val showUndo: (String) -> Unit = { message ->
scope.launch { scope.launch {
snackbarHostState.currentSnackbarData?.dismiss() snackbarHostState.currentSnackbarData?.dismiss()
@@ -237,12 +272,30 @@ fun QueueScreen(
items(count = items.itemCount) { index -> items(count = items.itemCount) { index ->
val item = items[index] val item = items[index]
if (item != null) { if (item != null) {
val active = settle
val draggedPosition =
when {
active == null -> dragging
!settleReady -> active.from
else -> active.to
}
QueueRow( QueueRow(
item = item, item = item,
isPlaying = item.position == currentPosition, isPlaying = item.position == currentPosition,
coverArt = coverArt, coverArt = coverArt,
dragging = dragging == item.position, floating = draggedPosition == item.position,
dragOffset = if (dragging == item.position) dragOffset else 0f, translation =
translationFor(
item.position,
active,
dragging,
fingerY,
settleReady,
settleProgress,
rowHeight,
listState,
items,
),
indicator = indicatorFor(item.position, dragging, target), indicator = indicatorFor(item.position, dragging, target),
onClick = { onPlay(item.position) }, onClick = { onPlay(item.position) },
onRemove = { onRemove = {
@@ -251,28 +304,31 @@ fun QueueScreen(
}, },
onSize = { rowHeight = it.toFloat() }, onSize = { rowHeight = it.toFloat() },
onDragStart = { onDragStart = {
if (settle == null) {
dragging = item.position dragging = item.position
dragOffset = 0f
target = item.position target = item.position
fingerY =
(slotOffset(listState, items, rowHeight, item.position) ?: 0f) +
rowHeight / 2f
}
}, },
onDrag = { amount -> onDrag = { amount ->
dragOffset += amount fingerY += amount
target = dropTarget(items, item.position, dragOffset, rowHeight) target = pointerTarget(listState, items, rowHeight, fingerY, item.position)
}, },
onDragEnd = { onDragEnd = {
val from = dragging val from = dragging
val to = target val to = target
if (from != null && to != null && to != from) { if (from != null && to != null && to != from) {
settle = Settle(from, to, item.item.song.id, fingerY)
onMove(from, to) onMove(from, to)
showUndo("Queue reordered") showUndo("Queue reordered")
} }
dragging = null dragging = null
dragOffset = 0f
target = null target = null
}, },
onDragCancel = { onDragCancel = {
dragging = null dragging = null
dragOffset = 0f
target = null target = null
}, },
) )
@@ -284,18 +340,70 @@ fun QueueScreen(
} }
} }
private fun dropTarget( private fun slotOffset(
listState: LazyListState,
items: LazyPagingItems<QueueWindowItem>, items: LazyPagingItems<QueueWindowItem>,
from: Long,
offset: Float,
rowHeight: Float, rowHeight: Float,
position: Long,
): Float? {
val first = listState.layoutInfo.visibleItemsInfo.firstOrNull() ?: return null
val firstPosition = items.peek(first.index)?.position ?: return null
return first.offset + (position - firstPosition) * rowHeight
}
private fun pointerTarget(
listState: LazyListState,
items: LazyPagingItems<QueueWindowItem>,
rowHeight: Float,
fingerY: Float,
from: Long,
): Long { ): Long {
if (items.itemCount == 0 || rowHeight <= 0f) return from if (items.itemCount == 0 || rowHeight <= 0f) return from
val first = items.peek(0)?.position ?: from val first = listState.layoutInfo.visibleItemsInfo.firstOrNull() ?: return from
val last = items.peek(items.itemCount - 1)?.position ?: from val firstPosition = items.peek(first.index)?.position ?: return from
return (from + (offset / rowHeight).roundToInt()).coerceIn(minOf(first, last), maxOf(first, last)) val index = firstPosition + floor((fingerY - first.offset) / rowHeight).toLong()
val firstLoaded = items.peek(0)?.position ?: firstPosition
val lastLoaded = items.peek(items.itemCount - 1)?.position ?: firstPosition
return index.coerceIn(minOf(firstLoaded, lastLoaded), maxOf(firstLoaded, lastLoaded))
} }
private fun translationFor(
position: Long,
settle: Settle?,
dragging: Long?,
fingerY: Float,
settleReady: Boolean,
progress: Float,
rowHeight: Float,
listState: LazyListState,
items: LazyPagingItems<QueueWindowItem>,
): Float =
when {
settle == null -> {
if (dragging == position) fingerY - (slotOffset(listState, items, rowHeight, position) ?: fingerY) else 0f
}
!settleReady -> {
if (position == settle.from) settle.fingerY - (slotOffset(listState, items, rowHeight, position) ?: settle.fingerY) else 0f
}
position == settle.to -> {
(settle.fingerY - (slotOffset(listState, items, rowHeight, position) ?: settle.fingerY)) * (1f - progress)
}
settle.to > settle.from && position in settle.from until settle.to -> {
rowHeight * (1f - progress)
}
settle.to < settle.from && position in (settle.to + 1)..settle.from -> {
-rowHeight * (1f - progress)
}
else -> {
0f
}
}
private fun indicatorFor( private fun indicatorFor(
position: Long, position: Long,
dragging: Long?, dragging: Long?,
@@ -313,8 +421,8 @@ private fun QueueRow(
item: QueueWindowItem, item: QueueWindowItem,
isPlaying: Boolean, isPlaying: Boolean,
coverArt: (String?, Boolean) -> CoverArtRef?, coverArt: (String?, Boolean) -> CoverArtRef?,
dragging: Boolean, floating: Boolean,
dragOffset: Float, translation: Float,
indicator: DropIndicator?, indicator: DropIndicator?,
onClick: () -> Unit, onClick: () -> Unit,
onRemove: () -> Unit, onRemove: () -> Unit,
@@ -324,13 +432,12 @@ private fun QueueRow(
onDragEnd: () -> Unit, onDragEnd: () -> Unit,
onDragCancel: () -> Unit, onDragCancel: () -> Unit,
) { ) {
Column( Box(
modifier = modifier =
Modifier Modifier
.zIndex(if (dragging) 1f else 0f) .zIndex(if (floating) 1f else 0f)
.graphicsLayer { translationY = dragOffset }, .graphicsLayer { translationY = translation },
) { ) {
if (indicator == DropIndicator.Above) DropIndicatorLine()
SongRow( SongRow(
song = item.item.song, song = item.item.song,
coverArtId = item.item.coverArt, coverArtId = item.item.coverArt,
@@ -369,7 +476,7 @@ private fun QueueRow(
modifier = modifier =
Modifier Modifier
.background( .background(
if (dragging) { if (floating) {
MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.95f) MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.95f)
} else { } else {
Color.Transparent Color.Transparent
@@ -377,17 +484,24 @@ private fun QueueRow(
).onSizeChanged { size -> onSize(size.height) } ).onSizeChanged { size -> onSize(size.height) }
.clickable(onClick = onClick), .clickable(onClick = onClick),
) )
if (indicator == DropIndicator.Below) DropIndicatorLine() when (indicator) {
DropIndicator.Above -> DropIndicatorLine(Modifier.align(Alignment.TopStart))
DropIndicator.Below -> DropIndicatorLine(Modifier.align(Alignment.BottomStart))
null -> Unit
}
} }
} }
@Composable @Composable
private fun DropIndicatorLine() { private fun DropIndicatorLine(modifier: Modifier = Modifier) {
Box( Box(
modifier = modifier =
Modifier modifier
.fillMaxWidth() .fillMaxWidth()
.height(2.dp) .height(2.dp)
.background(MaterialTheme.colorScheme.primary), .background(MaterialTheme.colorScheme.primary),
) )
} }
private const val SETTLE_DURATION_MS = 200
private const val SETTLE_TIMEOUT_MS = 1_000L