Improve compose-reordering (#176)

This commit is contained in:
vfsfitvnm 2022-08-16 23:12:39 +02:00
parent 4ae3c604e4
commit 1682228ece
8 changed files with 539 additions and 194 deletions

View file

@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.BasicText
@ -35,10 +34,11 @@ import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import it.vfsfitvnm.reordering.ReorderingLazyColumn
import it.vfsfitvnm.reordering.animateItemPlacement
import it.vfsfitvnm.reordering.draggedItem
import it.vfsfitvnm.reordering.rememberReorderingState
import it.vfsfitvnm.reordering.verticalDragToReorder
import it.vfsfitvnm.reordering.reorder
import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
@ -92,7 +92,8 @@ fun LocalPlaylistScreen(playlistId: Long) {
val thumbnailSize = Dimensions.thumbnails.song.px
val reorderingState = rememberReorderingState(
items = playlistWithSongs.songs,
lazyListState = lazyListState,
key = playlistWithSongs.songs,
onDragStart = {
hapticFeedback.performHapticFeedback(
HapticFeedbackType.LongPress
@ -123,11 +124,7 @@ fun LocalPlaylistScreen(playlistId: Long) {
)
}
},
itemSizeProvider = { index ->
lazyListState.layoutInfo.visibleItemsInfo.find {
it.index == index + 3
}?.size
}
extraItemCount = 3
)
var isRenaming by rememberSaveable {
@ -164,8 +161,8 @@ fun LocalPlaylistScreen(playlistId: Long) {
)
}
LazyColumn(
state = lazyListState,
ReorderingLazyColumn(
reorderingState = reorderingState,
contentPadding = WindowInsets.systemBars.asPaddingValues()
.add(bottom = Dimensions.collapsedPlayer),
modifier = Modifier
@ -311,7 +308,7 @@ fun LocalPlaylistScreen(playlistId: Long) {
colorFilter = ColorFilter.tint(colorPalette.textSecondary),
modifier = Modifier
.clickable { }
.verticalDragToReorder(
.reorder(
reorderingState = reorderingState,
index = index
)

View file

@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
@ -37,10 +36,11 @@ import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.valentinilk.shimmer.shimmer
import it.vfsfitvnm.reordering.ReorderingLazyColumn
import it.vfsfitvnm.reordering.animateItemPlacement
import it.vfsfitvnm.reordering.draggedItem
import it.vfsfitvnm.reordering.rememberReorderingState
import it.vfsfitvnm.reordering.verticalDragToReorder
import it.vfsfitvnm.reordering.reorder
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness
@ -79,11 +79,9 @@ fun CurrentPlaylistView(
val windows by rememberWindows(binder.player)
val shouldBePlaying by rememberShouldBePlaying(binder.player)
val lazyListState =
rememberLazyListState(initialFirstVisibleItemIndex = mediaItemIndex)
val reorderingState = rememberReorderingState(
items = windows,
lazyListState = rememberLazyListState(initialFirstVisibleItemIndex = mediaItemIndex),
key = windows,
onDragStart = {
hapticFeedback.performHapticFeedback(
HapticFeedbackType.LongPress
@ -92,24 +90,20 @@ fun CurrentPlaylistView(
onDragEnd = { fromIndex, toIndex ->
binder.player.moveMediaItem(fromIndex, toIndex)
},
itemSizeProvider = { index ->
lazyListState.layoutInfo.visibleItemsInfo.find {
it.index == index
}?.size
}
extraItemCount = 0
)
val paddingValues = WindowInsets.systemBars.asPaddingValues()
val bottomPadding = paddingValues.calculateBottomPadding()
Column {
LazyColumn(
state = lazyListState,
ReorderingLazyColumn(
reorderingState = reorderingState,
contentPadding = paddingValues.add(bottom = -bottomPadding),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.nestedScroll(remember {
layoutState.nestedScrollConnection(lazyListState.firstVisibleItemIndex == 0 && lazyListState.firstVisibleItemScrollOffset == 0)
layoutState.nestedScrollConnection(reorderingState.lazyListState.firstVisibleItemIndex == 0 && reorderingState.lazyListState.firstVisibleItemScrollOffset == 0)
})
.background(colorPalette.background1)
.weight(1f)
@ -182,7 +176,7 @@ fun CurrentPlaylistView(
colorFilter = ColorFilter.tint(colorPalette.textSecondary),
modifier = Modifier
.clickable { }
.verticalDragToReorder(
.reorder(
reorderingState = reorderingState,
index = window.firstPeriodIndex
)

View file

@ -1,153 +0,0 @@
package it.vfsfitvnm.reordering
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.pointerInput
import kotlin.math.roundToInt
import kotlin.reflect.KSuspendFunction5
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private fun Modifier.dragToReorder(
reorderingState: ReorderingState,
index: Int,
orientation: Orientation,
function: KSuspendFunction5<PointerInputScope, (Offset) -> Unit, () -> Unit, () -> Unit, (change: PointerInputChange, dragAmount: Offset) -> Unit, Unit>,
): Modifier = pointerInput(reorderingState) {
// require(index in 0..reorderingState.lastIndex)
var previousItemSize = 0
var nextItemSize = 0
function(
this,
{
reorderingState.onDragStart.invoke()
reorderingState.draggingIndex = index
reorderingState.reachedIndex = index
reorderingState.draggingItemSize = reorderingState.itemSizeProvider?.invoke(index) ?: when (orientation) {
Orientation.Vertical -> size.height
Orientation.Horizontal -> size.width
}
nextItemSize = reorderingState.draggingItemSize
previousItemSize = -reorderingState.draggingItemSize
reorderingState.offset.updateBounds(
lowerBound = -index * reorderingState.draggingItemSize,
upperBound = (reorderingState.lastIndex - index) * reorderingState.draggingItemSize
)
},
{
reorderingState.coroutineScope.launch {
reorderingState.offset.animateTo((previousItemSize + nextItemSize) / 2)
withContext(Dispatchers.Main) {
reorderingState.onDragEnd.invoke(index, reorderingState.reachedIndex)
}
if (reorderingState.areEquals(
reorderingState.draggingIndex,
reorderingState.reachedIndex
)
) {
reorderingState.draggingIndex = -1
reorderingState.reachedIndex = -1
reorderingState.draggingItemSize = 0
reorderingState.offset.snapTo(0)
}
}
},
{},
{ _, offset ->
val delta = when (orientation) {
Orientation.Vertical -> offset.y
Orientation.Horizontal -> offset.x
}.roundToInt()
val targetOffset = reorderingState.offset.value + delta
if (targetOffset > nextItemSize) {
if (reorderingState.reachedIndex < reorderingState.lastIndex) {
reorderingState.reachedIndex += 1
nextItemSize += reorderingState.draggingItemSize
previousItemSize += reorderingState.draggingItemSize
}
} else if (targetOffset < previousItemSize) {
if (reorderingState.reachedIndex > 0) {
reorderingState.reachedIndex -= 1
previousItemSize -= reorderingState.draggingItemSize
nextItemSize -= reorderingState.draggingItemSize
}
}
reorderingState.coroutineScope.launch {
reorderingState.offset.snapTo(targetOffset)
}
},
)
}
fun Modifier.dragToReorder(
reorderingState: ReorderingState,
index: Int,
orientation: Orientation,
): Modifier = dragToReorder(
reorderingState = reorderingState,
index = index,
orientation = orientation,
function = PointerInputScope::detectDragGestures,
)
fun Modifier.verticalDragToReorder(
reorderingState: ReorderingState,
index: Int,
): Modifier = dragToReorder(
reorderingState = reorderingState,
index = index,
orientation = Orientation.Vertical,
)
fun Modifier.horizontalDragToReorder(
reorderingState: ReorderingState,
index: Int,
): Modifier = dragToReorder(
reorderingState = reorderingState,
index = index,
orientation = Orientation.Horizontal,
)
fun Modifier.dragAfterLongPressToReorder(
reorderingState: ReorderingState,
index: Int,
orientation: Orientation,
): Modifier = dragToReorder(
reorderingState = reorderingState,
index = index,
orientation = orientation,
function = PointerInputScope::detectDragGesturesAfterLongPress,
)
fun Modifier.verticalDragAfterLongPressToReorder(
reorderingState: ReorderingState,
index: Int,
): Modifier = dragAfterLongPressToReorder(
reorderingState = reorderingState,
index = index,
orientation = Orientation.Vertical,
)
fun Modifier.horizontalDragAfterLongPressToReorder(
reorderingState: ReorderingState,
index: Int
): Modifier = dragAfterLongPressToReorder(
reorderingState = reorderingState,
index = index,
orientation = Orientation.Horizontal,
)

View file

@ -15,7 +15,7 @@ fun Modifier.draggedItem(
val translation by reorderingState.translationFor(index)
offset {
when (reorderingState.orientation) {
when (reorderingState.lazyListState.layoutInfo.orientation) {
Orientation.Vertical -> IntOffset(0, translation)
Orientation.Horizontal -> IntOffset(translation, 0)
}

View file

@ -0,0 +1,51 @@
package it.vfsfitvnm.reordering
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.pointerInput
private fun Modifier.reorder(
reorderingState: ReorderingState,
index: Int,
detectDragGestures: DetectDragGestures,
): Modifier = pointerInput(reorderingState) {
with(detectDragGestures) {
detectDragGestures(
onDragStart = { reorderingState.onDragStart(index) },
onDrag = reorderingState::onDrag,
onDragEnd = reorderingState::onDragEnd,
onDragCancel = reorderingState::onDragEnd,
)
}
}
fun Modifier.reorder(
reorderingState: ReorderingState,
index: Int,
): Modifier = reorder(
reorderingState = reorderingState,
index = index,
detectDragGestures = PointerInputScope::detectDragGestures,
)
fun Modifier.reorderAfterLongPress(
reorderingState: ReorderingState,
index: Int
): Modifier = reorder(
reorderingState = reorderingState,
index = index,
detectDragGestures = PointerInputScope::detectDragGesturesAfterLongPress,
)
private fun interface DetectDragGestures {
suspend fun PointerInputScope.detectDragGestures(
onDragStart: (Offset) -> Unit,
onDragEnd: () -> Unit,
onDragCancel: () -> Unit,
onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
)
}

View file

@ -0,0 +1,41 @@
@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
package it.vfsfitvnm.reordering
import androidx.compose.foundation.gestures.FlingBehavior
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun ReorderingLazyColumn(
reorderingState: ReorderingState,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp),
reverseLayout: Boolean = false,
verticalArrangement: Arrangement.Vertical =
if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
userScrollEnabled: Boolean = true,
content: LazyListScope.() -> Unit
) {
ReorderingLazyList(
modifier = modifier,
state = reorderingState.lazyListState,
reorderingState = reorderingState,
contentPadding = contentPadding,
flingBehavior = flingBehavior,
horizontalAlignment = horizontalAlignment,
verticalArrangement = verticalArrangement,
isVertical = true,
reverseLayout = reverseLayout,
userScrollEnabled = userScrollEnabled,
content = content
)
}

View file

@ -0,0 +1,300 @@
@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
package it.vfsfitvnm.reordering
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.OverscrollEffect
import androidx.compose.foundation.checkScrollableContainerConstraints
import androidx.compose.foundation.clipScrollableContainer
import androidx.compose.foundation.gestures.FlingBehavior
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.lazy.DataIndex
import androidx.compose.foundation.lazy.LazyListBeyondBoundsInfo
import androidx.compose.foundation.lazy.LazyListItemPlacementAnimator
import androidx.compose.foundation.lazy.LazyListItemProvider
import androidx.compose.foundation.lazy.LazyListMeasureResult
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyMeasuredItem
import androidx.compose.foundation.lazy.LazyMeasuredItemProvider
import androidx.compose.foundation.lazy.layout.LazyLayout
import androidx.compose.foundation.lazy.layout.LazyLayoutMeasureScope
import androidx.compose.foundation.lazy.lazyListBeyondBoundsModifier
import androidx.compose.foundation.lazy.lazyListPinningModifier
import androidx.compose.foundation.lazy.lazyListSemantics
import androidx.compose.foundation.lazy.measureLazyList
import androidx.compose.foundation.lazy.rememberLazyListItemProvider
import androidx.compose.foundation.overscroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.constrainHeight
import androidx.compose.ui.unit.constrainWidth
import androidx.compose.ui.unit.offset
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun ReorderingLazyList(
modifier: Modifier,
state: LazyListState,
reorderingState: ReorderingState,
contentPadding: PaddingValues,
reverseLayout: Boolean,
isVertical: Boolean,
flingBehavior: FlingBehavior,
userScrollEnabled: Boolean,
horizontalAlignment: Alignment.Horizontal? = null,
verticalArrangement: Arrangement.Vertical? = null,
verticalAlignment: Alignment.Vertical? = null,
horizontalArrangement: Arrangement.Horizontal? = null,
content: LazyListScope.() -> Unit
) {
val overscrollEffect = ScrollableDefaults.overscrollEffect()
val itemProvider = rememberLazyListItemProvider(state, content)
val scope = rememberCoroutineScope()
val placementAnimator = remember(state, isVertical) {
LazyListItemPlacementAnimator(scope, isVertical)
}
state.placementAnimator = placementAnimator
val measurePolicy = rememberLazyListMeasurePolicy(
itemProvider,
state,
reorderingState.lazyListBeyondBoundsInfo,
overscrollEffect,
contentPadding,
reverseLayout,
isVertical,
horizontalAlignment,
verticalAlignment,
horizontalArrangement,
verticalArrangement,
placementAnimator
)
val orientation = if (isVertical) Orientation.Vertical else Orientation.Horizontal
LazyLayout(
modifier = modifier
.then(state.remeasurementModifier)
.then(state.awaitLayoutModifier)
.lazyListSemantics(
itemProvider = itemProvider,
state = state,
coroutineScope = scope,
isVertical = isVertical,
reverseScrolling = reverseLayout,
userScrollEnabled = userScrollEnabled
)
.clipScrollableContainer(orientation)
.lazyListBeyondBoundsModifier(state, reorderingState.lazyListBeyondBoundsInfo, reverseLayout)
.lazyListPinningModifier(state, reorderingState.lazyListBeyondBoundsInfo)
.overscroll(overscrollEffect)
.scrollable(
orientation = orientation,
reverseDirection = ScrollableDefaults.reverseDirection(
LocalLayoutDirection.current,
orientation,
reverseLayout
),
interactionSource = state.internalInteractionSource,
flingBehavior = flingBehavior,
state = state,
overscrollEffect = overscrollEffect,
enabled = userScrollEnabled
),
prefetchState = state.prefetchState,
measurePolicy = measurePolicy,
itemProvider = itemProvider
)
}
@ExperimentalFoundationApi
@Composable
private fun rememberLazyListMeasurePolicy(
itemProvider: LazyListItemProvider,
state: LazyListState,
beyondBoundsInfo: LazyListBeyondBoundsInfo,
overscrollEffect: OverscrollEffect,
contentPadding: PaddingValues,
reverseLayout: Boolean,
isVertical: Boolean,
horizontalAlignment: Alignment.Horizontal? = null,
verticalAlignment: Alignment.Vertical? = null,
horizontalArrangement: Arrangement.Horizontal? = null,
verticalArrangement: Arrangement.Vertical? = null,
placementAnimator: LazyListItemPlacementAnimator
) = remember<LazyLayoutMeasureScope.(Constraints) -> MeasureResult>(
state,
beyondBoundsInfo,
overscrollEffect,
contentPadding,
reverseLayout,
isVertical,
horizontalAlignment,
verticalAlignment,
horizontalArrangement,
verticalArrangement,
placementAnimator
) {
{ containerConstraints ->
checkScrollableContainerConstraints(
containerConstraints,
if (isVertical) Orientation.Vertical else Orientation.Horizontal
)
// resolve content paddings
val startPadding =
if (isVertical) {
contentPadding.calculateLeftPadding(layoutDirection).roundToPx()
} else {
// in horizontal configuration, padding is reversed by placeRelative
contentPadding.calculateStartPadding(layoutDirection).roundToPx()
}
val endPadding =
if (isVertical) {
contentPadding.calculateRightPadding(layoutDirection).roundToPx()
} else {
// in horizontal configuration, padding is reversed by placeRelative
contentPadding.calculateEndPadding(layoutDirection).roundToPx()
}
val topPadding = contentPadding.calculateTopPadding().roundToPx()
val bottomPadding = contentPadding.calculateBottomPadding().roundToPx()
val totalVerticalPadding = topPadding + bottomPadding
val totalHorizontalPadding = startPadding + endPadding
val totalMainAxisPadding = if (isVertical) totalVerticalPadding else totalHorizontalPadding
val beforeContentPadding = when {
isVertical && !reverseLayout -> topPadding
isVertical && reverseLayout -> bottomPadding
!isVertical && !reverseLayout -> startPadding
else -> endPadding // !isVertical && reverseLayout
}
val afterContentPadding = totalMainAxisPadding - beforeContentPadding
val contentConstraints =
containerConstraints.offset(-totalHorizontalPadding, -totalVerticalPadding)
// Update the state's cached Density
state.density = this
// this will update the scope used by the item composables
itemProvider.itemScope.maxWidth = contentConstraints.maxWidth.toDp()
itemProvider.itemScope.maxHeight = contentConstraints.maxHeight.toDp()
val spaceBetweenItemsDp = if (isVertical) {
requireNotNull(verticalArrangement).spacing
} else {
requireNotNull(horizontalArrangement).spacing
}
val spaceBetweenItems = spaceBetweenItemsDp.roundToPx()
val itemsCount = itemProvider.itemCount
// can be negative if the content padding is larger than the max size from constraints
val mainAxisAvailableSize = if (isVertical) {
containerConstraints.maxHeight - totalVerticalPadding
} else {
containerConstraints.maxWidth - totalHorizontalPadding
}
val visualItemOffset = if (!reverseLayout || mainAxisAvailableSize > 0) {
IntOffset(startPadding, topPadding)
} else {
// When layout is reversed and paddings together take >100% of the available space,
// layout size is coerced to 0 when positioning. To take that space into account,
// we offset start padding by negative space between paddings.
IntOffset(
if (isVertical) startPadding else startPadding + mainAxisAvailableSize,
if (isVertical) topPadding + mainAxisAvailableSize else topPadding
)
}
val measuredItemProvider = LazyMeasuredItemProvider(
contentConstraints,
isVertical,
itemProvider,
this
) { index, key, placeables ->
// we add spaceBetweenItems as an extra spacing for all items apart from the last one so
// the lazy list measuring logic will take it into account.
val spacing = if (index.value == itemsCount - 1) 0 else spaceBetweenItems
LazyMeasuredItem(
index = index.value,
placeables = placeables,
isVertical = isVertical,
horizontalAlignment = horizontalAlignment,
verticalAlignment = verticalAlignment,
layoutDirection = layoutDirection,
reverseLayout = reverseLayout,
beforeContentPadding = beforeContentPadding,
afterContentPadding = afterContentPadding,
spacing = spacing,
visualOffset = visualItemOffset,
key = key,
placementAnimator = placementAnimator
)
}
state.premeasureConstraints = measuredItemProvider.childConstraints
val firstVisibleItemIndex: DataIndex
val firstVisibleScrollOffset: Int
Snapshot.withoutReadObservation {
firstVisibleItemIndex = DataIndex(state.firstVisibleItemIndex)
firstVisibleScrollOffset = state.firstVisibleItemScrollOffset
}
measureLazyList(
itemsCount = itemsCount,
itemProvider = measuredItemProvider,
mainAxisAvailableSize = mainAxisAvailableSize,
beforeContentPadding = beforeContentPadding,
afterContentPadding = afterContentPadding,
firstVisibleItemIndex = firstVisibleItemIndex,
firstVisibleItemScrollOffset = firstVisibleScrollOffset,
scrollToBeConsumed = state.scrollToBeConsumed,
constraints = contentConstraints,
isVertical = isVertical,
headerIndexes = itemProvider.headerIndexes,
verticalArrangement = verticalArrangement,
horizontalArrangement = horizontalArrangement,
reverseLayout = reverseLayout,
density = this,
placementAnimator = placementAnimator,
beyondBoundsInfo = beyondBoundsInfo,
layout = { width, height, placement ->
layout(
containerConstraints.constrainWidth(width + totalHorizontalPadding),
containerConstraints.constrainHeight(height + totalVerticalPadding),
emptyMap(),
placement
)
}
).also {
state.applyMeasureResult(it)
refreshOverscrollInfo(overscrollEffect, it)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
private fun refreshOverscrollInfo(
overscrollEffect: OverscrollEffect,
result: LazyListMeasureResult
) {
val canScrollForward = result.canScrollForward
val canScrollBackward = (result.firstVisibleItem?.index ?: 0) != 0 ||
result.firstVisibleItemScrollOffset != 0
overscrollEffect.isEnabled = canScrollForward || canScrollBackward
}

View file

@ -1,3 +1,5 @@
@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
package it.vfsfitvnm.reordering
import androidx.compose.animation.core.Animatable
@ -5,6 +7,9 @@ import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.animateIntAsState
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.lazy.LazyListBeyondBoundsInfo
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
@ -12,22 +17,36 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerInputChange
import kotlin.math.roundToInt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class ReorderingState(
internal val itemSizeProvider: ((Int) -> Int?)?,
val lazyListState: LazyListState,
internal val coroutineScope: CoroutineScope,
internal val lastIndex: Int,
internal val areEquals: (Int, Int) -> Boolean,
internal val orientation: Orientation,
private val lastIndex: Int,
internal val onDragStart: () -> Unit,
internal val onDragEnd: (Int, Int) -> Unit,
private val extraItemCount: Int
) {
private lateinit var lazyListBeyondBoundsInfoInterval: LazyListBeyondBoundsInfo.Interval
internal val lazyListBeyondBoundsInfo = LazyListBeyondBoundsInfo()
internal val offset: Animatable<Int, AnimationVector1D> = Animatable(0, Int.VectorConverter)
internal var draggingIndex by mutableStateOf(-1)
internal var reachedIndex by mutableStateOf(-1)
internal var draggingItemSize by mutableStateOf(0)
private var reachedIndex by mutableStateOf(-1)
private var draggingItemSize by mutableStateOf(0)
lateinit var itemInfo: LazyListItemInfo
var previousItemSize = 0
var nextItemSize = 0
private var overscrolled = 0
private val noTranslation = object : State<Int> {
override val value = 0
@ -45,27 +64,123 @@ class ReorderingState(
}
)
}
fun onDragStart(index: Int) {
overscrolled = 0
itemInfo = lazyListState.layoutInfo.visibleItemsInfo.find {
it.index == index + extraItemCount
}!!
onDragStart.invoke()
draggingIndex = index
reachedIndex = index
draggingItemSize = itemInfo.size
nextItemSize = draggingItemSize
previousItemSize = -draggingItemSize
offset.updateBounds(
lowerBound = -index * draggingItemSize,
upperBound = (lastIndex - index) * draggingItemSize
)
lazyListBeyondBoundsInfoInterval =
lazyListBeyondBoundsInfo.addInterval(index + extraItemCount, index + extraItemCount)
}
fun onDrag(change: PointerInputChange, dragAmount: Offset) {
change.consume()
val delta = when (lazyListState.layoutInfo.orientation) {
Orientation.Vertical -> dragAmount.y
Orientation.Horizontal -> dragAmount.x
}.roundToInt()
val targetOffset = offset.value + delta
coroutineScope.launch {
offset.snapTo(targetOffset)
}
if (targetOffset > nextItemSize) {
if (reachedIndex < lastIndex) {
reachedIndex += 1
nextItemSize += draggingItemSize
previousItemSize += draggingItemSize
}
} else if (targetOffset < previousItemSize) {
if (reachedIndex > 0) {
reachedIndex -= 1
previousItemSize -= draggingItemSize
nextItemSize -= draggingItemSize
}
} else {
val offsetInViewPort = targetOffset + itemInfo.offset - overscrolled
val topOverscroll = lazyListState.layoutInfo.viewportStartOffset - offsetInViewPort
val bottomOverscroll =
lazyListState.layoutInfo.viewportEndOffset - offsetInViewPort - itemInfo.size
if (topOverscroll > 0) {
overscroll(topOverscroll)
} else if (bottomOverscroll < 0) {
overscroll(bottomOverscroll)
}
}
}
fun onDragEnd() {
coroutineScope.launch {
offset.animateTo((previousItemSize + nextItemSize) / 2)
withContext(Dispatchers.Main) {
onDragEnd.invoke(draggingIndex, reachedIndex)
}
if (areEquals()) {
draggingIndex = -1
reachedIndex = -1
draggingItemSize = 0
offset.snapTo(0)
}
lazyListBeyondBoundsInfo.removeInterval(lazyListBeyondBoundsInfoInterval)
}
}
private fun overscroll(overscroll: Int) {
lazyListState.dispatchRawDelta(-overscroll.toFloat())
coroutineScope.launch {
offset.snapTo(offset.value - overscroll)
}
overscrolled -= overscroll
}
private fun areEquals(): Boolean {
return lazyListState.layoutInfo.visibleItemsInfo.find {
it.index + extraItemCount == draggingIndex
}?.key == lazyListState.layoutInfo.visibleItemsInfo.find {
it.index + extraItemCount == reachedIndex
}?.key
}
}
@Composable
fun rememberReorderingState(
items: List<Any>,
lazyListState: LazyListState,
key: Any,
onDragEnd: (Int, Int) -> Unit,
onDragStart: () -> Unit = {},
orientation: Orientation = Orientation.Vertical,
itemSizeProvider: ((Int) -> Int?)? = null
extraItemCount: Int = 0
): ReorderingState {
val coroutineScope = rememberCoroutineScope()
return remember(items) {
return remember(key) {
ReorderingState(
itemSizeProvider = itemSizeProvider,
lazyListState = lazyListState,
coroutineScope = coroutineScope,
orientation = orientation,
lastIndex = items.lastIndex,
areEquals = { i, j -> items[i] == items[j] },
lastIndex = if (key is List<*>) key.lastIndex else lazyListState.layoutInfo.totalItemsCount,
onDragStart = onDragStart,
onDragEnd = onDragEnd,
extraItemCount = extraItemCount,
)
}
}