0.6.0
This commit is contained in:
1
compose/reordering/.gitignore
vendored
Normal file
1
compose/reordering/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
44
compose/reordering/build.gradle.kts
Normal file
44
compose/reordering/build.gradle.kts
Normal file
@@ -0,0 +1,44 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "it.hamy.compose.reordering"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 21
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
freeCompilerArgs = freeCompilerArgs + listOf("-Xcontext-receivers")
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(libs.versions.jvm.get().toInt())
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.compose.foundation)
|
||||
|
||||
detektPlugins(libs.detekt.compose)
|
||||
detektPlugins(libs.detekt.formatting)
|
||||
}
|
||||
2
compose/reordering/src/main/AndroidManifest.xml
Normal file
2
compose/reordering/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest />
|
||||
@@ -0,0 +1,34 @@
|
||||
package it.hamy.compose.reordering
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.AnimationVector
|
||||
import androidx.compose.animation.core.TwoWayConverter
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
class AnimatablesPool<T, V : AnimationVector>(
|
||||
private val size: Int,
|
||||
private val initialValue: T,
|
||||
typeConverter: TwoWayConverter<T, V>
|
||||
) {
|
||||
private val values = MutableList(size) {
|
||||
Animatable(initialValue = initialValue, typeConverter = typeConverter)
|
||||
}
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
init {
|
||||
require(size > 0)
|
||||
}
|
||||
|
||||
suspend fun acquire() = mutex.withLock {
|
||||
if (values.isNotEmpty()) values.removeFirst() else null
|
||||
}
|
||||
|
||||
suspend fun release(animatable: Animatable<T, V>) = mutex.withLock {
|
||||
if (values.size < size) {
|
||||
animatable.snapTo(initialValue)
|
||||
values.add(animatable)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package it.hamy.compose.reordering
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.lazy.LazyItemScope
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
context(LazyItemScope)
|
||||
@ExperimentalFoundationApi
|
||||
fun Modifier.animateItemPlacement(reorderingState: ReorderingState) =
|
||||
if (reorderingState.draggingIndex == -1) animateItemPlacement() else this
|
||||
@@ -0,0 +1,58 @@
|
||||
package it.hamy.compose.reordering
|
||||
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.layout.LocalPinnableContainer
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
|
||||
fun Modifier.draggedItem(
|
||||
reorderingState: ReorderingState,
|
||||
index: Int,
|
||||
draggedElevation: Dp = 4.dp
|
||||
): Modifier = when (reorderingState.draggingIndex) {
|
||||
-1 -> this
|
||||
index -> offset {
|
||||
when (reorderingState.lazyListState.layoutInfo.orientation) {
|
||||
Orientation.Vertical -> IntOffset(0, reorderingState.offset.value)
|
||||
Orientation.Horizontal -> IntOffset(reorderingState.offset.value, 0)
|
||||
}
|
||||
}.zIndex(1f)
|
||||
|
||||
else -> offset {
|
||||
val offset = when (index) {
|
||||
in reorderingState.indexesToAnimate -> reorderingState.indexesToAnimate.getValue(index).value
|
||||
in (reorderingState.draggingIndex + 1)..reorderingState.reachedIndex -> -reorderingState.draggingItemSize
|
||||
in reorderingState.reachedIndex..<reorderingState.draggingIndex -> reorderingState.draggingItemSize
|
||||
else -> 0
|
||||
}
|
||||
when (reorderingState.lazyListState.layoutInfo.orientation) {
|
||||
Orientation.Vertical -> IntOffset(0, offset)
|
||||
Orientation.Horizontal -> IntOffset(offset, 0)
|
||||
}
|
||||
}
|
||||
}.composed {
|
||||
val container = LocalPinnableContainer.current
|
||||
val elevation by animateDpAsState(
|
||||
targetValue = if (reorderingState.draggingIndex == index) draggedElevation else 0.dp,
|
||||
label = ""
|
||||
)
|
||||
|
||||
DisposableEffect(reorderingState.draggingIndex) {
|
||||
val handle = if (reorderingState.draggingIndex == index) container?.pin() else null
|
||||
|
||||
onDispose {
|
||||
handle?.release()
|
||||
}
|
||||
}
|
||||
|
||||
this.shadow(elevation = elevation)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package it.hamy.compose.reordering
|
||||
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
|
||||
private fun Modifier.reorder(
|
||||
reorderingState: ReorderingState,
|
||||
index: Int,
|
||||
detectDragGestures: DetectDragGestures
|
||||
) = this.pointerInput(reorderingState) {
|
||||
with(detectDragGestures) {
|
||||
detectDragGestures(
|
||||
onDragStart = { reorderingState.onDragStart(index) },
|
||||
onDrag = reorderingState::onDrag,
|
||||
onDragEnd = reorderingState::onDragEnd,
|
||||
onDragCancel = reorderingState::onDragEnd
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.reorder(
|
||||
reorderingState: ReorderingState,
|
||||
index: Int
|
||||
) = this.reorder(
|
||||
reorderingState = reorderingState,
|
||||
index = index,
|
||||
detectDragGestures = PointerInputScope::detectDragGestures
|
||||
)
|
||||
|
||||
private fun interface DetectDragGestures {
|
||||
suspend fun PointerInputScope.detectDragGestures(
|
||||
onDragStart: (Offset) -> Unit,
|
||||
onDragEnd: () -> Unit,
|
||||
onDragCancel: () -> Unit,
|
||||
onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package it.hamy.compose.reordering
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.AnimationVector1D
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.lazy.LazyListItemInfo
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Stable
|
||||
class ReorderingState(
|
||||
val lazyListState: LazyListState,
|
||||
val coroutineScope: CoroutineScope,
|
||||
private val lastIndex: Int,
|
||||
internal val onDragStart: () -> Unit,
|
||||
internal val onDragEnd: (Int, Int) -> Unit,
|
||||
private val extraItemCount: Int
|
||||
) {
|
||||
internal val offset = Animatable(0, Int.VectorConverter)
|
||||
|
||||
internal var draggingIndex by mutableIntStateOf(-1)
|
||||
internal var reachedIndex by mutableIntStateOf(-1)
|
||||
internal var draggingItemSize by mutableIntStateOf(0)
|
||||
|
||||
private lateinit var itemInfo: LazyListItemInfo
|
||||
|
||||
private var previousItemSize = 0
|
||||
private var nextItemSize = 0
|
||||
|
||||
private var overscrolled = 0
|
||||
|
||||
internal var indexesToAnimate = mutableStateMapOf<Int, Animatable<Int, AnimationVector1D>>()
|
||||
private var animatablesPool: AnimatablesPool<Int, AnimationVector1D>? = null
|
||||
|
||||
val isDragging: Boolean
|
||||
get() = draggingIndex != -1
|
||||
|
||||
fun onDragStart(index: Int) {
|
||||
overscrolled = 0
|
||||
itemInfo = lazyListState.layoutInfo.visibleItemsInfo
|
||||
.find { it.index == index + extraItemCount } ?: return
|
||||
|
||||
onDragStart()
|
||||
draggingIndex = index
|
||||
reachedIndex = index
|
||||
draggingItemSize = itemInfo.size
|
||||
|
||||
nextItemSize = draggingItemSize
|
||||
previousItemSize = -draggingItemSize
|
||||
|
||||
offset.updateBounds(
|
||||
lowerBound = -index * draggingItemSize,
|
||||
upperBound = (lastIndex - index) * draggingItemSize
|
||||
)
|
||||
|
||||
animatablesPool = AnimatablesPool(
|
||||
size = (lazyListState.layoutInfo.viewportEndOffset - lazyListState.layoutInfo.viewportStartOffset) /
|
||||
(draggingItemSize + 2),
|
||||
initialValue = 0,
|
||||
typeConverter = Int.VectorConverter
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
fun onDrag(change: PointerInputChange, dragAmount: Offset) {
|
||||
if (!isDragging) return
|
||||
|
||||
change.consume()
|
||||
|
||||
val delta = when (lazyListState.layoutInfo.orientation) {
|
||||
Orientation.Vertical -> dragAmount.y
|
||||
Orientation.Horizontal -> dragAmount.x
|
||||
}.roundToInt()
|
||||
|
||||
val targetOffset = offset.value + delta
|
||||
|
||||
coroutineScope.launch { offset.snapTo(targetOffset) }
|
||||
|
||||
when {
|
||||
targetOffset > nextItemSize -> {
|
||||
if (reachedIndex < lastIndex) {
|
||||
reachedIndex += 1
|
||||
nextItemSize += draggingItemSize
|
||||
previousItemSize += draggingItemSize
|
||||
|
||||
val indexToAnimate = reachedIndex - if (draggingIndex < reachedIndex) 0 else 1
|
||||
|
||||
coroutineScope.launch {
|
||||
val animatable = indexesToAnimate.getOrPut(indexToAnimate) {
|
||||
animatablesPool?.acquire() ?: return@launch
|
||||
}
|
||||
|
||||
if (draggingIndex < reachedIndex) {
|
||||
animatable.snapTo(0)
|
||||
animatable.animateTo(-draggingItemSize)
|
||||
} else {
|
||||
animatable.snapTo(draggingItemSize)
|
||||
animatable.animateTo(0)
|
||||
}
|
||||
|
||||
indexesToAnimate.remove(indexToAnimate)
|
||||
animatablesPool?.release(animatable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
targetOffset < previousItemSize -> {
|
||||
if (reachedIndex > 0) {
|
||||
reachedIndex -= 1
|
||||
previousItemSize -= draggingItemSize
|
||||
nextItemSize -= draggingItemSize
|
||||
|
||||
val indexToAnimate = reachedIndex + if (draggingIndex > reachedIndex) 0 else 1
|
||||
|
||||
coroutineScope.launch {
|
||||
val animatable = indexesToAnimate.getOrPut(indexToAnimate) {
|
||||
animatablesPool?.acquire() ?: return@launch
|
||||
}
|
||||
|
||||
if (draggingIndex > reachedIndex) {
|
||||
animatable.snapTo(0)
|
||||
animatable.animateTo(draggingItemSize)
|
||||
} else {
|
||||
animatable.snapTo(-draggingItemSize)
|
||||
animatable.animateTo(0)
|
||||
}
|
||||
indexesToAnimate.remove(indexToAnimate)
|
||||
animatablesPool?.release(animatable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
val offsetInViewPort = targetOffset + itemInfo.offset - overscrolled
|
||||
|
||||
val topOverscroll = lazyListState.layoutInfo.viewportStartOffset +
|
||||
lazyListState.layoutInfo.beforeContentPadding - offsetInViewPort
|
||||
val bottomOverscroll = lazyListState.layoutInfo.viewportEndOffset -
|
||||
lazyListState.layoutInfo.afterContentPadding - offsetInViewPort - itemInfo.size
|
||||
|
||||
if (topOverscroll > 0) overscroll(topOverscroll) else if (bottomOverscroll < 0)
|
||||
overscroll(bottomOverscroll)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onDragEnd() {
|
||||
if (!isDragging) return
|
||||
|
||||
coroutineScope.launch {
|
||||
offset.animateTo((previousItemSize + nextItemSize) / 2)
|
||||
|
||||
withContext(Dispatchers.Main) { onDragEnd(draggingIndex, reachedIndex) }
|
||||
|
||||
if (areEquals()) {
|
||||
draggingIndex = -1
|
||||
reachedIndex = -1
|
||||
draggingItemSize = 0
|
||||
offset.snapTo(0)
|
||||
}
|
||||
|
||||
animatablesPool = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun overscroll(overscroll: Int) {
|
||||
val newHeight = itemInfo.offset - overscroll
|
||||
@Suppress("ComplexCondition")
|
||||
if (
|
||||
!(overscroll > 0 && newHeight <= lazyListState.layoutInfo.viewportEndOffset) &&
|
||||
!(overscroll < 0 && newHeight >= lazyListState.layoutInfo.viewportStartOffset)
|
||||
) return
|
||||
|
||||
coroutineScope.launch {
|
||||
lazyListState.scrollBy(-overscroll.toFloat())
|
||||
offset.snapTo(offset.value - overscroll)
|
||||
}
|
||||
overscrolled -= overscroll
|
||||
}
|
||||
|
||||
private fun areEquals() = lazyListState.layoutInfo.visibleItemsInfo.find {
|
||||
it.index + extraItemCount == draggingIndex
|
||||
}?.key == lazyListState.layoutInfo.visibleItemsInfo.find {
|
||||
it.index + extraItemCount == reachedIndex
|
||||
}?.key
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberReorderingState(
|
||||
lazyListState: LazyListState,
|
||||
key: Any,
|
||||
onDragEnd: (Int, Int) -> Unit,
|
||||
onDragStart: () -> Unit = {},
|
||||
extraItemCount: Int = 0
|
||||
): ReorderingState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
return remember(key) {
|
||||
ReorderingState(
|
||||
lazyListState = lazyListState,
|
||||
coroutineScope = coroutineScope,
|
||||
lastIndex = if (key is List<*>) key.lastIndex else lazyListState.layoutInfo.totalItemsCount,
|
||||
onDragStart = onDragStart,
|
||||
onDragEnd = onDragEnd,
|
||||
extraItemCount = extraItemCount
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user