Update Compose snapshot and vendor Reorderable

As `animateItemPlacement` has been removed.
This commit is contained in:
FooIbar committed 2024-06-22 12:57:13 +08:00
1 parent 1d50855f52
commit 5c4f034532
13 files changed
+1684 -55

No files matched your search

+1 -1
View File
@@ -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)
@@ -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 <T> 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 <T> Modifier.anchoredHorizontalDraggable(
},
enabled = enabled,
startDragImmediately = startDragImmediately,
flingBehavior = flingBehavior,
)
@OptIn(ExperimentalFoundationApi::class)
private class AnchoredHorizontalDraggableElement<T>(
private val state: AnchoredDraggableState<T>,
private val canDrag: (Offset) -> Boolean,
private val enabled: Boolean,
private val startDragImmediately: Boolean,
private val flingBehavior: FlingBehavior?,
) : ModifierNodeElement<AnchoredHorizontalDraggableNode<T>>() {
override fun create() = AnchoredHorizontalDraggableNode(
state,
canDrag,
enabled,
startDragImmediately,
flingBehavior,
)
override fun update(node: AnchoredHorizontalDraggableNode<T>) {
@@ -75,6 +92,7 @@ private class AnchoredHorizontalDraggableElement<T>(
canDrag,
enabled,
startDragImmediately,
flingBehavior,
)
}
@@ -83,6 +101,7 @@ private class AnchoredHorizontalDraggableElement<T>(
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<T>(
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<T>(
properties["canDrag"] = canDrag
properties["enabled"] = enabled
properties["startDragImmediately"] = startDragImmediately
properties["flingBehavior"] = flingBehavior
}
}
@OptIn(ExperimentalFoundationApi::class)
private class AnchoredHorizontalDraggableNode<T>(
private var state: AnchoredDraggableState<T>,
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<T>(
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<T>(
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<T>(
internal fun <T> AnchoredDraggableState<T>.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 <T> DraggableAnchors<T>.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 <T> anchoredDraggableFlingBehavior(
state: AnchoredDraggableState<T>,
density: Density,
positionalThreshold: (totalDistance: Float) -> Float,
snapAnimationSpec: AnimationSpec<Float>,
): 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 <T> AnchoredDraggableLayoutInfoProvider(
state: AnchoredDraggableState<T>,
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<Float> =
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
@@ -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<Float>(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 {
@@ -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<Float>()
// TODO: Update to M3 spec once it's available
private val DecayAnimationSpec = exponentialDecay<Float>()
/**
* 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<SwipeToDismissBoxState, SwipeToDismissBoxValue>(
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
@@ -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<Context>()) && 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 = {},
@@ -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) {
@@ -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<out T> {
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<out T> {
val visibleItemsInfo: List<LazyCollectionItemInfo<T>>
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<LazyCollectionItemInfo<T>> {
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<out T> {
val firstVisibleItemIndex: Int
val firstVisibleItemScrollOffset: Int
val layoutInfo: LazyCollectionLayoutInfo<T>
suspend fun animateScrollBy(
value: Float,
animationSpec: AnimationSpec<Float> = 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<out T> internal constructor(
private val state: LazyCollectionState<T>,
private val scope: CoroutineScope,
private val onMoveState: State<suspend CoroutineScope.(from: T, to: T) -> 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<Any?>(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<Int?>(null)
private var predictedDraggingItemOffset by mutableStateOf<IntOffset?>(null)
private val draggingItemLayoutInfo: LazyCollectionItemInfo<T>?
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<Any?>()
internal var previousDraggingItemKey by mutableStateOf<Any?>(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<T> ->
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<LazyCollectionItemInfo<T>> = state.layoutInfo.getItemsInContentArea(),
direction: Scroller.Direction = Scroller.Direction.FORWARD,
additionalPredicate: (LazyCollectionItemInfo<T>) -> Boolean = { true },
): LazyCollectionItemInfo<T>? {
val targetItemFunc = { item: LazyCollectionItemInfo<T> ->
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<T>,
targetItem: LazyCollectionItemInfo<T>,
) {
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<Boolean> = 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)
}
}
}
@@ -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.bottom
}
Orientation.Horizontal -> { draggingItem, item ->
item.center.x in draggingItem.left..<draggingItem.right
}
},
)
}
return state
}
private val LazyListLayoutInfo.mainAxisViewportSize: Int
get() = when (orientation) {
Orientation.Vertical -> viewportSize.height
Orientation.Horizontal -> viewportSize.width
}
private fun LazyListItemInfo.toLazyCollectionItemInfo(orientation: Orientation) =
object : LazyCollectionItemInfo<LazyListItemInfo> {
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<LazyListItemInfo> {
override val visibleItemsInfo: List<LazyCollectionItemInfo<LazyListItemInfo>>
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<LazyListItemInfo> {
override val firstVisibleItemIndex: Int
get() = this@toLazyCollectionState.firstVisibleItemIndex
override val firstVisibleItemScrollOffset: Int
get() = this@toLazyCollectionState.firstVisibleItemScrollOffset
override val layoutInfo: LazyCollectionLayoutInfo<LazyListItemInfo>
get() = this@toLazyCollectionState.layoutInfo.toLazyCollectionLayoutInfo()
override suspend fun animateScrollBy(value: Float, animationSpec: AnimationSpec<Float>) =
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<suspend CoroutineScope.(from: LazyListItemInfo, to: LazyListItemInfo) -> 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<LazyListItemInfo>(
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,
)
}
@@ -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<ScrollInfo>(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()
}
}
}
@@ -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<DragInteraction.Start?>(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<DragInteraction.Start?>(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,
)
}
}
}
@@ -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)
+2 -2
View File
@@ -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" }
+1 -2
View File
@@ -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")
}
}