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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.BasicText 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.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import it.vfsfitvnm.reordering.ReorderingLazyColumn
import it.vfsfitvnm.reordering.animateItemPlacement import it.vfsfitvnm.reordering.animateItemPlacement
import it.vfsfitvnm.reordering.draggedItem import it.vfsfitvnm.reordering.draggedItem
import it.vfsfitvnm.reordering.rememberReorderingState import it.vfsfitvnm.reordering.rememberReorderingState
import it.vfsfitvnm.reordering.verticalDragToReorder import it.vfsfitvnm.reordering.reorder
import it.vfsfitvnm.route.RouteHandler import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
@@ -92,7 +92,8 @@ fun LocalPlaylistScreen(playlistId: Long) {
val thumbnailSize = Dimensions.thumbnails.song.px val thumbnailSize = Dimensions.thumbnails.song.px
val reorderingState = rememberReorderingState( val reorderingState = rememberReorderingState(
items = playlistWithSongs.songs, lazyListState = lazyListState,
key = playlistWithSongs.songs,
onDragStart = { onDragStart = {
hapticFeedback.performHapticFeedback( hapticFeedback.performHapticFeedback(
HapticFeedbackType.LongPress HapticFeedbackType.LongPress
@@ -123,11 +124,7 @@ fun LocalPlaylistScreen(playlistId: Long) {
) )
} }
}, },
itemSizeProvider = { index -> extraItemCount = 3
lazyListState.layoutInfo.visibleItemsInfo.find {
it.index == index + 3
}?.size
}
) )
var isRenaming by rememberSaveable { var isRenaming by rememberSaveable {
@@ -164,8 +161,8 @@ fun LocalPlaylistScreen(playlistId: Long) {
) )
} }
LazyColumn( ReorderingLazyColumn(
state = lazyListState, reorderingState = reorderingState,
contentPadding = WindowInsets.systemBars.asPaddingValues() contentPadding = WindowInsets.systemBars.asPaddingValues()
.add(bottom = Dimensions.collapsedPlayer), .add(bottom = Dimensions.collapsedPlayer),
modifier = Modifier modifier = Modifier
@@ -311,7 +308,7 @@ fun LocalPlaylistScreen(playlistId: Long) {
colorFilter = ColorFilter.tint(colorPalette.textSecondary), colorFilter = ColorFilter.tint(colorPalette.textSecondary),
modifier = Modifier modifier = Modifier
.clickable { } .clickable { }
.verticalDragToReorder( .reorder(
reorderingState = reorderingState, reorderingState = reorderingState,
index = index 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape 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.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.valentinilk.shimmer.shimmer import com.valentinilk.shimmer.shimmer
import it.vfsfitvnm.reordering.ReorderingLazyColumn
import it.vfsfitvnm.reordering.animateItemPlacement import it.vfsfitvnm.reordering.animateItemPlacement
import it.vfsfitvnm.reordering.draggedItem import it.vfsfitvnm.reordering.draggedItem
import it.vfsfitvnm.reordering.rememberReorderingState import it.vfsfitvnm.reordering.rememberReorderingState
import it.vfsfitvnm.reordering.verticalDragToReorder import it.vfsfitvnm.reordering.reorder
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness
@@ -79,11 +79,9 @@ fun CurrentPlaylistView(
val windows by rememberWindows(binder.player) val windows by rememberWindows(binder.player)
val shouldBePlaying by rememberShouldBePlaying(binder.player) val shouldBePlaying by rememberShouldBePlaying(binder.player)
val lazyListState =
rememberLazyListState(initialFirstVisibleItemIndex = mediaItemIndex)
val reorderingState = rememberReorderingState( val reorderingState = rememberReorderingState(
items = windows, lazyListState = rememberLazyListState(initialFirstVisibleItemIndex = mediaItemIndex),
key = windows,
onDragStart = { onDragStart = {
hapticFeedback.performHapticFeedback( hapticFeedback.performHapticFeedback(
HapticFeedbackType.LongPress HapticFeedbackType.LongPress
@@ -92,24 +90,20 @@ fun CurrentPlaylistView(
onDragEnd = { fromIndex, toIndex -> onDragEnd = { fromIndex, toIndex ->
binder.player.moveMediaItem(fromIndex, toIndex) binder.player.moveMediaItem(fromIndex, toIndex)
}, },
itemSizeProvider = { index -> extraItemCount = 0
lazyListState.layoutInfo.visibleItemsInfo.find {
it.index == index
}?.size
}
) )
val paddingValues = WindowInsets.systemBars.asPaddingValues() val paddingValues = WindowInsets.systemBars.asPaddingValues()
val bottomPadding = paddingValues.calculateBottomPadding() val bottomPadding = paddingValues.calculateBottomPadding()
Column { Column {
LazyColumn( ReorderingLazyColumn(
state = lazyListState, reorderingState = reorderingState,
contentPadding = paddingValues.add(bottom = -bottomPadding), contentPadding = paddingValues.add(bottom = -bottomPadding),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier modifier = modifier
.nestedScroll(remember { .nestedScroll(remember {
layoutState.nestedScrollConnection(lazyListState.firstVisibleItemIndex == 0 && lazyListState.firstVisibleItemScrollOffset == 0) layoutState.nestedScrollConnection(reorderingState.lazyListState.firstVisibleItemIndex == 0 && reorderingState.lazyListState.firstVisibleItemScrollOffset == 0)
}) })
.background(colorPalette.background1) .background(colorPalette.background1)
.weight(1f) .weight(1f)
@@ -182,7 +176,7 @@ fun CurrentPlaylistView(
colorFilter = ColorFilter.tint(colorPalette.textSecondary), colorFilter = ColorFilter.tint(colorPalette.textSecondary),
modifier = Modifier modifier = Modifier
.clickable { } .clickable { }
.verticalDragToReorder( .reorder(
reorderingState = reorderingState, reorderingState = reorderingState,
index = window.firstPeriodIndex 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) val translation by reorderingState.translationFor(index)
offset { offset {
when (reorderingState.orientation) { when (reorderingState.lazyListState.layoutInfo.orientation) {
Orientation.Vertical -> IntOffset(0, translation) Orientation.Vertical -> IntOffset(0, translation)
Orientation.Horizontal -> IntOffset(translation, 0) 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 package it.vfsfitvnm.reordering
import androidx.compose.animation.core.Animatable 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.VectorConverter
import androidx.compose.animation.core.animateIntAsState import androidx.compose.animation.core.animateIntAsState
import androidx.compose.foundation.gestures.Orientation 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.Composable
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -12,22 +17,36 @@ 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.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerInputChange
import kotlin.math.roundToInt
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class ReorderingState( class ReorderingState(
internal val itemSizeProvider: ((Int) -> Int?)?, val lazyListState: LazyListState,
internal val coroutineScope: CoroutineScope, internal val coroutineScope: CoroutineScope,
internal val lastIndex: Int, private val lastIndex: Int,
internal val areEquals: (Int, Int) -> Boolean,
internal val orientation: Orientation,
internal val onDragStart: () -> Unit, internal val onDragStart: () -> Unit,
internal val onDragEnd: (Int, Int) -> 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 val offset: Animatable<Int, AnimationVector1D> = Animatable(0, Int.VectorConverter)
internal var draggingIndex by mutableStateOf(-1) internal var draggingIndex by mutableStateOf(-1)
internal var reachedIndex by mutableStateOf(-1) private var reachedIndex by mutableStateOf(-1)
internal var draggingItemSize by mutableStateOf(0) 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> { private val noTranslation = object : State<Int> {
override val value = 0 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 @Composable
fun rememberReorderingState( fun rememberReorderingState(
items: List<Any>, lazyListState: LazyListState,
key: Any,
onDragEnd: (Int, Int) -> Unit, onDragEnd: (Int, Int) -> Unit,
onDragStart: () -> Unit = {}, onDragStart: () -> Unit = {},
orientation: Orientation = Orientation.Vertical, extraItemCount: Int = 0
itemSizeProvider: ((Int) -> Int?)? = null
): ReorderingState { ): ReorderingState {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
return remember(items) { return remember(key) {
ReorderingState( ReorderingState(
itemSizeProvider = itemSizeProvider, lazyListState = lazyListState,
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
orientation = orientation, lastIndex = if (key is List<*>) key.lastIndex else lazyListState.layoutInfo.totalItemsCount,
lastIndex = items.lastIndex,
areEquals = { i, j -> items[i] == items[j] },
onDragStart = onDragStart, onDragStart = onDragStart,
onDragEnd = onDragEnd, onDragEnd = onDragEnd,
extraItemCount = extraItemCount,
) )
} }
} }