diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/LocalPlaylistScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/LocalPlaylistScreen.kt index 9bda7fc..fef808e 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/LocalPlaylistScreen.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/LocalPlaylistScreen.kt @@ -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 ) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/CurrentPlaylistView.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/CurrentPlaylistView.kt index d46f9e4..4cfa97d 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/CurrentPlaylistView.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/CurrentPlaylistView.kt @@ -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 ) diff --git a/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/DragToReorder.kt b/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/DragToReorder.kt deleted file mode 100644 index 024fb27..0000000 --- a/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/DragToReorder.kt +++ /dev/null @@ -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 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, -) diff --git a/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/DraggedItem.kt b/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/DraggedItem.kt index 2abcb99..249d6b4 100644 --- a/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/DraggedItem.kt +++ b/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/DraggedItem.kt @@ -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) } diff --git a/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/Reorder.kt b/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/Reorder.kt new file mode 100644 index 0000000..c061487 --- /dev/null +++ b/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/Reorder.kt @@ -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 + ) +} diff --git a/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/ReorderingLazyColumn.kt b/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/ReorderingLazyColumn.kt new file mode 100644 index 0000000..895b433 --- /dev/null +++ b/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/ReorderingLazyColumn.kt @@ -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 + ) +} diff --git a/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/ReorderingLazyList.kt b/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/ReorderingLazyList.kt new file mode 100644 index 0000000..e386208 --- /dev/null +++ b/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/ReorderingLazyList.kt @@ -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 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 +} diff --git a/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/ReorderingState.kt b/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/ReorderingState.kt index bf9b2f0..1a24af1 100644 --- a/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/ReorderingState.kt +++ b/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/ReorderingState.kt @@ -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 = 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 { 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, + 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, ) } }