diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6d1b69057..c10751d00 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -241,7 +241,7 @@ dependencies { implementation(libs.aboutlibraries.compose.m3) implementation(libs.accompanist.drawable.painter) - // implementation(libs.reorderable) + implementation(libs.reorderable) implementation(platform(libs.arrow.stack)) implementation(libs.arrow.fx.coroutines) diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/DownloadsScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/DownloadsScreen.kt index d3a89f371..2b1d9c2d9 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/DownloadsScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/DownloadsScreen.kt @@ -356,7 +356,12 @@ fun AnimatedVisibilityScope.DownloadsScreen(navigator: DestinationsNavigator) = } } } - ReorderableItem(reorderableLabelState, id, enabled = editEnable, animated = animateItems) { isDragging -> + ReorderableItem( + reorderableLabelState, + id, + enabled = editEnable, + animateItemModifier = Modifier.thenIf(animateItems) { animateItem() }, + ) { isDragging -> SwipeToDismissBox( state = dismissState, backgroundContent = {}, diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt index 4b9b13719..3fd0081c0 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt @@ -134,6 +134,7 @@ import com.hippo.ehviewer.ui.tools.rememberHapticFeedback import com.hippo.ehviewer.ui.tools.rememberInVM import com.hippo.ehviewer.ui.tools.rememberMutableStateInDataStore import com.hippo.ehviewer.ui.tools.snackBarPadding +import com.hippo.ehviewer.ui.tools.thenIf import com.hippo.ehviewer.util.FavouriteStatusRouter import com.hippo.ehviewer.util.pickVisualMedia import com.hippo.ehviewer.util.sha1 @@ -380,7 +381,11 @@ fun AnimatedVisibilityScope.GalleryListScreen(lub: ListUrlBuilder, navigator: De ) { itemsIndexed(quickSearchList, key = { _, item -> item.id!! }) { itemIndex, item -> val index by rememberUpdatedState(itemIndex) - ReorderableItem(reorderableLazyListState, item.id!!, animated = animateItems) { isDragging -> + ReorderableItem( + reorderableLazyListState, + item.id!!, + animateItemModifier = Modifier.thenIf(animateItems) { animateItem() }, + ) { isDragging -> // Not using rememberSwipeToDismissBoxState to prevent LazyColumn from reusing it val dismissState = remember { SwipeToDismissBoxState(SwipeToDismissBoxValue.Settled, density) } LaunchedEffect(dismissState) { diff --git a/app/src/main/kotlin/sh/calvin/reorderable/ReorderableLazyCollection.kt b/app/src/main/kotlin/sh/calvin/reorderable/ReorderableLazyCollection.kt deleted file mode 100644 index 1486a4763..000000000 --- a/app/src/main/kotlin/sh/calvin/reorderable/ReorderableLazyCollection.kt +++ /dev/null @@ -1,809 +0,0 @@ -/* - * Copyright 2023 Calvin Liang - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@file:Suppress("ConstPropertyName") - -package sh.calvin.reorderable - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.AnimationSpec -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.VectorConverter -import androidx.compose.animation.core.VisibilityThreshold -import androidx.compose.animation.core.spring -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.interaction.DragInteraction -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Stable -import androidx.compose.runtime.State -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Modifier -import androidx.compose.ui.composed -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.positionInRoot -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.toOffset -import androidx.compose.ui.unit.toSize -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withTimeout - -object ReorderableLazyCollectionDefaults { - val ScrollThreshold = 48.dp -} - -internal const val ScrollAmountMultiplier = 0.05f - -internal data class AbsolutePixelPadding( - val start: Float, - val end: Float, - val top: Float, - val bottom: Float, -) - -internal interface LazyCollectionItemInfo { - val index: Int - val key: Any - val offset: IntOffset - val size: IntSize - val data: T - - val center: IntOffset - get() = IntOffset(offset.x + size.width / 2, offset.y + size.height / 2) -} - -internal data class CollectionScrollPadding( - val start: Float, - val end: Float, -) { - companion object { - val Zero = CollectionScrollPadding(0f, 0f) - - fun fromAbsolutePixelPadding( - orientation: Orientation, - padding: AbsolutePixelPadding, - reverseLayout: Boolean, - ): CollectionScrollPadding = when (orientation) { - Orientation.Vertical -> CollectionScrollPadding( - start = padding.top, - end = padding.bottom, - ) - - Orientation.Horizontal -> CollectionScrollPadding( - start = padding.start, - end = padding.end, - ) - }.let { - when (reverseLayout) { - true -> CollectionScrollPadding( - start = it.end, - end = it.start, - ) - - false -> it - } - } - } -} - -/** - * The offsets in the main axis from the start of the content to the - * start and end of the content minus the padding. - */ -internal data class ScrollAreaOffsets( - val start: Float, - val end: Float, -) - -internal interface LazyCollectionLayoutInfo { - val visibleItemsInfo: List> - val viewportSize: IntSize - val orientation: Orientation - val reverseLayout: Boolean - val beforeContentPadding: Int - - val mainAxisViewportSize: Int - get() = when (orientation) { - Orientation.Vertical -> viewportSize.height - Orientation.Horizontal -> viewportSize.width - } - - fun getScrollAreaOffsets( - padding: AbsolutePixelPadding, - ) = getScrollAreaOffsets( - CollectionScrollPadding.fromAbsolutePixelPadding( - orientation, - padding, - reverseLayout, - ), - ) - - fun getScrollAreaOffsets(padding: CollectionScrollPadding): ScrollAreaOffsets { - val (startPadding, endPadding) = padding - val contentEndOffset = when (orientation) { - Orientation.Vertical -> viewportSize.height - Orientation.Horizontal -> viewportSize.width - } - endPadding - - return ScrollAreaOffsets( - start = startPadding, - end = contentEndOffset, - ) - } - - /** - * get items that are fully inside the content area - */ - fun getItemsInContentArea(padding: AbsolutePixelPadding) = getItemsInContentArea( - CollectionScrollPadding.fromAbsolutePixelPadding( - orientation, - padding, - reverseLayout, - ), - ) - - /** - * get items that are fully inside the content area - */ - fun getItemsInContentArea(padding: CollectionScrollPadding = CollectionScrollPadding.Zero): List> { - val (contentStartOffset, contentEndOffset) = getScrollAreaOffsets( - padding, - ) - - return when (orientation) { - Orientation.Vertical -> { - visibleItemsInfo.filter { item -> - item.offset.y >= contentStartOffset && item.offset.y + item.size.height <= contentEndOffset - } - } - - Orientation.Horizontal -> { - visibleItemsInfo.filter { item -> - item.offset.x >= contentStartOffset && item.offset.x + item.size.width <= contentEndOffset - } - } - } - } -} - -internal interface LazyCollectionState { - val firstVisibleItemIndex: Int - val firstVisibleItemScrollOffset: Int - val layoutInfo: LazyCollectionLayoutInfo - - suspend fun animateScrollBy( - value: Float, - animationSpec: AnimationSpec = spring(), - ): Float - - suspend fun scrollToItem(scrollToIndex: Int, firstVisibleItemScrollOffset: Int) - - fun requestScrollToItem(index: Int, scrollOffset: Int = 0) -} - -interface ReorderableLazyCollectionStateInterface { - val isAnyItemDragging: Boolean -} - -// base on https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/LazyColumnDragAndDropDemo.kt;drc=edde6e8b9d304264598f962a3b0e5c267e1373bb -// and https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/LazyGridDragAndDropDemo.kt;drc=44e4233f7bc0290a1509ef2d448f1309eb63248f -@Stable -open class ReorderableLazyCollectionState internal constructor( - private val state: LazyCollectionState, - private val scope: CoroutineScope, - private val onMoveState: State Unit>, - - /** - * The threshold in pixels for scrolling the list when dragging an item. - * If the dragged item is within this threshold of the top or bottom of the list, the list will scroll. - * Must be greater than 0. - */ - private val scrollThreshold: Float, - private val scrollThresholdPadding: AbsolutePixelPadding, - private val scroller: Scroller, - - private val layoutDirection: LayoutDirection, - - /** - * Whether this is a LazyVerticalStaggeredGrid - */ - private val lazyVerticalStaggeredGridRtlFix: Boolean = false, - - /** - * A function that determines whether the `draggingItem` should be moved with the `item`. - * Given their bounding rectangles, return `true` if they should be moved. - * The default implementation is to move if the dragging item's bounding rectangle has crossed the center of the item's bounding rectangle. - */ - private val shouldItemMove: (draggingItem: Rect, item: Rect) -> Boolean = { draggingItem, item -> - draggingItem.contains(item.center) - }, -) : ReorderableLazyCollectionStateInterface { - private val onMoveStateMutex: Mutex = Mutex() - - internal val orientation: Orientation - get() = state.layoutInfo.orientation - - private var draggingItemKey by mutableStateOf(null) - private val draggingItemIndex: Int? - get() = draggingItemLayoutInfo?.index - - /** - * Whether any item is being dragged. This property is observable. - */ - override val isAnyItemDragging by derivedStateOf { - draggingItemKey != null - } - - private var draggingItemDraggedDelta by mutableStateOf(Offset.Zero) - private var draggingItemInitialOffset by mutableStateOf(IntOffset.Zero) - - // visibleItemsInfo doesn't update immediately after onMove, draggingItemLayoutInfo.item may be outdated for a short time. - // not a clean solution, but it works. - private var draggingItemTargetIndex by mutableStateOf(null) - private var predictedDraggingItemOffset by mutableStateOf(null) - - private val draggingItemLayoutInfo: LazyCollectionItemInfo? - get() = draggingItemKey?.let { draggingItemKey -> - state.layoutInfo.visibleItemsInfo.firstOrNull { it.key == draggingItemKey } - } - internal val draggingItemOffset: Offset - get() = ( - draggingItemLayoutInfo?.let { - val offset = - if (it.index == draggingItemTargetIndex || draggingItemTargetIndex == null) { - draggingItemTargetIndex = null - predictedDraggingItemOffset = null - it.offset - } else { - predictedDraggingItemOffset ?: it.offset - } - - draggingItemDraggedDelta + - (draggingItemInitialOffset.toOffset() - offset.toOffset()) - .reverseAxisIfNecessary() - .reverseAxisWithLayoutDirectionIfLazyVerticalStaggeredGridRtlFix() - } - ) ?: Offset.Zero - - // the offset of the handle center from the top left of the dragging item when dragging starts - private var draggingItemHandleOffset = Offset.Zero - - internal val reorderableKeys = HashSet() - - internal var previousDraggingItemKey by mutableStateOf(null) - private set - internal var previousDraggingItemOffset = Animatable(Offset.Zero, Offset.VectorConverter) - private set - - private fun Offset.reverseAxisWithReverseLayoutIfNecessary() = - when (state.layoutInfo.reverseLayout) { - true -> reverseAxis(orientation) - false -> this - } - - private fun Offset.reverseAxisWithLayoutDirectionIfNecessary() = when (orientation) { - Orientation.Vertical -> this - Orientation.Horizontal -> reverseAxisWithLayoutDirection() - } - - private fun Offset.reverseAxisWithLayoutDirection() = when (layoutDirection) { - LayoutDirection.Ltr -> this - LayoutDirection.Rtl -> reverseAxis(Orientation.Horizontal) - } - - private fun Offset.reverseAxisWithLayoutDirectionIfLazyVerticalStaggeredGridRtlFix() = - when (layoutDirection) { - LayoutDirection.Ltr -> this - LayoutDirection.Rtl -> if (lazyVerticalStaggeredGridRtlFix && orientation == Orientation.Vertical) { - reverseAxis(Orientation.Horizontal) - } else { - this - } - } - - private fun Offset.reverseAxisIfNecessary() = - this.reverseAxisWithReverseLayoutIfNecessary() - .reverseAxisWithLayoutDirectionIfNecessary() - - private fun Offset.mainAxis() = getAxis(orientation) - - private fun IntOffset.mainAxis() = getAxis(orientation) - - internal suspend fun onDragStart(key: Any, handleOffset: Offset) { - state.layoutInfo.visibleItemsInfo.firstOrNull { item -> - item.key == key - }?.also { - val mainAxisOffset = it.offset.mainAxis() - if (mainAxisOffset < 0) { - // if item is not fully in view, scroll to it - state.animateScrollBy(mainAxisOffset.toFloat(), spring()) - } - - draggingItemKey = key - draggingItemInitialOffset = it.offset - draggingItemHandleOffset = handleOffset - } - } - - internal fun onDragStop() { - val previousDraggingItemInitialOffset = draggingItemLayoutInfo?.offset - - if (draggingItemIndex != null) { - previousDraggingItemKey = draggingItemKey - val startOffset = draggingItemOffset - scope.launch { - previousDraggingItemOffset.snapTo(startOffset) - previousDraggingItemOffset.animateTo( - Offset.Zero, - spring( - stiffness = Spring.StiffnessMediumLow, - visibilityThreshold = Offset.VisibilityThreshold, - ), - ) - previousDraggingItemKey = null - } - } - draggingItemDraggedDelta = Offset.Zero - draggingItemKey = null - draggingItemInitialOffset = previousDraggingItemInitialOffset ?: IntOffset.Zero - scroller.tryStop() - draggingItemTargetIndex = null - predictedDraggingItemOffset = null - } - - internal fun onDrag(offset: Offset) { - draggingItemDraggedDelta += offset - - val draggingItem = draggingItemLayoutInfo ?: return - // how far the dragging item is from the original position - val dragOffset = draggingItemOffset.reverseAxisIfNecessary() - .reverseAxisWithLayoutDirectionIfLazyVerticalStaggeredGridRtlFix() - val startOffset = draggingItem.offset.toOffset() + dragOffset - val endOffset = startOffset + draggingItem.size.toSize() - val (contentStartOffset, contentEndOffset) = state.layoutInfo.getScrollAreaOffsets( - scrollThresholdPadding, - ) - - // the distance from the top or left of the list to the center of the dragging item handle - val handleOffset = - when ( - state.layoutInfo.reverseLayout || - ( - layoutDirection == LayoutDirection.Rtl && - orientation == Orientation.Horizontal - ) - ) { - true -> endOffset - draggingItemHandleOffset - false -> startOffset + draggingItemHandleOffset - } + IntOffset.fromAxis( - orientation, - state.layoutInfo.beforeContentPadding, - ).toOffset() - - // check if the handle center is in the scroll threshold - val distanceFromStart = (handleOffset.getAxis(orientation) - contentStartOffset) - .coerceAtLeast(0f) - val distanceFromEnd = (contentEndOffset - handleOffset.getAxis(orientation)) - .coerceAtLeast(0f) - - val isScrollingStarted = if (distanceFromStart < scrollThreshold) { - scroller.start( - Scroller.Direction.BACKWARD, - getScrollSpeedMultiplier(distanceFromStart), - maxScrollDistanceProvider = { - // distance from the start of the dragging item's stationary position to the end of the list - ( - draggingItemLayoutInfo?.let { - state.layoutInfo.mainAxisViewportSize - - it.offset.toOffset().getAxis(orientation) - 1f - } - ) ?: 0f - }, - onScroll = { - moveDraggingItemToEnd(Scroller.Direction.BACKWARD) - }, - ) - } else if (distanceFromEnd < scrollThreshold) { - scroller.start( - Scroller.Direction.FORWARD, - getScrollSpeedMultiplier(distanceFromEnd), - maxScrollDistanceProvider = { - // distance from the end of the dragging item's stationary position to the start of the list - ( - draggingItemLayoutInfo?.let { - var itemToAlmostScrollOff = it - var scrollDistance = - itemToAlmostScrollOff.offset.toOffset().getAxis(orientation) + - itemToAlmostScrollOff.size.getAxis(orientation) - 1f - if (scrollDistance <= 0f) { - itemToAlmostScrollOff = it - scrollDistance = - itemToAlmostScrollOff.offset.toOffset().getAxis(orientation) + - itemToAlmostScrollOff.size.getAxis(orientation) - 1f - } - - scrollDistance - } - ) ?: 0f - }, - onScroll = { - moveDraggingItemToEnd(Scroller.Direction.FORWARD) - }, - ) - } else { - scroller.tryStop() - false - } - - if (!onMoveStateMutex.tryLock()) return - if (!scroller.isScrolling && !isScrollingStarted) { - val draggingItemRect = Rect(startOffset, endOffset) - // find a target item to move with - val targetItem = findTargetItem( - draggingItemRect, - items = state.layoutInfo.visibleItemsInfo, - ) { - it.index != draggingItem.index - } - if (targetItem != null) { - scope.launch { - moveItems(draggingItem, targetItem) - } - } - } - onMoveStateMutex.unlock() - } - - // keep dragging item in visible area to prevent it from disappearing - private suspend fun moveDraggingItemToEnd( - direction: Scroller.Direction, - ) { - // wait for the current moveItems to finish - onMoveStateMutex.lock() - - val draggingItem = draggingItemLayoutInfo - if (draggingItem == null) { - onMoveStateMutex.unlock() - return - } - val isDraggingItemAtEnd = when (direction) { - Scroller.Direction.FORWARD -> draggingItem.index == state.layoutInfo.visibleItemsInfo.lastOrNull()?.index - Scroller.Direction.BACKWARD -> draggingItem.index == state.firstVisibleItemIndex - } - if (isDraggingItemAtEnd) { - onMoveStateMutex.unlock() - return - } - val dragOffset = draggingItemOffset.reverseAxisIfNecessary() - .reverseAxisWithLayoutDirectionIfLazyVerticalStaggeredGridRtlFix() - val startOffset = draggingItem.offset.toOffset() + dragOffset - val endOffset = startOffset + draggingItem.size.toSize() - val draggingItemRect = Rect(startOffset, endOffset).maxOutAxis(orientation) - val targetItem = findTargetItem( - draggingItemRect, - items = state.layoutInfo.getItemsInContentArea(scrollThresholdPadding), - direction.opposite, - ) ?: state.layoutInfo.getItemsInContentArea( - scrollThresholdPadding, - ).let { - val targetItemFunc = { item: LazyCollectionItemInfo -> - item.key in reorderableKeys && item.index != state.firstVisibleItemIndex - } - when (direction) { - Scroller.Direction.FORWARD -> it.findLast(targetItemFunc) - Scroller.Direction.BACKWARD -> it.find(targetItemFunc) - } - } - val job = scope.launch { - if (targetItem != null) { - moveItems(draggingItem, targetItem) - } - } - onMoveStateMutex.unlock() - job.join() - } - - private fun Rect.maxOutAxis(orientation: Orientation): Rect = when (orientation) { - Orientation.Vertical -> copy( - top = Float.NEGATIVE_INFINITY, - bottom = Float.POSITIVE_INFINITY, - ) - - Orientation.Horizontal -> copy( - left = Float.NEGATIVE_INFINITY, - right = Float.POSITIVE_INFINITY, - ) - } - - private fun findTargetItem( - draggingItemRect: Rect, - items: List> = state.layoutInfo.getItemsInContentArea(), - direction: Scroller.Direction = Scroller.Direction.FORWARD, - additionalPredicate: (LazyCollectionItemInfo) -> Boolean = { true }, - ): LazyCollectionItemInfo? { - val targetItemFunc = { item: LazyCollectionItemInfo -> - val targetItemRect = Rect(item.offset.toOffset(), item.size.toSize()) - - shouldItemMove(draggingItemRect, targetItemRect) && - item.key in reorderableKeys && - additionalPredicate(item) - } - val targetItem = when (direction) { - Scroller.Direction.FORWARD -> items.find(targetItemFunc) - Scroller.Direction.BACKWARD -> items.findLast(targetItemFunc) - } - return targetItem - } - - private val layoutInfoFlow = snapshotFlow { state.layoutInfo } - - companion object { - const val MoveItemsLayoutInfoUpdateMaxWaitDuration = 1000L - } - - private suspend fun moveItems( - draggingItem: LazyCollectionItemInfo, - targetItem: LazyCollectionItemInfo, - ) { - if (draggingItem.index == targetItem.index) return - - if ( - draggingItem.index == state.firstVisibleItemIndex || - targetItem.index == state.firstVisibleItemIndex - ) { - state.requestScrollToItem( - state.firstVisibleItemIndex, - state.firstVisibleItemScrollOffset, - ) - } - - try { - onMoveStateMutex.withLock { - scope.(onMoveState.value)(draggingItem.data, targetItem.data) - - predictedDraggingItemOffset = if (targetItem.index > draggingItem.index) { - (targetItem.offset + targetItem.size) - draggingItem.size - } else { - targetItem.offset - } - draggingItemTargetIndex = targetItem.index - - withTimeout(MoveItemsLayoutInfoUpdateMaxWaitDuration) { - // the first result from layoutInfoFlow is the current layoutInfo - // the second result is the updated layoutInfo - layoutInfoFlow.take(2).collect() - } - } - } catch (e: CancellationException) { - // do nothing - } - } - - internal fun isItemDragging(key: Any): State = derivedStateOf { - key == draggingItemKey - } - - private fun getScrollSpeedMultiplier(distance: Float): Float { - // map distance in scrollThreshold..-scrollThreshold to 1..10 - return ( - 1 - ((distance + scrollThreshold) / (scrollThreshold * 2)).coerceIn( - 0f, - 1f, - ) - ) * 10 - } -} - -interface ReorderableCollectionItemScope { - /** - * Make the UI element the draggable handle for the reorderable item. - * - * This modifier can only be used on the UI element that is a child of [ReorderableItem]. - * - * @param enabled Whether or not drag is enabled - * @param interactionSource [MutableInteractionSource] that will be used to emit [DragInteraction.Start] when this draggable is being dragged. - * @param onDragStarted The function that is called when the item starts being dragged - * @param onDragStopped The function that is called when the item stops being dragged - */ - fun Modifier.draggableHandle( - enabled: Boolean = true, - interactionSource: MutableInteractionSource? = null, - onDragStarted: (startedPosition: Offset) -> Unit = {}, - onDragStopped: () -> Unit = {}, - ): Modifier - - /** - * Make the UI element the draggable handle for the reorderable item. Drag will start only after a long press. - * - * This modifier can only be used on the UI element that is a child of [ReorderableItem]. - * - * @param enabled Whether or not drag is enabled - * @param interactionSource [MutableInteractionSource] that will be used to emit [DragInteraction.Start] when this draggable is being dragged. - * @param onDragStarted The function that is called when the item starts being dragged - * @param onDragStopped The function that is called when the item stops being dragged - */ - fun Modifier.longPressDraggableHandle( - enabled: Boolean = true, - interactionSource: MutableInteractionSource? = null, - onDragStarted: (startedPosition: Offset) -> Unit = {}, - onDragStopped: () -> Unit = {}, - ): Modifier -} - -internal class ReorderableCollectionItemScopeImpl( - private val reorderableLazyCollectionState: ReorderableLazyCollectionState<*>, - private val key: Any, - private val itemPositionProvider: () -> Offset, -) : ReorderableCollectionItemScope { - /** - * Make the UI element the draggable handle for the reorderable item. - * - * @param enabled Whether or not drag is enabled - * @param interactionSource [MutableInteractionSource] that will be used to emit [DragInteraction.Start] when this draggable is being dragged. - * @param onDragStarted The function that is called when the item starts being dragged - * @param onDragStopped The function that is called when the item stops being dragged - */ - override fun Modifier.draggableHandle( - enabled: Boolean, - interactionSource: MutableInteractionSource?, - onDragStarted: (startedPosition: Offset) -> Unit, - onDragStopped: () -> Unit, - ) = composed { - var handleOffset by remember { mutableStateOf(Offset.Zero) } - var handleSize by remember { mutableStateOf(IntSize.Zero) } - - val coroutineScope = rememberCoroutineScope() - - onGloballyPositioned { - handleOffset = it.positionInRoot() - handleSize = it.size - }.draggable( - key1 = reorderableLazyCollectionState, - enabled = enabled && (reorderableLazyCollectionState.isItemDragging(key).value || !reorderableLazyCollectionState.isAnyItemDragging), - interactionSource = interactionSource, - onDragStarted = { - coroutineScope.launch { - val handleOffsetRelativeToItem = handleOffset - itemPositionProvider() - val handleCenter = Offset( - handleOffsetRelativeToItem.x + handleSize.width / 2f, - handleOffsetRelativeToItem.y + handleSize.height / 2f, - ) - - reorderableLazyCollectionState.onDragStart(key, handleCenter) - } - onDragStarted(it) - }, - onDragStopped = { - reorderableLazyCollectionState.onDragStop() - onDragStopped() - }, - onDrag = { change, dragAmount -> - change.consume() - reorderableLazyCollectionState.onDrag(dragAmount) - }, - ) - } - - /** - * Make the UI element the draggable handle for the reorderable item. Drag will start only after a long press. - * - * @param enabled Whether or not drag is enabled - * @param interactionSource [MutableInteractionSource] that will be used to emit [DragInteraction.Start] when this draggable is being dragged - * @param onDragStarted The function that is called when the item starts being dragged - * @param onDragStopped The function that is called when the item stops being dragged - */ - override fun Modifier.longPressDraggableHandle( - enabled: Boolean, - interactionSource: MutableInteractionSource?, - onDragStarted: (startedPosition: Offset) -> Unit, - onDragStopped: () -> Unit, - ) = composed { - var handleOffset by remember { mutableStateOf(Offset.Zero) } - var handleSize by remember { mutableStateOf(IntSize.Zero) } - - val coroutineScope = rememberCoroutineScope() - - onGloballyPositioned { - handleOffset = it.positionInRoot() - handleSize = it.size - }.longPressDraggable( - key1 = reorderableLazyCollectionState, - enabled = enabled && (reorderableLazyCollectionState.isItemDragging(key).value || !reorderableLazyCollectionState.isAnyItemDragging), - interactionSource = interactionSource, - onDragStarted = { - coroutineScope.launch { - val handleOffsetRelativeToItem = handleOffset - itemPositionProvider() - val handleCenter = Offset( - handleOffsetRelativeToItem.x + handleSize.width / 2f, - handleOffsetRelativeToItem.y + handleSize.height / 2f, - ) - - reorderableLazyCollectionState.onDragStart(key, handleCenter) - } - onDragStarted(it) - }, - onDragStopped = { - reorderableLazyCollectionState.onDragStop() - onDragStopped() - }, - onDrag = { change, dragAmount -> - change.consume() - reorderableLazyCollectionState.onDrag(dragAmount) - }, - ) - } -} - -/** - * A composable that allows items to be reordered by dragging. - * - * @param state The return value of [rememberReorderableLazyListState] - * @param key The key of the item, must be the same as the key passed to the parent composable - * @param enabled Whether or this item is reorderable. If true, the item will not move for other items but may still be draggable. To make an item not draggable, set `enable = false` in [Modifier.draggable] or [Modifier.longPressDraggable] instead. - * @param dragging Whether or not this item is currently being dragged - */ -@ExperimentalFoundationApi -@Composable -internal fun ReorderableCollectionItem( - state: ReorderableLazyCollectionState<*>, - key: Any, - modifier: Modifier = Modifier, - enabled: Boolean = true, - dragging: Boolean, - content: @Composable ReorderableCollectionItemScope.(isDragging: Boolean) -> Unit, -) { - var itemPosition by remember { mutableStateOf(Offset.Zero) } - - Box( - modifier.onGloballyPositioned { - itemPosition = it.positionInRoot() - }, - ) { - ReorderableCollectionItemScopeImpl( - reorderableLazyCollectionState = state, - key = key, - itemPositionProvider = { itemPosition }, - ).content(dragging) - } - - LaunchedEffect(state.reorderableKeys, enabled) { - if (enabled) { - state.reorderableKeys.add(key) - } else { - state.reorderableKeys.remove(key) - } - } -} diff --git a/app/src/main/kotlin/sh/calvin/reorderable/ReorderableLazyList.kt b/app/src/main/kotlin/sh/calvin/reorderable/ReorderableLazyList.kt deleted file mode 100644 index 258b81298..000000000 --- a/app/src/main/kotlin/sh/calvin/reorderable/ReorderableLazyList.kt +++ /dev/null @@ -1,259 +0,0 @@ -/* - * Copyright 2023 Calvin Liang - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package sh.calvin.reorderable - -import androidx.compose.animation.core.AnimationSpec -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.animateScrollBy -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.calculateEndPadding -import androidx.compose.foundation.layout.calculateStartPadding -import androidx.compose.foundation.lazy.LazyItemScope -import androidx.compose.foundation.lazy.LazyListItemInfo -import androidx.compose.foundation.lazy.LazyListLayoutInfo -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex -import kotlinx.coroutines.CoroutineScope - -/** - * Creates a [ReorderableLazyListState] that is remembered across compositions. - * - * Changes to [lazyListState], [scrollThresholdPadding], [scrollThreshold], and [scroller] will result in [ReorderableLazyListState] being updated. - * - * @param lazyListState The return value of [rememberLazyListState](androidx.compose.foundation.lazy.LazyListStateKt.rememberLazyListState) - * @param scrollThresholdPadding The padding that will be added to the top and bottom, or start and end of the list to determine the scrollThreshold. Useful for when the grid is displayed under the navigation bar or notification bar. - * @param scrollThreshold The distance in dp from the top and bottom, or start and end of the list that will trigger scrolling - * @param scroller The [Scroller] that will be used to scroll the list. Use [rememberScroller](sh.calvin.reorderable.ScrollerKt.rememberScroller) to create a [Scroller]. - * @param onMove The function that is called when an item is moved. Make sure this function returns only after the items are moved. This suspend function is invoked with the `rememberReorderableLazyListState` scope, allowing for async processing, if desired. Note that the scope used here is the one provided by the composition where `rememberReorderableLazyListState` is called, for long running work that needs to outlast `rememberReorderableLazyListState` being in the composition you should use a scope that fits the lifecycle needed. - */ -@Composable -fun rememberReorderableLazyListState( - lazyListState: LazyListState, - scrollThresholdPadding: PaddingValues = PaddingValues(0.dp), - scrollThreshold: Dp = ReorderableLazyCollectionDefaults.ScrollThreshold, - scroller: Scroller = rememberScroller( - scrollableState = lazyListState, - pixelAmountProvider = { lazyListState.layoutInfo.mainAxisViewportSize * ScrollAmountMultiplier }, - ), - onMove: suspend CoroutineScope.(from: LazyListItemInfo, to: LazyListItemInfo) -> Unit, -): ReorderableLazyListState { - val density = LocalDensity.current - val scrollThresholdPx = with(density) { scrollThreshold.toPx() } - - val scope = rememberCoroutineScope() - val onMoveState = rememberUpdatedState(onMove) - val layoutDirection = LocalLayoutDirection.current - val absoluteScrollThresholdPadding = AbsolutePixelPadding( - start = with(density) { - scrollThresholdPadding.calculateStartPadding(layoutDirection).toPx() - }, - end = with(density) { - scrollThresholdPadding.calculateEndPadding(layoutDirection).toPx() - }, - top = with(density) { scrollThresholdPadding.calculateTopPadding().toPx() }, - bottom = with(density) { scrollThresholdPadding.calculateBottomPadding().toPx() }, - ) - val orientation by remember { derivedStateOf { lazyListState.layoutInfo.orientation } } - val state = remember( - scope, - lazyListState, - scrollThreshold, - scrollThresholdPadding, - scroller, - orientation, - ) { - ReorderableLazyListState( - state = lazyListState, - scope = scope, - onMoveState = onMoveState, - scrollThreshold = scrollThresholdPx, - scrollThresholdPadding = absoluteScrollThresholdPadding, - scroller = scroller, - layoutDirection = layoutDirection, - shouldItemMove = when (orientation) { - Orientation.Vertical -> { draggingItem, item -> - item.center.y in draggingItem.top.. { draggingItem, item -> - item.center.x in draggingItem.left.. viewportSize.height - Orientation.Horizontal -> viewportSize.width - } - -private fun LazyListItemInfo.toLazyCollectionItemInfo(orientation: Orientation) = - object : LazyCollectionItemInfo { - override val index: Int - get() = this@toLazyCollectionItemInfo.index - override val key: Any - get() = this@toLazyCollectionItemInfo.key - override val offset: IntOffset - get() = IntOffset.fromAxis(orientation, this@toLazyCollectionItemInfo.offset) - override val size: IntSize - get() = IntSize.fromAxis(orientation, this@toLazyCollectionItemInfo.size) - override val data: LazyListItemInfo - get() = this@toLazyCollectionItemInfo - } - -private fun LazyListLayoutInfo.toLazyCollectionLayoutInfo() = - object : LazyCollectionLayoutInfo { - override val visibleItemsInfo: List> - get() = this@toLazyCollectionLayoutInfo.visibleItemsInfo.map { - it.toLazyCollectionItemInfo(orientation) - } - override val viewportSize: IntSize - get() = this@toLazyCollectionLayoutInfo.viewportSize - override val orientation: Orientation - get() = this@toLazyCollectionLayoutInfo.orientation - override val reverseLayout: Boolean - get() = this@toLazyCollectionLayoutInfo.reverseLayout - override val beforeContentPadding: Int - get() = this@toLazyCollectionLayoutInfo.beforeContentPadding - } - -private fun LazyListState.toLazyCollectionState() = - object : LazyCollectionState { - override val firstVisibleItemIndex: Int - get() = this@toLazyCollectionState.firstVisibleItemIndex - override val firstVisibleItemScrollOffset: Int - get() = this@toLazyCollectionState.firstVisibleItemScrollOffset - override val layoutInfo: LazyCollectionLayoutInfo - get() = this@toLazyCollectionState.layoutInfo.toLazyCollectionLayoutInfo() - - override suspend fun animateScrollBy(value: Float, animationSpec: AnimationSpec) = - this@toLazyCollectionState.animateScrollBy(value, animationSpec) - - override suspend fun scrollToItem(scrollToIndex: Int, firstVisibleItemScrollOffset: Int) = - this@toLazyCollectionState.scrollToItem(scrollToIndex, firstVisibleItemScrollOffset) - - override fun requestScrollToItem(index: Int, scrollOffset: Int) = - this@toLazyCollectionState.requestScrollToItem(index, scrollOffset) - } - -class ReorderableLazyListState internal constructor( - state: LazyListState, - scope: CoroutineScope, - onMoveState: State Unit>, - - /** - * The threshold in pixels for scrolling the list when dragging an item. - * If the dragged item is within this threshold of the top or bottom of the list, the list will scroll. - * Must be greater than 0. - */ - scrollThreshold: Float, - scrollThresholdPadding: AbsolutePixelPadding, - scroller: Scroller, - layoutDirection: LayoutDirection, - shouldItemMove: (draggingItem: Rect, item: Rect) -> Boolean, -) : ReorderableLazyCollectionState( - state.toLazyCollectionState(), - scope, - onMoveState, - scrollThreshold, - scrollThresholdPadding, - scroller, - layoutDirection, - shouldItemMove = shouldItemMove, -) - -/** - * A composable that allows an item in LazyColumn or LazyRow to be reordered by dragging. - * - * @param state The return value of [rememberReorderableLazyListState] - * @param key The key of the item, must be the same as the key passed to [LazyListScope.item](androidx.compose.foundation.lazy.item), [LazyListScope.items](androidx.compose.foundation.lazy.items) or similar functions in [LazyListScope](androidx.compose.foundation.lazy.LazyListScope) - * @param enabled Whether or this item is reorderable. If true, the item will not move for other items but may still be draggable. To make an item not draggable, set `enable = false` in [Modifier.draggable] or [Modifier.longPressDraggable] instead. - */ -@ExperimentalFoundationApi -@Composable -fun LazyItemScope.ReorderableItem( - state: ReorderableLazyListState, - key: Any, - modifier: Modifier = Modifier, - enabled: Boolean = true, - animated: Boolean = true, - content: @Composable ReorderableCollectionItemScope.(isDragging: Boolean) -> Unit, -) { - val orientation = state.orientation - val dragging by state.isItemDragging(key) - val offsetModifier = if (dragging) { - Modifier - .zIndex(1f) - .then( - when (orientation) { - Orientation.Vertical -> Modifier.graphicsLayer { - translationY = state.draggingItemOffset.y - } - - Orientation.Horizontal -> Modifier.graphicsLayer { - translationX = state.draggingItemOffset.x - } - }, - ) - } else if (key == state.previousDraggingItemKey) { - Modifier - .zIndex(1f) - .then( - when (orientation) { - Orientation.Vertical -> Modifier.graphicsLayer { - translationY = state.previousDraggingItemOffset.value.y - } - - Orientation.Horizontal -> Modifier.graphicsLayer { - translationX = state.previousDraggingItemOffset.value.x - } - }, - ) - } else { - if (animated) Modifier.animateItem() else Modifier - } - - ReorderableCollectionItem( - state = state, - key = key, - modifier = modifier.then(offsetModifier), - enabled = enabled, - dragging = dragging, - content = content, - ) -} diff --git a/app/src/main/kotlin/sh/calvin/reorderable/Scroller.kt b/app/src/main/kotlin/sh/calvin/reorderable/Scroller.kt deleted file mode 100644 index 95c0e7284..000000000 --- a/app/src/main/kotlin/sh/calvin/reorderable/Scroller.kt +++ /dev/null @@ -1,183 +0,0 @@ -/* - * Copyright 2023 Calvin Liang - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@file:Suppress("ConstPropertyName") - -package sh.calvin.reorderable - -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.tween -import androidx.compose.foundation.gestures.ScrollableState -import androidx.compose.foundation.gestures.animateScrollBy -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -/** - * A utility to programmatically scroll a [ScrollableState]. - * - * @param scrollableState The [ScrollableState] to scroll. - * @param pixelAmountProvider A function that returns the amount of pixels to scroll per duration. - * @param duration The duration of each scroll in milliseconds. - */ -@Composable -fun rememberScroller( - scrollableState: ScrollableState, - pixelAmountProvider: () -> Float, - duration: Long = 100, -): Scroller { - val scope = rememberCoroutineScope() - val pixelAmountProviderUpdated = rememberUpdatedState(pixelAmountProvider) - val durationUpdated = rememberUpdatedState(duration) - - return remember(scrollableState, scope, duration) { - Scroller( - scrollableState, - scope, - pixelPerSecondProvider = { - pixelAmountProviderUpdated.value() / (durationUpdated.value / 1000f) - }, - ) - } -} - -@Stable -class Scroller internal constructor( - private val scrollableState: ScrollableState, - private val scope: CoroutineScope, - private val pixelPerSecondProvider: () -> Float, -) { - companion object { - // The maximum duration for a scroll animation in milliseconds. - private const val MaxScrollDuration = 100L - private const val ZeroScrollWaitDuration = 100L - } - - internal enum class Direction { - BACKWARD, - FORWARD, - ; - - val opposite: Direction - get() = when (this) { - BACKWARD -> FORWARD - FORWARD -> BACKWARD - } - } - - private data class ScrollInfo( - val direction: Direction, - val speedMultiplier: Float, - val maxScrollDistanceProvider: () -> Float, - val onScroll: suspend () -> Unit, - ) { - companion object { - val Null = ScrollInfo(Direction.FORWARD, 0f, { 0f }, {}) - } - } - - private var programmaticScrollJob: Job? = null - val isScrolling: Boolean - get() = programmaticScrollJob?.isActive == true - - private val scrollInfoChannel = Channel(Channel.CONFLATED) - - internal fun start( - direction: Direction, - speedMultiplier: Float = 1f, - maxScrollDistanceProvider: () -> Float = { Float.MAX_VALUE }, - onScroll: suspend () -> Unit = {}, - ): Boolean { - if (!canScroll(direction)) return false - - if (programmaticScrollJob == null) { - programmaticScrollJob = scope.launch { - scrollLoop() - } - } - - val scrollInfo = - ScrollInfo(direction, speedMultiplier, maxScrollDistanceProvider, onScroll) - - scrollInfoChannel.trySend(scrollInfo) - return true - } - - private suspend fun scrollLoop() { - var scrollInfo: ScrollInfo? = null - - while (true) { - scrollInfo = scrollInfoChannel.tryReceive().getOrNull() ?: scrollInfo - if (scrollInfo == null || scrollInfo == ScrollInfo.Null) break - - val (direction, speedMultiplier, maxScrollDistanceProvider, onScroll) = scrollInfo - - val pixelPerSecond = pixelPerSecondProvider() * speedMultiplier - val pixelPerMs = pixelPerSecond / 1000f - - onScroll() - - if (!canScroll(direction)) break - - val maxScrollDistance = maxScrollDistanceProvider() - if (maxScrollDistance <= 0f) { - delay(ZeroScrollWaitDuration) - continue - } - val maxScrollDistanceDuration = maxScrollDistance / pixelPerMs - val duration = - maxScrollDistanceDuration.toLong().coerceIn(1L, MaxScrollDuration) - val scrollDistance = - maxScrollDistance * (duration / maxScrollDistanceDuration) - val diff = scrollDistance.let { - when (direction) { - Direction.BACKWARD -> -it - Direction.FORWARD -> it - } - } - - scrollableState.animateScrollBy( - diff, - tween(durationMillis = duration.toInt(), easing = LinearEasing), - ) - } - } - - private fun canScroll(direction: Direction): Boolean = when (direction) { - Direction.BACKWARD -> scrollableState.canScrollBackward - Direction.FORWARD -> scrollableState.canScrollForward - } - - internal suspend fun stop() { - scrollInfoChannel.send(ScrollInfo.Null) - programmaticScrollJob?.cancelAndJoin() - programmaticScrollJob = null - } - - internal fun tryStop() { - scope.launch { - stop() - } - } -} diff --git a/app/src/main/kotlin/sh/calvin/reorderable/draggable.kt b/app/src/main/kotlin/sh/calvin/reorderable/draggable.kt deleted file mode 100644 index 5e9e78976..000000000 --- a/app/src/main/kotlin/sh/calvin/reorderable/draggable.kt +++ /dev/null @@ -1,170 +0,0 @@ -@file:Suppress("ktlint:standard:filename") - -package sh.calvin.reorderable - -import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress -import androidx.compose.foundation.interaction.DragInteraction -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.composed -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.pointer.PointerInputChange -import androidx.compose.ui.input.pointer.pointerInput -import kotlinx.coroutines.launch - -internal fun Modifier.draggable( - key1: Any?, - enabled: Boolean = true, - interactionSource: MutableInteractionSource? = null, - onDragStarted: (Offset) -> Unit = { }, - onDragStopped: () -> Unit = { }, - onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit, -) = composed { - val coroutineScope = rememberCoroutineScope() - var dragInteractionStart by remember { mutableStateOf(null) } - var dragStarted by remember { mutableStateOf(false) } - - DisposableEffect(key1) { - onDispose { - if (dragStarted) { - dragInteractionStart?.also { - coroutineScope.launch { - interactionSource?.emit(DragInteraction.Cancel(it)) - } - } - - if (dragStarted) { - onDragStopped() - } - - dragStarted = false - } - } - } - - pointerInput(key1, enabled) { - if (enabled) { - detectDragGestures( - onDragStart = { - dragStarted = true - dragInteractionStart = DragInteraction.Start().also { - coroutineScope.launch { - interactionSource?.emit(it) - } - } - - onDragStarted(it) - }, - onDragEnd = { - dragInteractionStart?.also { - coroutineScope.launch { - interactionSource?.emit(DragInteraction.Stop(it)) - } - } - - if (dragStarted) { - onDragStopped() - } - - dragStarted = false - }, - onDragCancel = { - dragInteractionStart?.also { - coroutineScope.launch { - interactionSource?.emit(DragInteraction.Cancel(it)) - } - } - - if (dragStarted) { - onDragStopped() - } - - dragStarted = false - }, - onDrag = onDrag, - ) - } - } -} - -internal fun Modifier.longPressDraggable( - key1: Any?, - enabled: Boolean = true, - interactionSource: MutableInteractionSource? = null, - onDragStarted: (Offset) -> Unit = { }, - onDragStopped: () -> Unit = { }, - onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit, -) = composed { - val coroutineScope = rememberCoroutineScope() - var dragInteractionStart by remember { mutableStateOf(null) } - var dragStarted by remember { mutableStateOf(false) } - - DisposableEffect(key1) { - onDispose { - if (dragStarted) { - dragInteractionStart?.also { - coroutineScope.launch { - interactionSource?.emit(DragInteraction.Cancel(it)) - } - } - - if (dragStarted) { - onDragStopped() - } - - dragStarted = false - } - } - } - - pointerInput(key1, enabled) { - if (enabled) { - detectDragGesturesAfterLongPress( - onDragStart = { - dragStarted = true - dragInteractionStart = DragInteraction.Start().also { - coroutineScope.launch { - interactionSource?.emit(it) - } - } - - onDragStarted(it) - }, - onDragEnd = { - dragInteractionStart?.also { - coroutineScope.launch { - interactionSource?.emit(DragInteraction.Stop(it)) - } - } - - if (dragStarted) { - onDragStopped() - } - - dragStarted = false - }, - onDragCancel = { - dragInteractionStart?.also { - coroutineScope.launch { - interactionSource?.emit(DragInteraction.Cancel(it)) - } - } - - if (dragStarted) { - onDragStopped() - } - - dragStarted = false - }, - onDrag = onDrag, - ) - } - } -} diff --git a/app/src/main/kotlin/sh/calvin/reorderable/util.kt b/app/src/main/kotlin/sh/calvin/reorderable/util.kt deleted file mode 100644 index 29638673a..000000000 --- a/app/src/main/kotlin/sh/calvin/reorderable/util.kt +++ /dev/null @@ -1,48 +0,0 @@ -@file:Suppress("ktlint:standard:filename") - -package sh.calvin.reorderable - -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntSize - -internal fun Offset.getAxis(orientation: Orientation) = when (orientation) { - Orientation.Vertical -> y - Orientation.Horizontal -> x -} - -internal fun IntOffset.getAxis(orientation: Orientation) = when (orientation) { - Orientation.Vertical -> y - Orientation.Horizontal -> x -} - -internal fun IntSize.getAxis(orientation: Orientation) = when (orientation) { - Orientation.Vertical -> height - Orientation.Horizontal -> width -} - -internal fun Offset.reverseAxis(orientation: Orientation) = - when (orientation) { - Orientation.Vertical -> Offset(x, -y) - Orientation.Horizontal -> Offset(-x, y) - } - -internal fun IntOffset.Companion.fromAxis(orientation: Orientation, value: Int) = - when (orientation) { - Orientation.Vertical -> IntOffset(0, value) - Orientation.Horizontal -> IntOffset(value, 0) - } - -internal fun IntSize.Companion.fromAxis(orientation: Orientation, value: Int) = - when (orientation) { - Orientation.Vertical -> IntSize(0, value) - Orientation.Horizontal -> IntSize(value, 0) - } - -internal operator fun Offset.plus(size: Size) = Offset(x + size.width, y + size.height) -internal operator fun Offset.minus(size: Size) = Offset(x - size.width, y - size.height) - -internal operator fun IntOffset.plus(size: IntSize) = IntOffset(x + size.width, y + size.height) -internal operator fun IntOffset.minus(size: IntSize) = IntOffset(x - size.width, y - size.height) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7bacbcd4e..87f0938ce 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -99,7 +99,7 @@ logcat = "com.squareup.logcat:logcat:0.1" okio-jvm = { module = "com.squareup.okio:okio-jvm", version.ref = "okio" } -reorderable = "sh.calvin.reorderable:reorderable:2.3.0" +reorderable = "sh.calvin.reorderable:reorderable:2.4.0-alpha01" serialization-cbor = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cbor", version.ref = "kotlinx-serialization" } serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }