diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 150d1850d..d0c8d8a1a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -230,7 +230,7 @@ dependencies { implementation(libs.insetter) // Dead Dependency - implementation(libs.reorderable) + // implementation(libs.reorderable) implementation(platform(libs.arrow.stack)) implementation(libs.arrow.fx.coroutines) diff --git a/app/src/main/kotlin/androidx/compose/foundation/gestures/AnchoredHorizontalDraggable.kt b/app/src/main/kotlin/androidx/compose/foundation/gestures/AnchoredHorizontalDraggable.kt index f6d9e116e..cea70922a 100644 --- a/app/src/main/kotlin/androidx/compose/foundation/gestures/AnchoredHorizontalDraggable.kt +++ b/app/src/main/kotlin/androidx/compose/foundation/gestures/AnchoredHorizontalDraggable.kt @@ -16,8 +16,14 @@ package androidx.compose.foundation.gestures +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.DecayAnimationSpec +import androidx.compose.animation.core.FloatDecayAnimationSpec +import androidx.compose.animation.core.generateDecayAnimationSpec import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.gestures.HorizontalDragEvent.DragDelta +import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider +import androidx.compose.foundation.gestures.snapping.snapFlingBehavior import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerInputChange @@ -27,15 +33,23 @@ import androidx.compose.ui.layout.MeasureResult import androidx.compose.ui.layout.MeasureScope import androidx.compose.ui.node.LayoutModifierNode import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.ObserverModifierNode +import androidx.compose.ui.node.currentValueOf +import androidx.compose.ui.node.observeReads import androidx.compose.ui.node.requireLayoutDirection import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.debugInspectorInfo import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import kotlin.math.abs import kotlin.math.absoluteValue import kotlin.math.roundToInt +import kotlin.math.sign import kotlinx.coroutines.launch @ExperimentalFoundationApi @@ -45,6 +59,7 @@ fun Modifier.anchoredHorizontalDraggable( enableDragFromEndToStart: Boolean = true, enabled: Boolean = true, startDragImmediately: Boolean = state.isAnimationRunning, + flingBehavior: FlingBehavior? = null, ): Modifier = this then AnchoredHorizontalDraggableElement( state = state, canDrag = { (x, y) -> @@ -53,20 +68,22 @@ fun Modifier.anchoredHorizontalDraggable( }, enabled = enabled, startDragImmediately = startDragImmediately, + flingBehavior = flingBehavior, ) -@OptIn(ExperimentalFoundationApi::class) private class AnchoredHorizontalDraggableElement( private val state: AnchoredDraggableState, private val canDrag: (Offset) -> Boolean, private val enabled: Boolean, private val startDragImmediately: Boolean, + private val flingBehavior: FlingBehavior?, ) : ModifierNodeElement>() { override fun create() = AnchoredHorizontalDraggableNode( state, canDrag, enabled, startDragImmediately, + flingBehavior, ) override fun update(node: AnchoredHorizontalDraggableNode) { @@ -75,6 +92,7 @@ private class AnchoredHorizontalDraggableElement( canDrag, enabled, startDragImmediately, + flingBehavior, ) } @@ -83,6 +101,7 @@ private class AnchoredHorizontalDraggableElement( result = 31 * result + canDrag.hashCode() result = 31 * result + enabled.hashCode() result = 31 * result + startDragImmediately.hashCode() + result = 31 * result + flingBehavior.hashCode() return result } @@ -95,6 +114,7 @@ private class AnchoredHorizontalDraggableElement( if (canDrag != other.canDrag) return false if (enabled != other.enabled) return false if (startDragImmediately != other.startDragImmediately) return false + if (flingBehavior != other.flingBehavior) return false return true } @@ -105,22 +125,54 @@ private class AnchoredHorizontalDraggableElement( properties["canDrag"] = canDrag properties["enabled"] = enabled properties["startDragImmediately"] = startDragImmediately + properties["flingBehavior"] = flingBehavior } } -@OptIn(ExperimentalFoundationApi::class) private class AnchoredHorizontalDraggableNode( private var state: AnchoredDraggableState, private var canDrag: (Offset) -> Boolean, enabled: Boolean, private var startDragImmediately: Boolean, -) : HorizontalDragGestureNode( - enabled = enabled, -) { + private var flingBehavior: FlingBehavior?, +) : HorizontalDragGestureNode(enabled = enabled), + ObserverModifierNode { + + lateinit var resolvedFlingBehavior: FlingBehavior + lateinit var density: Density private val isReverseDirection: Boolean get() = requireLayoutDirection() == LayoutDirection.Rtl + override fun onAttach() { + updateFlingBehavior(flingBehavior) + } + + override fun onObservedReadsChanged() { + val newDensity = currentValueOf(LocalDensity) + if (density != newDensity) { + density = newDensity + updateFlingBehavior(flingBehavior) + } + } + + private fun updateFlingBehavior(newFlingBehavior: FlingBehavior?) { + // Fall back to default fling behavior if the new fling behavior is null + this.resolvedFlingBehavior = + if (newFlingBehavior == null) { + // Only register for LocalDensity snapshot updates if we are creating a decay + observeReads { density = currentValueOf(LocalDensity) } + anchoredDraggableFlingBehavior( + snapAnimationSpec = AnchoredDraggableDefaults.SnapAnimationSpec, + positionalThreshold = AnchoredDraggableDefaults.PositionalThreshold, + density = density, + state = state, + ) + } else { + newFlingBehavior + } + } + override fun canDrag(change: PointerInputChange): Boolean = canDrag(change.positionChange().reverseIfNeeded()) override suspend fun drag(forEachDelta: suspend ((dragDelta: DragDelta) -> Unit) -> Unit) { @@ -136,10 +188,29 @@ private class AnchoredHorizontalDraggableNode( override fun onDragStopped(velocity: Velocity) { if (!isAttached) return coroutineScope.launch { - state.settle(velocity.reverseIfNeeded().x) + fling(velocity.reverseIfNeeded().x) } } + private suspend fun fling(velocity: Float): Float { + var leftoverVelocity = velocity + state.anchoredDrag { + val scrollScope = + object : ScrollScope { + override fun scrollBy(pixels: Float): Float { + val newOffset = state.newOffsetForDelta(pixels) + val consumed = newOffset - state.offset + dragTo(newOffset) + return consumed + } + } + with(resolvedFlingBehavior) { + leftoverVelocity = scrollScope.performFling(velocity) + } + } + return leftoverVelocity + } + override fun startDragImmediately(): Boolean = startDragImmediately fun update( @@ -147,11 +218,15 @@ private class AnchoredHorizontalDraggableNode( canDrag: (Offset) -> Boolean, enabled: Boolean, startDragImmediately: Boolean, + flingBehavior: FlingBehavior?, ) { + this.flingBehavior = flingBehavior + var resetPointerInputHandling = false if (this.state != state) { this.state = state + updateFlingBehavior(flingBehavior) resetPointerInputHandling = true } @@ -170,7 +245,118 @@ private class AnchoredHorizontalDraggableNode( internal fun AnchoredDraggableState.newOffsetForDelta(delta: Float) = ((if (offset.isNaN()) 0f else offset) + delta) - .coerceIn(anchors.minAnchor(), anchors.maxAnchor()) + .coerceIn(anchors.minPosition(), anchors.maxPosition()) + +/** + * Compute the target anchor based on the [currentOffset], [velocity] and [positionalThreshold] and + * [velocityThreshold]. + * + * @return The suggested target anchor + */ +@ExperimentalFoundationApi +private fun DraggableAnchors.computeTarget( + currentOffset: Float, + currentValue: T, + velocity: Float, + positionalThreshold: (totalDistance: Float) -> Float, + velocityThreshold: () -> Float, +): T { + val currentAnchors = this + val currentAnchorPosition = currentAnchors.positionOf(currentValue) + val velocityThresholdPx = velocityThreshold() + return if (currentAnchorPosition == currentOffset || currentAnchorPosition.isNaN()) { + currentValue + } else { + if (abs(velocity) >= abs(velocityThresholdPx)) { + currentAnchors.closestAnchor(currentOffset, sign(velocity) > 0)!! + } else { + val neighborAnchor = + currentAnchors.closestAnchor( + currentOffset, + currentOffset - currentAnchorPosition > 0, + )!! + val neighborAnchorPosition = currentAnchors.positionOf(neighborAnchor) + val distance = abs(currentAnchorPosition - neighborAnchorPosition) + val relativeThreshold = abs(positionalThreshold(distance)) + val relativePosition = abs(currentAnchorPosition - currentOffset) + if (relativePosition <= relativeThreshold) currentValue else neighborAnchor + } + } +} + +/** + * Construct a [FlingBehavior] for use with [Modifier.anchoredDraggable]. + * + * @param state The [AnchoredDraggableState] that will be used for the fling animation + * @param positionalThreshold A positional threshold that needs to be crossed in order to reach the + * next anchor when flinging, in pixels. This can be a derived from the distance that the lambda + * is invoked with. + * @param snapAnimationSpec The animation spec that will be used to snap to a new state. + */ +@ExperimentalFoundationApi +internal fun anchoredDraggableFlingBehavior( + state: AnchoredDraggableState, + density: Density, + positionalThreshold: (totalDistance: Float) -> Float, + snapAnimationSpec: AnimationSpec, +): TargetedFlingBehavior = + snapFlingBehavior( + decayAnimationSpec = NoOpDecayAnimationSpec, + snapAnimationSpec = snapAnimationSpec, + snapLayoutInfoProvider = + AnchoredDraggableLayoutInfoProvider( + state = state, + positionalThreshold = positionalThreshold, + velocityThreshold = { with(density) { 125.dp.toPx() } }, + ), + ) + +@Suppress("FunctionName") +@OptIn(ExperimentalFoundationApi::class) +private fun AnchoredDraggableLayoutInfoProvider( + state: AnchoredDraggableState, + positionalThreshold: (totalDistance: Float) -> Float, + velocityThreshold: () -> Float, +): SnapLayoutInfoProvider = + object : SnapLayoutInfoProvider { + + // We never decay in AnchoredDraggable's fling + override fun calculateApproachOffset(velocity: Float, decayOffset: Float) = 0f + + override fun calculateSnapOffset(velocity: Float): Float { + val currentOffset = state.requireOffset() + val target = + state.anchors.computeTarget( + currentOffset = currentOffset, + currentValue = state.currentValue, + velocity = velocity, + positionalThreshold = positionalThreshold, + velocityThreshold = velocityThreshold, + ) + return state.anchors.positionOf(target) - currentOffset + } + } + +private val NoOpDecayAnimationSpec: DecayAnimationSpec = + object : FloatDecayAnimationSpec { + override val absVelocityThreshold = 0f + + override fun getValueFromNanos( + playTimeNanos: Long, + initialValue: Float, + initialVelocity: Float, + ) = 0f + + override fun getDurationNanos(initialValue: Float, initialVelocity: Float) = 0L + + override fun getVelocityFromNanos( + playTimeNanos: Long, + initialValue: Float, + initialVelocity: Float, + ) = 0f + + override fun getTargetValue(initialValue: Float, initialVelocity: Float) = 0f + }.generateDecayAnimationSpec() /** * This Modifier allows configuring an [AnchoredDraggableState]'s anchors based on this layout diff --git a/app/src/main/kotlin/androidx/compose/material3/SideDrawer.kt b/app/src/main/kotlin/androidx/compose/material3/SideDrawer.kt index 118ceafb7..600f5a5f2 100644 --- a/app/src/main/kotlin/androidx/compose/material3/SideDrawer.kt +++ b/app/src/main/kotlin/androidx/compose/material3/SideDrawer.kt @@ -5,9 +5,9 @@ package androidx.compose.material3 import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.TweenSpec import androidx.compose.animation.core.animate -import androidx.compose.animation.core.exponentialDecay import androidx.compose.foundation.Canvas import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.AnchoredDraggableDefaults import androidx.compose.foundation.gestures.AnchoredDraggableState import androidx.compose.foundation.gestures.DraggableAnchors import androidx.compose.foundation.gestures.anchoredHorizontalDraggable @@ -53,8 +53,6 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch private val AnimationSpec = TweenSpec(durationMillis = 256) -private const val DrawerPositionalThreshold = 0.5f -private val DrawerVelocityThreshold = 400.dp @Suppress("NotCloseable") @Stable @@ -65,11 +63,7 @@ class DrawerState2( val anchoredDraggableState = AnchoredDraggableState( initialValue = initialValue, - snapAnimationSpec = AnimationSpec, - decayAnimationSpec = exponentialDecay(7.5f), confirmValueChange = confirmStateChange, - positionalThreshold = { distance -> distance * DrawerPositionalThreshold }, - velocityThreshold = { with(requireDensity()) { DrawerVelocityThreshold.toPx() } }, ) /** @@ -264,6 +258,10 @@ fun ModalSideDrawer( enableDragFromStartToEnd = drawerState.isOpen, enableDragFromEndToStart = drawerState.isClosed, enabled = gesturesEnabled, + flingBehavior = AnchoredDraggableDefaults.flingBehavior( + state = drawerState.anchoredDraggableState, + animationSpec = AnimationSpec, + ), ), ) { val radius by remember { diff --git a/app/src/main/kotlin/androidx/compose/material3/fork/SwipeToDismissBox.fork.kt b/app/src/main/kotlin/androidx/compose/material3/fork/SwipeToDismissBox.fork.kt index 1d59aa314..e39c04e7a 100644 --- a/app/src/main/kotlin/androidx/compose/material3/fork/SwipeToDismissBox.fork.kt +++ b/app/src/main/kotlin/androidx/compose/material3/fork/SwipeToDismissBox.fork.kt @@ -18,7 +18,7 @@ package androidx.compose.material3.fork import androidx.annotation.FloatRange import androidx.compose.animation.core.SpringSpec -import androidx.compose.animation.core.exponentialDecay +import androidx.compose.foundation.gestures.AnchoredDraggableDefaults import androidx.compose.foundation.gestures.AnchoredDraggableState import androidx.compose.foundation.gestures.DraggableAnchors import androidx.compose.foundation.gestures.Orientation @@ -38,21 +38,16 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.dp import kotlinx.coroutines.CancellationException private val SnapAnimationSpec = SpringSpec() -// TODO: Update to M3 spec once it's available -private val DecayAnimationSpec = exponentialDecay() - /** * State of the [SwipeToDismissBox] composable. * * @param initialValue The initial value of the state. * @param density The density that this state can use to convert values to and from dp. * @param confirmValueChange Optional callback invoked to confirm or veto a pending state change. - * @param positionalThreshold The positional threshold to be used when calculating the target state * while a swipe is in progress and when settling after the swipe ends. This is the distance from * the start of a transition. It will be, depending on the direction of the interaction, added or * subtracted from/to the origin offset. It should always be a positive value. @@ -61,16 +56,11 @@ class SwipeToDismissBoxState( initialValue: SwipeToDismissBoxValue, internal val density: Density, confirmValueChange: (SwipeToDismissBoxValue) -> Boolean = { true }, - positionalThreshold: (totalDistance: Float) -> Float, ) { internal val anchoredDraggableState = AnchoredDraggableState( initialValue = initialValue, - snapAnimationSpec = SnapAnimationSpec, - decayAnimationSpec = DecayAnimationSpec, confirmValueChange = confirmValueChange, - positionalThreshold = positionalThreshold, - velocityThreshold = { with(density) { DismissVelocityThreshold.toPx() } }, ) internal val offset: Float get() = anchoredDraggableState.offset @@ -150,7 +140,6 @@ class SwipeToDismissBoxState( */ fun Saver( confirmValueChange: (SwipeToDismissBoxValue) -> Boolean, - positionalThreshold: (totalDistance: Float) -> Float, density: Density, ) = Saver( save = { it.currentValue }, @@ -159,7 +148,6 @@ class SwipeToDismissBoxState( it, density, confirmValueChange, - positionalThreshold, ) }, ) @@ -171,7 +159,6 @@ class SwipeToDismissBoxState( * * @param initialValue The initial value of the state. * @param confirmValueChange Optional callback invoked to confirm or veto a pending state change. - * @param positionalThreshold The positional threshold to be used when calculating the target state * while a swipe is in progress and when settling after the swipe ends. This is the distance from * the start of a transition. It will be, depending on the direction of the interaction, added or * subtracted from/to the origin offset. It should always be a positive value. @@ -180,18 +167,15 @@ class SwipeToDismissBoxState( fun rememberSwipeToDismissBoxState( initialValue: SwipeToDismissBoxValue = SwipeToDismissBoxValue.Settled, confirmValueChange: (SwipeToDismissBoxValue) -> Boolean = { true }, - positionalThreshold: (totalDistance: Float) -> Float = - SwipeToDismissBoxDefaults.positionalThreshold, ): SwipeToDismissBoxState { val density = LocalDensity.current return rememberSaveable( saver = SwipeToDismissBoxState.Saver( confirmValueChange = confirmValueChange, density = density, - positionalThreshold = positionalThreshold, ), ) { - SwipeToDismissBoxState(initialValue, density, confirmValueChange, positionalThreshold) + SwipeToDismissBoxState(initialValue, density, confirmValueChange) } } @@ -224,6 +208,11 @@ fun SwipeToDismissBox( enableDragFromStartToEnd = enableDismissFromStartToEnd, enableDragFromEndToStart = enableDismissFromEndToStart, enabled = gesturesEnabled && state.currentValue == SwipeToDismissBoxValue.Settled, + flingBehavior = AnchoredDraggableDefaults.flingBehavior( + state = state.anchoredDraggableState, + positionalThreshold = SwipeToDismissBoxDefaults.positionalThreshold, + animationSpec = SnapAnimationSpec, + ), ), propagateMinConstraints = true, ) { @@ -251,5 +240,3 @@ fun SwipeToDismissBox( ) } } - -private val DismissVelocityThreshold = 125.dp 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 62f995bac..c2f015d96 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 @@ -48,7 +48,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SwipeToDismissBoxDefaults import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -152,7 +151,6 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) { val canTranslate = Settings.showTagTranslations && EhTagDatabase.isTranslatable(implicit()) && EhTagDatabase.initialized val ehTags = EhTagDatabase.takeIf { canTranslate } fun String.translateArtist() = ehTags?.getTranslation(TagNamespace.Artist.toPrefix(), this) ?: this - val positionalThreshold = SwipeToDismissBoxDefaults.positionalThreshold val allName = stringResource(R.string.download_all) val defaultName = stringResource(R.string.default_download_label_name) val unknownName = stringResource(R.string.unknown_artists) @@ -333,7 +331,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) { val index by rememberUpdatedState(itemIndex) val item by rememberUpdatedState(label) // Not using rememberSwipeToDismissBoxState to prevent LazyColumn from reusing it - val dismissState = remember { SwipeToDismissBoxState(SwipeToDismissBoxValue.Settled, density, positionalThreshold = positionalThreshold) } + val dismissState = remember { SwipeToDismissBoxState(SwipeToDismissBoxValue.Settled, density) } LaunchedEffect(dismissState) { snapshotFlow { dismissState.currentValue }.collect { if (it == SwipeToDismissBoxValue.EndToStart) { @@ -353,7 +351,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) { } } } - ReorderableItem(reorderableLabelState, enabled = editEnable, key = id) { isDragging -> + ReorderableItem(reorderableLabelState, id, enabled = editEnable, animated = animateItems) { 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 66b891222..7b0319be1 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 @@ -39,12 +39,10 @@ import androidx.compose.material.icons.filled.Shuffle import androidx.compose.material.icons.outlined.Bookmarks import androidx.compose.material3.ElevatedCard import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SwipeToDismissBoxDefaults import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -177,6 +175,7 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c var searchBarExpanded by rememberSaveable { mutableStateOf(false) } var searchBarOffsetY by remember { mutableIntStateOf(0) } var showSearchLayout by rememberSaveable { mutableStateOf(false) } + val animateItems by Settings.animateItems.collectAsState() var category by rememberMutableStateInDataStore("SearchCategory") { EhUtils.ALL_CATEGORY } var advancedSearchOption by rememberMutableStateInDataStore("AdvancedSearchOption") { AdvancedSearchOption() } @@ -203,7 +202,6 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c onBack = { showSearchLayout = false }, ) val density = LocalDensity.current - val positionalThreshold = SwipeToDismissBoxDefaults.positionalThreshold val listState = rememberLazyGridState() val gridState = rememberLazyStaggeredGridState() val isTopList = remember(urlBuilder) { urlBuilder.mode == MODE_TOPLIST } @@ -366,9 +364,7 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c val quickSearchListState = rememberLazyListState() val hapticFeedback = rememberHapticFeedback() val reorderableLazyListState = rememberReorderableLazyListState(quickSearchListState) { from, to -> - val fromIndex = from.index - 1 - val toIndex = to.index - 1 - quickSearchList.apply { add(toIndex, removeAt(fromIndex)) } + quickSearchList.apply { add(to.index, removeAt(from.index)) } hapticFeedback.performHapticFeedback(HapticFeedbackType.MOVE) } var fromIndex by remember { mutableIntStateOf(-1) } @@ -377,15 +373,11 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c state = quickSearchListState, contentPadding = WindowInsets.systemBars.only(WindowInsetsSides.Bottom).asPaddingValues(), ) { - // Fix the first item's reorder animation - stickyHeader { - HorizontalDivider() - } itemsIndexed(quickSearchList, key = { _, item -> item.id!! }) { itemIndex, item -> val index by rememberUpdatedState(itemIndex) - ReorderableItem(reorderableLazyListState, key = item.id!!) { isDragging -> + ReorderableItem(reorderableLazyListState, item.id!!, animated = animateItems) { isDragging -> // Not using rememberSwipeToDismissBoxState to prevent LazyColumn from reusing it - val dismissState = remember { SwipeToDismissBoxState(SwipeToDismissBoxValue.Settled, density, positionalThreshold = positionalThreshold) } + val dismissState = remember { SwipeToDismissBoxState(SwipeToDismissBoxValue.Settled, density) } LaunchedEffect(dismissState) { snapshotFlow { dismissState.currentValue }.collect { value -> if (value == SwipeToDismissBoxValue.EndToStart) { diff --git a/app/src/main/kotlin/sh/calvin/reorderable/ReorderableLazyCollection.kt b/app/src/main/kotlin/sh/calvin/reorderable/ReorderableLazyCollection.kt new file mode 100644 index 000000000..1486a4763 --- /dev/null +++ b/app/src/main/kotlin/sh/calvin/reorderable/ReorderableLazyCollection.kt @@ -0,0 +1,809 @@ +/* + * 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 new file mode 100644 index 000000000..258b81298 --- /dev/null +++ b/app/src/main/kotlin/sh/calvin/reorderable/ReorderableLazyList.kt @@ -0,0 +1,259 @@ +/* + * 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 new file mode 100644 index 000000000..95c0e7284 --- /dev/null +++ b/app/src/main/kotlin/sh/calvin/reorderable/Scroller.kt @@ -0,0 +1,183 @@ +/* + * 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 new file mode 100644 index 000000000..5e9e78976 --- /dev/null +++ b/app/src/main/kotlin/sh/calvin/reorderable/draggable.kt @@ -0,0 +1,170 @@ +@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 new file mode 100644 index 000000000..29638673a --- /dev/null +++ b/app/src/main/kotlin/sh/calvin/reorderable/util.kt @@ -0,0 +1,48 @@ +@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 6835871b2..d5a5f8f76 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -70,9 +70,9 @@ coil-gif = { module = "io.coil-kt.coil3:coil-gif" } coil-network = { module = "io.coil-kt.coil3:coil-network-ktor" } compose-bom = { module = "dev.chrisbanes.compose:compose-bom", version.ref = "compose-bom" } -compose-foundation = { module = "androidx.compose.foundation:foundation", version = "1.7.0-SNAPSHOT" } +compose-foundation = { module = "androidx.compose.foundation:foundation", version = "1.8.0-SNAPSHOT" } compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } -compose-material3 = { module = "androidx.compose.material3:material3", version = "1.3.0-SNAPSHOT" } +compose-material3 = { module = "androidx.compose.material3:material3", version = "1.4.0-SNAPSHOT" } compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } diff --git a/settings.gradle.kts b/settings.gradle.kts index f1935ccd4..2635ae6c3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -11,8 +11,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() - // Hold until https://issuetracker.google.com/343750859 is fixed - maven("https://androidx.dev/snapshots/builds/11851351/artifacts/repository") + maven("https://androidx.dev/snapshots/builds/11994105/artifacts/repository") } }