This commit is contained in:
2024-02-27 22:09:30 +03:00
parent bfa3231823
commit 38a3141d43
479 changed files with 36348 additions and 10142 deletions

1
compose/reordering/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View 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)
}

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />

View File

@@ -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)
}
}
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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
)
}

View File

@@ -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
)
}
}