diff --git a/README.md b/README.md index c7cd07f..ccd011a 100644 --- a/README.md +++ b/README.md @@ -15,86 +15,37 @@ dependencies { ## How to use -Create `reorderState` and add the `reorderable` Modifier to the LazyList/Grid: - -``` -// For a LazyGrid just use `rememberReorderLazyListState` -val state: ReorderableLazyListState = rememberReorderLazyListState(onMove = { from, to -> data.move(from.index, to.index) }) - -LazyColumn( - state = state.listState, - modifier = Modifier.reorderable(state)) { -... -} -``` - -For a LazyGrid just use `rememberReorderLazyListState` -To make an item reorderable/draggable add at least one drag modifier to the item: - -``` - Modifier.detectReorder(state) - or - Modifier.detectReorderAfterLongPress(state) -``` - -> Adding one of the detect modifiers to the LazyList instead of an item , will make all items reordable. - -At least apply the dragged item offset: - -``` -items(items, { it.key }) {item -> - Column( - modifier = Modifier.draggedItem(state.offsetByKey(item.key)) - ) { - ... - } -} - -or without keyed items: - -itemsIndexed(items) { idx, item -> - Column( - modifier = Modifier.draggedItem(state.offsetByIndex(idx)) - ) { - ... - } -} -``` - -> You can use `draggedItem` for a default dragged effect or create your own. - -Complete example: ``` @Composable -fun ReorderableList(){ - val data = List(100) { "item $it" }.toMutableStateList() - val state: ReorderableLazyListState = rememberReorderLazyListState(onMove = { from, to -> data.move(from.index, to.index) }) - +fun VerticalReorderList() { + val data = remember { mutableStateOf(List(100) { "Item $it" }) } + val state = rememberReorderableLazyListState(onMove = { from, to -> + data.value = data.value.toMutableList().apply { + add(to.index, removeAt(from.index)) + } + }) LazyColumn( state = state.listState, modifier = Modifier.reorderable(state) ) { - items(data, { it }) { item -> - Box( - modifier = Modifier - .fillMaxWidth() - .draggedItem(state.offsetByKey(item)) - .detectReorderAfterLongPress(state) - ) { - Text(text = item) + items(data.value, { it }) { item -> + ReorderableItem(state, key = item) { isDragging -> + Column( + modifier = Modifier + .background(MaterialTheme.colors.surface) + .detectReorderAfterLongPress(state) + ) { + Text(item) + } } } } } ``` - ## Notes -When dragging, the existing item will be modified. Because of that it's important that the item must be part of the LazyList visible -items all the time. - -This can be problematic if no drop target can be found during scrolling. +It's a known issue that the first visible item does not animate. ## License diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 561804d..6481256 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -5,15 +5,15 @@ plugins { } dependencies { - implementation("org.burnoutcrew.composereorderable:reorderable:0.8.1") - implementation("androidx.compose.runtime:runtime:1.2.0-beta01") - implementation("androidx.compose.material:material:1.2.0-beta01") + implementation("org.burnoutcrew.composereorderable:reorderable:0.9.0") + implementation("androidx.compose.runtime:runtime:1.2.0-beta02") + implementation("androidx.compose.material:material:1.2.0-beta02") implementation("androidx.activity:activity-compose:1.4.0") - implementation("com.google.android.material:material:1.6.0") + implementation("com.google.android.material:material:1.6.1") implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.4.1") implementation("androidx.navigation:navigation-compose:2.5.0-rc01") - implementation("io.coil-kt:coil-compose:1.4.0") + implementation("io.coil-kt:coil-compose:2.1.0") } android { diff --git a/android/src/main/kotlin/org/burnoutcrew/android/ui/reorderlist/ImageListViewModel.kt b/android/src/main/kotlin/org/burnoutcrew/android/ui/reorderlist/ImageListViewModel.kt index a40eb13..8845b9d 100644 --- a/android/src/main/kotlin/org/burnoutcrew/android/ui/reorderlist/ImageListViewModel.kt +++ b/android/src/main/kotlin/org/burnoutcrew/android/ui/reorderlist/ImageListViewModel.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 André Claßen + * Copyright 2022 André Claßen * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,21 +15,22 @@ */ package org.burnoutcrew.android.ui.reorderlist -import androidx.compose.runtime.toMutableStateList +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import org.burnoutcrew.reorderable.ItemPosition -import org.burnoutcrew.reorderable.move - import kotlin.random.Random class ImageListViewModel : ViewModel() { - val images = List(20) { "https://picsum.photos/seed/compose$it/200/300" }.toMutableStateList() val headerImage = "https://picsum.photos/seed/compose${Random.nextInt(Int.MAX_VALUE)}/400/200" val footerImage = "https://picsum.photos/seed/compose${Random.nextInt(Int.MAX_VALUE)}/400/200" - + var images by mutableStateOf(List(20) { "https://picsum.photos/seed/compose$it/200/300" }) fun onMove(from: ItemPosition, to: ItemPosition) { - images.move(images.indexOfFirst { it == from.key }, images.indexOfFirst { it == to.key }) + images = images.toMutableList().apply { + add(images.indexOfFirst { it == to.key }, removeAt(images.indexOfFirst { it == from.key })) + } } fun canDragOver(pos: ItemPosition) = images.any { it == pos.key } diff --git a/android/src/main/kotlin/org/burnoutcrew/android/ui/reorderlist/ItemData.kt b/android/src/main/kotlin/org/burnoutcrew/android/ui/reorderlist/ItemData.kt index 5ea89e8..d9dd898 100644 --- a/android/src/main/kotlin/org/burnoutcrew/android/ui/reorderlist/ItemData.kt +++ b/android/src/main/kotlin/org/burnoutcrew/android/ui/reorderlist/ItemData.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 André Claßen + * Copyright 2022 André Claßen * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/android/src/main/kotlin/org/burnoutcrew/android/ui/reorderlist/ReorderGrid.kt b/android/src/main/kotlin/org/burnoutcrew/android/ui/reorderlist/ReorderGrid.kt index 2ac4bb8..7c0e093 100644 --- a/android/src/main/kotlin/org/burnoutcrew/android/ui/reorderlist/ReorderGrid.kt +++ b/android/src/main/kotlin/org/burnoutcrew/android/ui/reorderlist/ReorderGrid.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 André Claßen + * Copyright 2022 André Claßen * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,10 +15,12 @@ */ package org.burnoutcrew.android.ui.reorderlist - +import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -32,55 +34,52 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel -import org.burnoutcrew.reorderable.ItemPosition +import org.burnoutcrew.reorderable.ReorderableItem import org.burnoutcrew.reorderable.detectReorderAfterLongPress -import org.burnoutcrew.reorderable.draggedItem import org.burnoutcrew.reorderable.rememberReorderableLazyGridState import org.burnoutcrew.reorderable.reorderable - @Composable fun ReorderGrid(vm: ReorderListViewModel = viewModel()) { Column { HorizontalGrid( - items = vm.cats, - modifier = Modifier.padding(vertical = 16.dp), - onMove = { from, to -> vm.moveCat(from, to) }, - ) - VerticalGrid( - items = vm.dogs, - onMove = { from, to -> vm.moveDog(from, to) }, - canDragOver = { vm.isDogDragEnabled(it) }, + vm = vm, + modifier = Modifier.padding(vertical = 16.dp) ) + VerticalGrid(vm = vm) } } - @Composable private fun HorizontalGrid( + vm: ReorderListViewModel, modifier: Modifier = Modifier, - items: List, - onMove: (fromPos: ItemPosition, toPos: ItemPosition) -> (Unit), ) { - val state = rememberReorderableLazyGridState(onMove = onMove) + val state = rememberReorderableLazyGridState(onMove = vm::moveCat) LazyHorizontalGrid( rows = GridCells.Fixed(2), state = state.gridState, + contentPadding = PaddingValues(horizontal = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier.reorderable(state).height(200.dp) ) { - items(items, { it.key }) { item -> - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .aspectRatio(1f) - .padding(4.dp) - .draggedItem(offset = state.offsetByKey(item.key)) - .background(MaterialTheme.colors.secondary) - .detectReorderAfterLongPress(state) - ) { - Text(item.title) + items(vm.cats, { it.key }) { item -> + ReorderableItem(state, item.key) { isDragging -> + val elevation = animateDpAsState(if (isDragging) 16.dp else 0.dp) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .shadow(elevation.value) + .aspectRatio(1f) + .background(MaterialTheme.colors.secondary) + .detectReorderAfterLongPress(state) + ) { + Text(item.title) + } } } } @@ -88,29 +87,43 @@ private fun HorizontalGrid( @Composable private fun VerticalGrid( + vm: ReorderListViewModel, modifier: Modifier = Modifier, - items: List, - onMove: (fromPos: ItemPosition, toPos: ItemPosition) -> (Unit), - canDragOver: ((pos: ItemPosition) -> Boolean), ) { - val state = rememberReorderableLazyGridState(onMove = onMove, canDragOver = canDragOver) + val state = rememberReorderableLazyGridState(onMove = vm::moveDog, canDragOver = vm::isDogDragEnabled) LazyVerticalGrid( columns = GridCells.Fixed(4), state = state.gridState, + contentPadding = PaddingValues(horizontal = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier.reorderable(state) ) { - items(items, { it.key }) { item -> - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .size(100.dp) - .padding(4.dp) - .draggedItem(state.offsetByKey(item.key)) - .background(MaterialTheme.colors.primary) - .detectReorderAfterLongPress(state) - ) { - Text(item.title) + items(vm.dogs, { it.key }) { item -> + ReorderableItem(state, item.key) { isDragging -> + val elevation = animateDpAsState(if (isDragging) 8.dp else 0.dp) + if (item.isLocked) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(100.dp) + .background(MaterialTheme.colors.surface) + ) { + Text(item.title) + } + } else { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .shadow(elevation.value) + .aspectRatio(1f) + .background(MaterialTheme.colors.primary) + .detectReorderAfterLongPress(state) + ) { + Text(item.title) + } + } } } } -} \ No newline at end of file +} diff --git a/android/src/main/kotlin/org/burnoutcrew/android/ui/reorderlist/ReorderImageList.kt b/android/src/main/kotlin/org/burnoutcrew/android/ui/reorderlist/ReorderImageList.kt index 20ce3de..b5913ed 100644 --- a/android/src/main/kotlin/org/burnoutcrew/android/ui/reorderlist/ReorderImageList.kt +++ b/android/src/main/kotlin/org/burnoutcrew/android/ui/reorderlist/ReorderImageList.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 André Claßen + * Copyright 2022 André Claßen * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,7 +15,7 @@ */ package org.burnoutcrew.android.ui.reorderlist - +import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -31,56 +31,66 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.Divider import androidx.compose.material.MaterialTheme import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.List import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.rememberAsyncImagePainter import coil.compose.rememberImagePainter import org.burnoutcrew.android.R -import org.burnoutcrew.reorderable.ReorderableLazyListState -import org.burnoutcrew.reorderable.detectReorderAfterLongPress -import org.burnoutcrew.reorderable.draggedItem -import org.burnoutcrew.reorderable.rememberReorderLazyListState +import org.burnoutcrew.reorderable.ReorderableItem +import org.burnoutcrew.reorderable.detectReorder +import org.burnoutcrew.reorderable.rememberReorderableLazyListState import org.burnoutcrew.reorderable.reorderable @Composable fun ReorderImageList( - vm: ImageListViewModel = viewModel(), modifier: Modifier = Modifier, + vm: ImageListViewModel = viewModel(), ) { - val state: ReorderableLazyListState = - rememberReorderLazyListState(onMove = { from, to -> vm.onMove(from, to) }, canDragOver = { vm.canDragOver(it) }) + val state = rememberReorderableLazyListState(onMove = vm::onMove, canDragOver = vm::canDragOver) LazyColumn( state = state.listState, - modifier = modifier.then(Modifier.reorderable(state)) + modifier = modifier + .then(Modifier.reorderable(state)) ) { item { HeaderFooter(stringResource(R.string.header_title), vm.headerImage) } items(vm.images, { it }) { item -> - Column( - modifier = Modifier - .fillMaxWidth() - .draggedItem(state.offsetByKey(item)) - .background(MaterialTheme.colors.surface) - .detectReorderAfterLongPress(state) - ) { - Row { - Image( - painter = rememberImagePainter(item), - contentDescription = null, - modifier = Modifier.size(128.dp) - ) - Text( - text = item, - modifier = Modifier.padding(16.dp) - ) + ReorderableItem(state, item) { isDragging -> + val elevation = animateDpAsState(if (isDragging) 8.dp else 0.dp) + Column( + modifier = Modifier + .shadow(elevation.value) + .fillMaxWidth() + .background(MaterialTheme.colors.surface) + + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + Icons.Default.List, "", + modifier = Modifier.detectReorder(state) + ) + Image( + painter = rememberAsyncImagePainter(item), + contentDescription = null, + modifier = Modifier.size(128.dp) + ) + Text( + text = item, + modifier = Modifier.padding(16.dp) + ) + } + Divider() } - Divider() } } item { diff --git a/android/src/main/kotlin/org/burnoutcrew/android/ui/reorderlist/ReorderList.kt b/android/src/main/kotlin/org/burnoutcrew/android/ui/reorderlist/ReorderList.kt index 26fb139..0f99959 100644 --- a/android/src/main/kotlin/org/burnoutcrew/android/ui/reorderlist/ReorderList.kt +++ b/android/src/main/kotlin/org/burnoutcrew/android/ui/reorderlist/ReorderList.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 André Claßen + * Copyright 2022 André Claßen * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,7 +15,8 @@ */ package org.burnoutcrew.android.ui.reorderlist - +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -26,7 +27,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Divider import androidx.compose.material.MaterialTheme @@ -36,102 +36,100 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel -import org.burnoutcrew.reorderable.ItemPosition -import org.burnoutcrew.reorderable.ReorderableLazyListState +import org.burnoutcrew.reorderable.ReorderableItem import org.burnoutcrew.reorderable.detectReorderAfterLongPress -import org.burnoutcrew.reorderable.draggedItem -import org.burnoutcrew.reorderable.rememberReorderLazyListState +import org.burnoutcrew.reorderable.rememberReorderableLazyListState import org.burnoutcrew.reorderable.reorderable @Composable fun ReorderList(vm: ReorderListViewModel = viewModel()) { Column { - HorizontalReorderList( - items = vm.cats, + NewHorizontalReorderList( + vm = vm, modifier = Modifier.padding(vertical = 16.dp), - onMove = { from, to -> vm.moveCat(from, to) }, - ) - VerticalReorderList( - items = vm.dogs, - onMove = { from, to -> vm.moveDog(from, to) }, - canDragOver = { vm.isDogDragEnabled(it) }, ) + NewVerticalReorderList(vm = vm) } } + @Composable -private fun HorizontalReorderList( +private fun NewVerticalReorderList( modifier: Modifier = Modifier, - items: List, - onMove: (fromPos: ItemPosition, toPos: ItemPosition) -> (Unit), + vm: ReorderListViewModel, ) { - val state: ReorderableLazyListState = rememberReorderLazyListState(onMove = onMove) - LazyRow( + val state = rememberReorderableLazyListState(onMove = vm::moveDog, canDragOver = vm::isDogDragEnabled) + LazyColumn( state = state.listState, - horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier - .then(Modifier.reorderable(state)), + .then(Modifier.reorderable(state)) ) { - itemsIndexed(items) { idx, item -> - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .size(100.dp) - .draggedItem(state.offsetByIndex(idx)) - .scale(if (state.draggedIndex == null || state.draggedIndex == idx) 1f else .9f) - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colors.primary) - .detectReorderAfterLongPress(state) - ) { - Text(item.title) + items(vm.dogs, { item -> item.key }) { item -> + ReorderableItem(state, item.key) { dragging -> + val elevation = animateDpAsState(if (dragging) 8.dp else 0.dp) + if (item.isLocked) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(Color.LightGray) + ) { + Text( + text = item.title, + modifier = Modifier.padding(24.dp) + ) + } + } else { + Column( + modifier = Modifier + .shadow(elevation.value) + .fillMaxWidth() + .background(MaterialTheme.colors.surface) + .detectReorderAfterLongPress(state) + ) { + Text( + text = item.title, + modifier = Modifier.padding(16.dp) + ) + Divider() + } + } } } } } @Composable -private fun VerticalReorderList( +private fun NewHorizontalReorderList( + vm: ReorderListViewModel, modifier: Modifier = Modifier, - items: List, - onMove: (fromPos: ItemPosition, toPos: ItemPosition) -> (Unit), - canDragOver: ((pos: ItemPosition) -> Boolean), ) { - val state: ReorderableLazyListState = rememberReorderLazyListState(onMove = onMove, canDragOver = canDragOver) - LazyColumn( + val state = rememberReorderableLazyListState(onMove = vm::moveCat) + LazyRow( state = state.listState, - modifier = modifier - .then(Modifier.reorderable(state)) + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier.then(Modifier.reorderable(state)), ) { - items(items, { it.key }) { item -> - if (item.isLocked) { - Column( + items(vm.cats, { item -> item.key }) { item -> + ReorderableItem(state, item.key) { dragging -> + val scale = animateFloatAsState(if (dragging) 1.1f else 1.0f) + val elevation = if (dragging) 8.dp else 0.dp + Box( + contentAlignment = Alignment.Center, modifier = Modifier - .fillMaxWidth() - .background(Color.LightGray) - ) { - Text( - text = item.title, - modifier = Modifier.padding(24.dp) - ) - } - } else { - Column( - modifier = Modifier - .fillMaxWidth() - .draggedItem(state.offsetByKey(item.key)) - .background(MaterialTheme.colors.surface) + .size(100.dp) + .scale(scale.value) + .shadow(elevation, RoundedCornerShape(24.dp)) + .clip(RoundedCornerShape(24.dp)) + .background(Color.Red) .detectReorderAfterLongPress(state) ) { - Text( - text = item.title, - modifier = Modifier.padding(16.dp) - ) - Divider() + Text(item.title) } } } } -} \ No newline at end of file +} diff --git a/android/src/main/kotlin/org/burnoutcrew/android/ui/reorderlist/ReorderListViewModel.kt b/android/src/main/kotlin/org/burnoutcrew/android/ui/reorderlist/ReorderListViewModel.kt index af0f07a..e7da5f1 100644 --- a/android/src/main/kotlin/org/burnoutcrew/android/ui/reorderlist/ReorderListViewModel.kt +++ b/android/src/main/kotlin/org/burnoutcrew/android/ui/reorderlist/ReorderListViewModel.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 André Claßen + * Copyright 2022 André Claßen * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,25 +15,29 @@ */ package org.burnoutcrew.android.ui.reorderlist -import androidx.compose.runtime.toMutableStateList +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import org.burnoutcrew.reorderable.ItemPosition -import org.burnoutcrew.reorderable.move - class ReorderListViewModel : ViewModel() { - val cats = List(500) { ItemData("Cat $it", "id$it") }.toMutableStateList() - val dogs = List(500) { + var cats by mutableStateOf(List(500) { ItemData("Cat $it", "id$it") }) + var dogs by mutableStateOf(List(500) { if (it.mod(10) == 0) ItemData("Locked", "id$it", true) else ItemData("Dog $it", "id$it") - }.toMutableStateList() + }) fun moveCat(from: ItemPosition, to: ItemPosition) { - cats.move(from.index, to.index) + cats = cats.toMutableList().apply { + add(to.index, removeAt(from.index)) + } } fun moveDog(from: ItemPosition, to: ItemPosition) { - dogs.move(from.index, to.index) + dogs = dogs.toMutableList().apply { + add(to.index, removeAt(from.index)) + } } fun isDogDragEnabled(pos: ItemPosition) = dogs.getOrNull(pos.index)?.isLocked != true -} +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 4d30758..0e685d8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,7 +10,7 @@ buildscript { dependencies { classpath("org.jetbrains.compose:compose-gradle-plugin:1.2.0-alpha01-dev686") - classpath("com.android.tools.build:gradle:7.2.0") + classpath("com.android.tools.build:gradle:7.2.1") classpath(kotlin("gradle-plugin", version = "1.6.21")) } } diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index cd4c5f1..4918d27 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -15,7 +15,7 @@ kotlin { sourceSets { val jvmMain by getting { dependencies { - implementation(project(":reorderable")) + implementation("org.burnoutcrew.composereorderable:reorderable:0.9.0") implementation(compose.desktop.currentOs) } } diff --git a/desktop/src/jvmMain/kotlin/Main.kt b/desktop/src/jvmMain/kotlin/Main.kt index 4b141cc..465acfb 100644 --- a/desktop/src/jvmMain/kotlin/Main.kt +++ b/desktop/src/jvmMain/kotlin/Main.kt @@ -14,6 +14,7 @@ * limitations under the License. */ +import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.Image import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.background @@ -31,61 +32,65 @@ import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Menu import androidx.compose.runtime.Composable -import androidx.compose.runtime.toMutableStateList +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window import androidx.compose.ui.window.application -import org.burnoutcrew.reorderable.ItemPosition -import org.burnoutcrew.reorderable.ReorderableLazyListState +import org.burnoutcrew.reorderable.ReorderableItem import org.burnoutcrew.reorderable.detectReorder -import org.burnoutcrew.reorderable.draggedItem -import org.burnoutcrew.reorderable.move -import org.burnoutcrew.reorderable.rememberReorderLazyListState +import org.burnoutcrew.reorderable.rememberReorderableLazyListState import org.burnoutcrew.reorderable.reorderable fun main() = application { - val data = List(500) { "Cat $it" }.toMutableStateList() Window( onCloseRequest = ::exitApplication, title = "Lazy reorder list" ) { - VerticalReorderList(items = data) { a, b -> data.move(a.index, b.index) } + VerticalReorderList() } } @Composable -fun VerticalReorderList( - items: List, - onMove: (fromPos: ItemPosition, toPos: ItemPosition) -> (Unit), -) { - val state: ReorderableLazyListState = rememberReorderLazyListState(onMove = onMove) +fun VerticalReorderList() { + val items = remember { mutableStateOf(List(100) { it }) } + val state = rememberReorderableLazyListState(onMove = { from, to -> + items.value = items.value.toMutableList().apply { + add(to.index, removeAt(from.index)) + } + }) Box { LazyColumn( state = state.listState, modifier = Modifier.reorderable(state) ) { - items(items, { it }) { item -> - Column( - modifier = Modifier.draggedItem(state.offsetByKey(item)) - .background(MaterialTheme.colors.surface) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(16.dp) + items(items.value, { it }) { item -> + ReorderableItem(state, orientationLocked = false, key = item) { isDragging -> + val elevation = animateDpAsState(if (isDragging) 8.dp else 0.dp) + Column( + modifier = Modifier + .shadow(elevation.value) + .background(MaterialTheme.colors.surface) ) { - Image( - imageVector = Icons.Filled.Menu, - contentDescription = "", - modifier = Modifier.detectReorder(state) - ) - Text( - text = item, - modifier = Modifier.padding(start = 8.dp) - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(16.dp) + ) { + Image( + imageVector = Icons.Filled.Menu, + contentDescription = "", + modifier = Modifier.detectReorder(state) + ) + Text( + text = item.toString(), + modifier = Modifier.padding(start = 8.dp) + ) + } + Divider() } - Divider() } } } diff --git a/reorderable/build.gradle.kts b/reorderable/build.gradle.kts index 007846d..0d75532 100644 --- a/reorderable/build.gradle.kts +++ b/reorderable/build.gradle.kts @@ -9,7 +9,7 @@ plugins { } group = "org.burnoutcrew.composereorderable" -version = "0.8.1" +version = "0.9.0" kotlin { jvm() diff --git a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/DetectReorder.kt b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/DetectReorder.kt index 3d07704..d73f341 100644 --- a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/DetectReorder.kt +++ b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/DetectReorder.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 André Claßen + * Copyright 2022 André Claßen * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,9 +20,7 @@ import androidx.compose.foundation.gestures.forEachGesture import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerInputChange -import androidx.compose.ui.input.pointer.consumePositionChange import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.input.pointer.positionChangeConsumed fun Modifier.detectReorder(state: ReorderableState<*>) = this.then( @@ -34,18 +32,19 @@ fun Modifier.detectReorder(state: ReorderableState<*>) = var overSlop = Offset.Zero do { drag = awaitPointerSlopOrCancellation(down.id, down.type) { change, over -> - change.consumePositionChange() + change.consume() overSlop = over } - } while (drag != null && !drag.positionChangeConsumed()) + } while (drag != null && !drag.isConsumed) if (drag != null) { - state.ch.trySend(StartDrag(down.id, overSlop)) + state.interactions.trySend(StartDrag(down.id, overSlop)) } } } } ) + fun Modifier.detectReorderAfterLongPress(state: ReorderableState<*>) = this.then( Modifier.pointerInput(Unit) { @@ -54,7 +53,7 @@ fun Modifier.detectReorderAfterLongPress(state: ReorderableState<*>) = awaitFirstDown(requireUnconsumed = false) } awaitLongPressOrCancellation(down)?.also { - state.ch.trySend(StartDrag(down.id)) + state.interactions.trySend(StartDrag(down.id)) } } } diff --git a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/DragCancelledAnimation.kt b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/DragCancelledAnimation.kt new file mode 100644 index 0000000..af8b9d1 --- /dev/null +++ b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/DragCancelledAnimation.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2022 André Claßen + * + * 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 + * + * https://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 org.burnoutcrew.reorderable + +import androidx.compose.animation.core.Animatable +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.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset + +interface DragCancelledAnimation { + suspend fun dragCancelled(position: ItemPosition, offset: Offset) + val position: ItemPosition? + val offset: Offset +} + +class NoDragCancelledAnimation : DragCancelledAnimation { + override suspend fun dragCancelled(position: ItemPosition, offset: Offset) {} + override val position: ItemPosition? = null + override val offset: Offset = Offset.Zero +} + +class SpringDragCancelledAnimation(private val stiffness: Float = Spring.StiffnessMediumLow) : DragCancelledAnimation { + private val animatable = Animatable(Offset.Zero, Offset.VectorConverter) + override val offset: Offset + get() = animatable.value + + override var position by mutableStateOf(null) + private set + + override suspend fun dragCancelled(position: ItemPosition, offset: Offset) { + this.position = position + animatable.snapTo(offset) + animatable.animateTo( + Offset.Zero, + spring(stiffness = stiffness, visibilityThreshold = Offset.VisibilityThreshold) + ) + this.position = null + } +} \ No newline at end of file diff --git a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/DragGesture.kt b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/DragGesture.kt index 7e36143..24e90d9 100644 --- a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/DragGesture.kt +++ b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/DragGesture.kt @@ -26,7 +26,6 @@ import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed import androidx.compose.ui.input.pointer.isOutOfBounds import androidx.compose.ui.input.pointer.positionChange -import androidx.compose.ui.input.pointer.positionChangeConsumed import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastAll @@ -40,18 +39,20 @@ import kotlinx.coroutines.withTimeout internal suspend fun AwaitPointerEventScope.awaitPointerSlopOrCancellation( pointerId: PointerId, pointerType: PointerType, - onPointerSlopReached: (change: PointerInputChange, overSlop: Offset) -> Unit, + onPointerSlopReached: (change: PointerInputChange, overSlop: Offset) -> Unit ): PointerInputChange? { if (currentEvent.isPointerUp(pointerId)) { return null // The pointer has already been lifted, so the gesture is canceled } var offset = Offset.Zero val touchSlop = viewConfiguration.pointerSlop(pointerType) + var pointer = pointerId + while (true) { val event = awaitPointerEvent() - val dragEvent = event.changes.fastFirstOrNull { it.id == pointer }!! - if (dragEvent.positionChangeConsumed()) { + val dragEvent = event.changes.fastFirstOrNull { it.id == pointer } ?: return null + if (dragEvent.isConsumed) { return null } else if (dragEvent.changedToUpIgnoreConsumed()) { val otherDown = event.changes.fastFirstOrNull { it.pressed } @@ -68,7 +69,7 @@ internal suspend fun AwaitPointerEventScope.awaitPointerSlopOrCancellation( if (distance >= touchSlop) { val touchSlopOffset = offset / distance * touchSlop onPointerSlopReached(dragEvent, offset - touchSlopOffset) - if (dragEvent.positionChangeConsumed()) { + if (dragEvent.isConsumed) { acceptedDrag = true } else { offset = Offset.Zero @@ -79,7 +80,7 @@ internal suspend fun AwaitPointerEventScope.awaitPointerSlopOrCancellation( return dragEvent } else { awaitPointerEvent(PointerEventPass.Final) - if (dragEvent.positionChangeConsumed()) { + if (dragEvent.isConsumed) { return null } } @@ -88,7 +89,7 @@ internal suspend fun AwaitPointerEventScope.awaitPointerSlopOrCancellation( } internal suspend fun PointerInputScope.awaitLongPressOrCancellation( - initialDown: PointerInputChange, + initialDown: PointerInputChange ): PointerInputChange? { var longPress: PointerInputChange? = null var currentDown = initialDown @@ -105,8 +106,11 @@ internal suspend fun PointerInputScope.awaitLongPressOrCancellation( finished = true } - - if (event.changes.fastAny { it.consumed.downChange || it.isOutOfBounds(size) }) { + if ( + event.changes.fastAny { + it.isConsumed || it.isOutOfBounds(size, extendedTouchPadding) + } + ) { finished = true // Canceled } @@ -114,7 +118,7 @@ internal suspend fun PointerInputScope.awaitLongPressOrCancellation( // the existing pointer event because it comes after the Main pass we checked // above. val consumeCheck = awaitPointerEvent(PointerEventPass.Final) - if (consumeCheck.changes.fastAny { it.positionChangeConsumed() }) { + if (consumeCheck.changes.fastAny { it.isConsumed }) { finished = true } if (!event.isPointerUp(currentDown.id)) { diff --git a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/DraggedItem.kt b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/DraggedItem.kt deleted file mode 100644 index 382ede8..0000000 --- a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/DraggedItem.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright 2021 André Claßen - * - * 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 - * - * https://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 org.burnoutcrew.reorderable - -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.zIndex - -fun Modifier.draggedItem(offset: IntOffset?): Modifier = this.then( - zIndex(offset?.let { 1f } ?: 0f) - .graphicsLayer { - translationX = offset?.x?.toFloat() ?: 0f - translationY = offset?.y?.toFloat() ?: 0f - shadowElevation = offset?.let { 8f } ?: 0f - } -) \ No newline at end of file diff --git a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ItemPosition.kt b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ItemPosition.kt index 18d09e4..b082aa6 100644 --- a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ItemPosition.kt +++ b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ItemPosition.kt @@ -1,18 +1,3 @@ -/* - * Copyright 2021 André Claßen - * - * 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 - * - * https://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 org.burnoutcrew.reorderable -data class ItemPosition(val index: Int, val key: Any) \ No newline at end of file +data class ItemPosition(val index: Int, val key: Any?) \ No newline at end of file diff --git a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/Move.kt b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/Move.kt deleted file mode 100644 index 4ed9707..0000000 --- a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/Move.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2021 André Claßen - * - * 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 - * - * https://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 org.burnoutcrew.reorderable - -fun MutableList.move(fromIdx: Int, toIdx: Int) { - when { - fromIdx == toIdx -> { - return - } - toIdx > fromIdx -> { - for (i in fromIdx until toIdx) { - this[i] = this[i + 1].also { this[i + 1] = this[i] } - } - } - else -> { - for (i in fromIdx downTo toIdx + 1) { - this[i] = this[i - 1].also { this[i - 1] = this[i] } - } - } - } -} \ No newline at end of file diff --git a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/Reorderable.kt b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/Reorderable.kt index b9f52bb..e9d6d07 100644 --- a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/Reorderable.kt +++ b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/Reorderable.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 André Claßen + * Copyright 2022 André Claßen * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,124 +17,44 @@ package org.burnoutcrew.reorderable import androidx.compose.foundation.gestures.drag import androidx.compose.foundation.gestures.forEachGesture -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.snapshotFlow -import androidx.compose.runtime.withFrameMillis import androidx.compose.ui.Modifier -import androidx.compose.ui.composed import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerId import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.changedToUp -import androidx.compose.ui.input.pointer.consumeAllChanges -import androidx.compose.ui.input.pointer.consumeDownChange -import androidx.compose.ui.input.pointer.consumePositionChange import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.positionChange -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastFirstOrNull -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.launch -@OptIn(ExperimentalCoroutinesApi::class) fun Modifier.reorderable( - state: ReorderableState<*>, - maxScrollPerFrame: Dp = 20.dp, -) = composed { - val job: MutableState = remember { mutableStateOf(null) } - val maxScroll = with(LocalDensity.current) { maxScrollPerFrame.toPx() } - val scope = rememberCoroutineScope() - val interactions = remember { MutableSharedFlow(extraBufferCapacity = 16) } - fun cancelAutoScroll() { - job.value = job.value?.let { - it.cancel() - null - } - } - LaunchedEffect(state) { - merge( - interactions, - snapshotFlow { state.visibleItemsInfo } - .map { ReorderAction.Drag(0f, 0f) } - ) - .collect { event -> - when (event) { - is ReorderAction.End -> { - cancelAutoScroll() - state.endDrag() - } - is ReorderAction.Start -> { - state.startDrag(event.key) - } - is ReorderAction.Drag -> { - if (state.dragBy(event.amount.toInt(), event.amountY?.toInt() ?: 0) && job.value?.isActive != true) { - val scrollOffset = state.calcAutoScrollOffset(0, maxScroll) - if (scrollOffset != 0f) { - job.value = - scope.launch { - var scroll = scrollOffset - var start = 0L - while (scroll != 0f && job.value?.isActive == true) { - withFrameMillis { - if (start == 0L) { - start = it - } else { - scroll = state.calcAutoScrollOffset(it - start, maxScroll) - } - } - if (state.scrollBy(scroll) != scroll) { - scroll = 0f - } - } - } - } else { - cancelAutoScroll() - } - } - } - } - } - } - + state: ReorderableState<*> +) = then( Modifier.pointerInput(Unit) { forEachGesture { - val dragStart = state.ch.receive() + val dragStart = state.interactions.receive() val down = awaitPointerEventScope { currentEvent.changes.fastFirstOrNull { it.id == dragStart.id } } - - val item = down?.position?.let { position -> - state.findKeyAt(position.x, position.y) - } - if (down != null && item != null) { - - interactions.tryEmit(ReorderAction.Start(item)) - dragStart.offet?.also { - interactions.tryEmit(ReorderAction.Drag(it.x, it.y)) + if (down != null && state.onDragStart(down.position.x.toInt(), down.position.y.toInt())) { + dragStart.offset?.apply { + state.onDrag(x.toInt(), y.toInt()) } detectDrag( down.id, - onDragEnd = { interactions.tryEmit(ReorderAction.End) }, - onDragCancel = { interactions.tryEmit(ReorderAction.End) }, + onDragEnd = { + state.onDragCanceled() + }, + onDragCancel = { + state.onDragCanceled() + }, onDrag = { change, dragAmount -> - change.consumeAllChanges() - interactions.tryEmit(ReorderAction.Drag(dragAmount.x, dragAmount.y)) + change.consume() + state.onDrag(dragAmount.x.toInt(), dragAmount.y.toInt()) }) } } - } -} + }) internal suspend fun PointerInputScope.detectDrag( down: PointerId, @@ -146,14 +66,12 @@ internal suspend fun PointerInputScope.detectDrag( if ( drag(down) { onDrag(it, it.positionChange()) - it.consumePositionChange() + it.consume() } ) { // consume up if we quit drag gracefully with the up currentEvent.changes.forEach { - if (it.changedToUp()) { - it.consumeDownChange() - } + if (it.changedToUp()) it.consume() } onDragEnd() } else { @@ -162,10 +80,4 @@ internal suspend fun PointerInputScope.detectDrag( } } -internal sealed class ReorderAction { - class Start(val key: Any) : ReorderAction() - class Drag(val amount: Float, val amountY: Float? = null) : ReorderAction() - object End : ReorderAction() -} - -internal data class StartDrag(val id: PointerId, val offet: Offset? = null) \ No newline at end of file +internal data class StartDrag(val id: PointerId, val offset: Offset? = null) \ No newline at end of file diff --git a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableItem.kt b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableItem.kt new file mode 100644 index 0000000..bbc95d2 --- /dev/null +++ b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableItem.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2022 André Claßen + * + * 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 + * + * https://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 org.burnoutcrew.reorderable + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.grid.LazyGridItemScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.zIndex + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LazyItemScope.ReorderableItem( + reorderableState: ReorderableState<*>, + key: Any?, + modifier: Modifier = Modifier, + index: Int? = null, + orientationLocked: Boolean = true, + content: @Composable BoxScope.(isDragging: Boolean) -> Unit +) = ReorderableItem(reorderableState, key, modifier, Modifier.animateItemPlacement(), orientationLocked, index, content) + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LazyGridItemScope.ReorderableItem( + reorderableState: ReorderableState<*>, + key: Any?, + modifier: Modifier = Modifier, + index: Int? = null, + content: @Composable BoxScope.(isDragging: Boolean) -> Unit +) = ReorderableItem(reorderableState, key, modifier, Modifier.animateItemPlacement(), false, index, content) + +@Composable +fun ReorderableItem( + state: ReorderableState<*>, + key: Any?, + modifier: Modifier = Modifier, + defaultDraggingModifier: Modifier = Modifier, + orientationLocked: Boolean = true, + index: Int? = null, + content: @Composable BoxScope.(isDragging: Boolean) -> Unit +) { + val isDragging = if (index != null) { + index == state.draggingItemIndex + } else { + key == state.draggingItemKey + } + val draggingModifier = + if (isDragging) { + Modifier + .zIndex(1f) + .graphicsLayer { + translationX = if (!orientationLocked || !state.isVerticalScroll) state.draggingItemLeft else 0f + translationY = if (!orientationLocked || state.isVerticalScroll) state.draggingItemTop else 0f + } + } else { + val cancel = if (index != null) { + index == state.dragCancelledAnimation.position?.index + } else { + key == state.dragCancelledAnimation.position?.key + } + if (cancel) { + Modifier.zIndex(1f) + .graphicsLayer { + translationX = if (!orientationLocked || !state.isVerticalScroll) state.dragCancelledAnimation.offset.x else 0f + translationY = if (!orientationLocked || state.isVerticalScroll) state.dragCancelledAnimation.offset.y else 0f + } + } else { + defaultDraggingModifier + } + } + Box(modifier = modifier.then(draggingModifier)) { + content(isDragging) + } +} \ No newline at end of file diff --git a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableLazyGridState.kt b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableLazyGridState.kt index 92e16ce..b51498d 100644 --- a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableLazyGridState.kt +++ b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableLazyGridState.kt @@ -18,37 +18,56 @@ package org.burnoutcrew.reorderable import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.lazy.grid.LazyGridItemInfo -import androidx.compose.foundation.lazy.grid.LazyGridLayoutInfo import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import androidx.compose.ui.unit.IntOffset +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope @Composable fun rememberReorderableLazyGridState( + onMove: (ItemPosition, ItemPosition) -> Unit, gridState: LazyGridState = rememberLazyGridState(), - onMove: (fromIndex: ItemPosition, toIndex: ItemPosition) -> (Unit), canDragOver: ((index: ItemPosition) -> Boolean)? = null, onDragEnd: ((startIndex: Int, endIndex: Int) -> (Unit))? = null, -) = remember { ReorderableLazyGridState(gridState, onMove, canDragOver, onDragEnd) } + maxScrollPerFrame: Dp = 20.dp, + dragCancelledAnimation: DragCancelledAnimation = SpringDragCancelledAnimation() +): ReorderableLazyGridState { + val maxScroll = with(LocalDensity.current) { maxScrollPerFrame.toPx() } + val scope = rememberCoroutineScope() + val state = remember(gridState) { + ReorderableLazyGridState(gridState, scope, maxScroll, onMove, canDragOver, onDragEnd, dragCancelledAnimation) + } + LaunchedEffect(state) { + state.visibleItemsChanged() + .collect { state.onDrag(0, 0) } + } + + LaunchedEffect(state) { + while (true) { + val diff = state.scrollChannel.receive() + gridState.scrollBy(diff) + } + } + return state +} class ReorderableLazyGridState( val gridState: LazyGridState, + scope: CoroutineScope, + maxScrollPerFrame: Float, onMove: (fromIndex: ItemPosition, toIndex: ItemPosition) -> (Unit), canDragOver: ((index: ItemPosition) -> Boolean)? = null, onDragEnd: ((startIndex: Int, endIndex: Int) -> (Unit))? = null, -) : ReorderableState(onMove, canDragOver, onDragEnd) { - override val visibleItemsInfo: List - get() = gridState.layoutInfo.visibleItemsInfo - override val isVertical: Boolean + dragCancelledAnimation: DragCancelledAnimation = SpringDragCancelledAnimation() +) : ReorderableState(scope, maxScrollPerFrame, onMove, canDragOver, onDragEnd, dragCancelledAnimation) { + override val isVerticalScroll: Boolean get() = gridState.layoutInfo.orientation == Orientation.Vertical - override val viewportStartOffset: Int - get() = gridState.layoutInfo.viewportStartOffset - override val viewportEndOffset: Int - get() = gridState.layoutInfo.viewportEndOffset override val LazyGridItemInfo.left: Int get() = offset.x override val LazyGridItemInfo.right: Int @@ -65,20 +84,18 @@ class ReorderableLazyGridState( get() = index override val LazyGridItemInfo.itemKey: Any get() = key - override val draggedOffset: IntOffset? by derivedStateOf { - draggedIndex - ?.let { gridState.layoutInfo.itemInfoByIndex(it) } - ?.let { (selected?.offset ?: IntOffset.Zero) + movedDist - it.offset } - } - - override suspend fun scrollBy(value: Float): Float { - return gridState.scrollBy(value) - } + override val visibleItemsInfo: List + get() = gridState.layoutInfo.visibleItemsInfo + override val viewportStartOffset: Int + get() = gridState.layoutInfo.viewportStartOffset + override val viewportEndOffset: Int + get() = gridState.layoutInfo.viewportEndOffset + override val firstVisibleItemIndex: Int + get() = gridState.firstVisibleItemIndex + override val firstVisibleItemScrollOffset: Int + get() = gridState.firstVisibleItemScrollOffset - override suspend fun scrollToCurrentItem() { - gridState.scrollToItem(gridState.firstVisibleItemIndex, gridState.firstVisibleItemScrollOffset) + override suspend fun scrollToItem(index: Int, offset: Int) { + gridState.scrollToItem(index, offset) } -} - -private fun LazyGridLayoutInfo.itemInfoByIndex(index: Int) = - visibleItemsInfo.getOrNull(index - visibleItemsInfo.first().index) \ No newline at end of file +} \ No newline at end of file diff --git a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableLazyListState.kt b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableLazyListState.kt index 70ac53a..678505a 100644 --- a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableLazyListState.kt +++ b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableLazyListState.kt @@ -18,84 +18,107 @@ package org.burnoutcrew.reorderable import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.lazy.LazyListItemInfo -import androidx.compose.foundation.lazy.LazyListLayoutInfo import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import androidx.compose.ui.unit.IntOffset +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope + @Composable -fun rememberReorderLazyListState( +fun rememberReorderableLazyListState( + onMove: (ItemPosition, ItemPosition) -> Unit, listState: LazyListState = rememberLazyListState(), - onMove: (fromIndex: ItemPosition, toIndex: ItemPosition) -> (Unit), canDragOver: ((index: ItemPosition) -> Boolean)? = null, onDragEnd: ((startIndex: Int, endIndex: Int) -> (Unit))? = null, -) = remember { ReorderableLazyListState(listState, onMove, canDragOver, onDragEnd) } + maxScrollPerFrame: Dp = 20.dp, + dragCancelledAnimation: DragCancelledAnimation = SpringDragCancelledAnimation() +): ReorderableLazyListState { + val maxScroll = with(LocalDensity.current) { maxScrollPerFrame.toPx() } + val scope = rememberCoroutineScope() + val state = remember(listState) { + ReorderableLazyListState(listState, scope, maxScroll, onMove, canDragOver, onDragEnd, dragCancelledAnimation) + } + + LaunchedEffect(state) { + state.visibleItemsChanged() + .collect { state.onDrag(0, 0) } + } + + LaunchedEffect(state) { + while (true) { + val diff = state.scrollChannel.receive() + listState.scrollBy(diff) + } + } + return state +} class ReorderableLazyListState( val listState: LazyListState, + scope: CoroutineScope, + maxScrollPerFrame: Float, onMove: (fromIndex: ItemPosition, toIndex: ItemPosition) -> (Unit), canDragOver: ((index: ItemPosition) -> Boolean)? = null, onDragEnd: ((startIndex: Int, endIndex: Int) -> (Unit))? = null, -) : ReorderableState(onMove, canDragOver, onDragEnd) { - + dragCancelledAnimation: DragCancelledAnimation = SpringDragCancelledAnimation() +) : ReorderableState(scope, maxScrollPerFrame, onMove, canDragOver, onDragEnd, dragCancelledAnimation) { + override val isVerticalScroll: Boolean + get() = listState.layoutInfo.orientation == Orientation.Vertical override val LazyListItemInfo.left: Int - get() = if (isVertical) 0 else offset + get() = if (isVerticalScroll) 0 else offset override val LazyListItemInfo.top: Int - get() = if (isVertical) offset else 0 + get() = if (isVerticalScroll) offset else 0 override val LazyListItemInfo.right: Int - get() = if (isVertical) 0 else offset + size + get() = if (isVerticalScroll) 0 else offset + size override val LazyListItemInfo.bottom: Int - get() = if (isVertical) offset + size else 0 + get() = if (isVerticalScroll) offset + size else 0 override val LazyListItemInfo.width: Int - get() = if (isVertical) 0 else size + get() = if (isVerticalScroll) 0 else size override val LazyListItemInfo.height: Int - get() = if (isVertical) size else 0 + get() = if (isVerticalScroll) size else 0 override val LazyListItemInfo.itemIndex: Int get() = index override val LazyListItemInfo.itemKey: Any get() = key override val visibleItemsInfo: List get() = listState.layoutInfo.visibleItemsInfo - override val isVertical: Boolean - get() = listState.layoutInfo.orientation == Orientation.Vertical override val viewportStartOffset: Int get() = listState.layoutInfo.viewportStartOffset override val viewportEndOffset: Int get() = listState.layoutInfo.viewportEndOffset + override val firstVisibleItemIndex: Int + get() = listState.firstVisibleItemIndex + override val firstVisibleItemScrollOffset: Int + get() = listState.firstVisibleItemScrollOffset - override suspend fun scrollBy(value: Float): Float { - return listState.scrollBy(value) + override suspend fun scrollToItem(index: Int, offset: Int) { + listState.scrollToItem(index, offset) } - override suspend fun scrollToCurrentItem() { - listState.scrollToItem(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) - } - - @Suppress("MemberVisibilityCanBePrivate") - override val draggedOffset: IntOffset? by derivedStateOf { - draggedIndex - ?.let { listState.layoutInfo.itemInfoByIndex(it) } - ?.let { - val v = (selected?.offset ?: 0) - it.offset - IntOffset( - if (isVertical) 0 else v + movedDist.x, - if (isVertical) v + movedDist.y else 0 - ) - } - } + override fun onDragStart(offsetX: Int, offsetY: Int): Boolean = + if (isVerticalScroll) { + super.onDragStart(0, offsetY) + } else { + super.onDragStart(offsetX, 0) + } - override fun findKeyAt(x: Float, y: Float): Any? { - return super.findKeyAt(if (isVertical) 0f else x, if (isVertical) y else 0f) - } - - override suspend fun dragBy(x: Int, y: Int): Boolean { - return super.dragBy(if (isVertical) 0 else x, if (isVertical) y else 0) - } -} + override fun findTargets(x: Int, y: Int, selected: LazyListItemInfo) = + if (isVerticalScroll) { + super.findTargets(0, y, selected) + } else { + super.findTargets(x, 0, selected) + } -private fun LazyListLayoutInfo.itemInfoByIndex(index: Int) = - visibleItemsInfo.getOrNull(index - visibleItemsInfo.first().index) \ No newline at end of file + override fun chooseDropItem(draggedItemInfo: LazyListItemInfo?, items: List, curX: Int, curY: Int) = + if (isVerticalScroll) { + super.chooseDropItem(draggedItemInfo, items, 0, curY) + } else { + super.chooseDropItem(draggedItemInfo, items, curX, 0) + } +} \ No newline at end of file diff --git a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableState.kt b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableState.kt index e4cbfcd..a6b06f5 100644 --- a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableState.kt +++ b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableState.kt @@ -15,23 +15,39 @@ */ package org.burnoutcrew.reorderable -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.util.fastFirstOrNull +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameMillis +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.util.fastForEach +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch import kotlin.math.absoluteValue import kotlin.math.min import kotlin.math.sign + abstract class ReorderableState( + private val scope: CoroutineScope, + private val maxScrollPerFrame: Float, private val onMove: (fromIndex: ItemPosition, toIndex: ItemPosition) -> (Unit), - private val canDragOver: ((index: ItemPosition) -> Boolean)? = null, - private val onDragEnd: ((startIndex: Int, endIndex: Int) -> (Unit))? = null, + private val canDragOver: ((index: ItemPosition) -> Boolean)?, + private val onDragEnd: ((startIndex: Int, endIndex: Int) -> (Unit))?, + val dragCancelledAnimation: DragCancelledAnimation ) { + var draggingItemIndex by mutableStateOf(null) + private set + val draggingItemKey: Any? + get() = selected?.itemKey protected abstract val T.left: Int protected abstract val T.top: Int protected abstract val T.right: Int @@ -40,51 +56,64 @@ abstract class ReorderableState( protected abstract val T.height: Int protected abstract val T.itemIndex: Int protected abstract val T.itemKey: Any - protected abstract val isVertical: Boolean + protected abstract val visibleItemsInfo: List + protected abstract val firstVisibleItemIndex: Int + protected abstract val firstVisibleItemScrollOffset: Int protected abstract val viewportStartOffset: Int protected abstract val viewportEndOffset: Int - protected abstract val draggedOffset: IntOffset? - protected abstract suspend fun scrollToCurrentItem() - abstract val visibleItemsInfo: List - abstract suspend fun scrollBy(value: Float): Float + internal val interactions = Channel() + internal val scrollChannel = Channel() + val draggingItemLeft: Float + get() = draggingLayoutInfo?.let { item -> + (selected?.left ?: 0) + draggingDelta.x - item.left + } ?: 0f + val draggingItemTop: Float + get() = draggingLayoutInfo?.let { item -> + (selected?.top ?: 0) + draggingDelta.y - item.top + } ?: 0f + abstract val isVerticalScroll: Boolean + private val draggingLayoutInfo: T? + get() = visibleItemsInfo + .firstOrNull { it.itemIndex == draggingItemIndex } + private var draggingDelta by mutableStateOf(Offset.Zero) + private var selected by mutableStateOf(null) + private var autoscroller: Job? = null private val targets = mutableListOf() private val distances = mutableListOf() - internal val ch = Channel() - internal var selected by mutableStateOf(null) - internal var movedDist by mutableStateOf(IntOffset.Zero) - - var draggedIndex by mutableStateOf(null) - internal set - val draggedKey by derivedStateOf { selected?.itemKey } + protected abstract suspend fun scrollToItem(index: Int, offset: Int) - fun offsetByKey(key: Any) = - if (draggedKey == key) draggedOffset else null + @OptIn(ExperimentalCoroutinesApi::class) + internal fun visibleItemsChanged() = + snapshotFlow { draggingItemIndex != null } + .flatMapLatest { if (it) snapshotFlow { visibleItemsInfo } else flowOf(null) } + .filterNotNull() + .distinctUntilChanged { old, new -> old.firstOrNull()?.itemIndex == new.firstOrNull()?.itemIndex && old.count() == new.count() } - fun offsetByIndex(index: Int) = - if (draggedIndex == index) draggedOffset else null + internal open fun onDragStart(offsetX: Int, offsetY: Int): Boolean { + return visibleItemsInfo + .firstOrNull { offsetX in it.left..it.right && offsetY in it.top..it.bottom } + ?.also { + selected = it + draggingItemIndex = it.itemIndex + } != null + } - fun startDrag(key: Any) = - visibleItemsInfo - .fastFirstOrNull { it.itemKey == key } - ?.also { info -> - selected = info - draggedIndex = info.itemIndex + internal fun onDragCanceled() { + val dragIdx = draggingItemIndex + if (dragIdx != null) { + val position = ItemPosition(dragIdx, selected?.itemKey) + val offset = Offset(draggingItemLeft, draggingItemTop) + scope.launch { + dragCancelledAnimation.dragCancelled(position, offset) } - - open suspend fun dragBy(x: Int, y: Int): Boolean = - draggedIndex?.let { - movedDist += IntOffset(x, y) - checkIfMoved() - true - } ?: false - - fun endDrag() { + } val startIndex = selected?.itemIndex - val endIndex = draggedIndex - draggedIndex = null + val endIndex = draggingItemIndex selected = null - movedDist = IntOffset.Zero + draggingDelta = Offset.Zero + draggingItemIndex = null + cancelAutoScroll() onDragEnd?.apply { if (startIndex != null && endIndex != null) { invoke(startIndex, endIndex) @@ -92,62 +121,70 @@ abstract class ReorderableState( } } - private suspend fun checkIfMoved() { + internal fun onDrag(offsetX: Int, offsetY: Int) { val selected = selected ?: return - val draggingIndex = draggedIndex ?: return - val x = movedDist.x + selected.left - val y = movedDist.y + selected.top - val draggedItem = visibleItemsInfo.getOrNull(draggingIndex - visibleItemsInfo.first().itemIndex) - chooseDropItem(draggedItem, findTargets(movedDist.x, movedDist.y, selected), x, y) - ?.also { targetIdx -> - onMove(ItemPosition(draggingIndex, selected.itemKey), ItemPosition(targetIdx.itemIndex, targetIdx.itemKey)) - draggedIndex = targetIdx.itemIndex - scrollToCurrentItem() + draggingDelta = Offset(draggingDelta.x + offsetX, draggingDelta.y + offsetY) + val draggingItem = draggingLayoutInfo ?: return + val startOffset = draggingItem.top + draggingItemTop + val startOffsetX = draggingItem.left + draggingItemLeft + chooseDropItem( + draggingItem, + findTargets(draggingDelta.x.toInt(), draggingDelta.y.toInt(), selected), + startOffsetX.toInt(), + startOffset.toInt() + )?.also { targetItem -> + if (targetItem.itemIndex == firstVisibleItemIndex || draggingItem.itemIndex == firstVisibleItemIndex) { + scope.launch { + onMove.invoke( + ItemPosition(draggingItem.itemIndex, draggingItem.itemKey), + ItemPosition(targetItem.itemIndex, targetItem.itemKey) + ) + scrollToItem(firstVisibleItemIndex, firstVisibleItemScrollOffset) + } + } else { + onMove.invoke( + ItemPosition(draggingItem.itemIndex, draggingItem.itemKey), + ItemPosition(targetItem.itemIndex, targetItem.itemKey) + ) } - } + draggingItemIndex = targetItem.itemIndex + } - open fun findKeyAt(x: Float, y: Float): Any? { - val posY: Int - val posX: Int - if (isVertical) { - posY = (y + viewportStartOffset).toInt() - posX = x.toInt() - } else { - posY = y.toInt() - posX = (x + viewportStartOffset).toInt() + with(calcAutoScrollOffset(0, maxScrollPerFrame)) { + if (this != 0f) autoscroll(this) } - return visibleItemsInfo - .fastFirstOrNull { posY in it.top..it.bottom && posX in it.left..it.right } - ?.itemKey } - fun calcAutoScrollOffset(time: Long, maxScroll: Float): Float = - selected?.let { selected -> - val start: Int - val size: Int - val dist: Int - if (isVertical) { - start = movedDist.y + selected.top - dist = movedDist.y - size = selected.height - } else { - start = movedDist.x + selected.left - dist = movedDist.x - size = selected.width + private fun autoscroll(scrollOffset: Float) { + if (scrollOffset != 0f) { + if (autoscroller?.isActive == true) { + return } - val outOfBounds: Int = when { - dist < 0 -> (start - viewportStartOffset).takeIf { it < 0 } - dist > 0 -> (start + size - viewportEndOffset).takeIf { it > 0 } - else -> null - } ?: 0 - if (outOfBounds != 0) { - interpolateOutOfBoundsScroll(size, outOfBounds.toFloat(), time, maxScroll) - } else { - null + autoscroller = scope.launch { + var scroll = scrollOffset + var start = 0L + while (scroll != 0f && autoscroller?.isActive == true) { + withFrameMillis { + if (start == 0L) { + start = it + } else { + scroll = calcAutoScrollOffset(it - start, maxScrollPerFrame) + } + } + scrollChannel.trySend(scroll) + } } - } ?: 0f + } else { + cancelAutoScroll() + } + } + + private fun cancelAutoScroll() { + autoscroller?.cancel() + autoscroller = null + } - private fun findTargets(x: Int, y: Int, selected: T): List { + protected open fun findTargets(x: Int, y: Int, selected: T): List { targets.clear() distances.clear() val left = x + selected.left @@ -158,7 +195,7 @@ abstract class ReorderableState( val centerY = (top + bottom) / 2 visibleItemsInfo.fastForEach { item -> if ( - item.itemIndex == draggedIndex + item.itemIndex == draggingItemIndex || item.bottom < top || item.top > bottom || item.right < left @@ -185,14 +222,9 @@ abstract class ReorderableState( return targets } - private fun chooseDropItem( - draggedItemInfo: T?, - items: List, - curX: Int, - curY: Int - ): T? { + protected open fun chooseDropItem(draggedItemInfo: T?, items: List, curX: Int, curY: Int): T? { if (draggedItemInfo == null) { - return if (draggedIndex != null) items.lastOrNull() else null + return if (draggingItemIndex != null) items.lastOrNull() else null } var target: T? = null var highScore = -1 @@ -200,6 +232,7 @@ abstract class ReorderableState( val bottom = curY + draggedItemInfo.height val dx = curX - draggedItemInfo.left val dy = curY - draggedItemInfo.top + items.fastForEach { item -> if (dx > 0) { val diff = item.right - right @@ -245,6 +278,31 @@ abstract class ReorderableState( return target } + private fun calcAutoScrollOffset(time: Long, maxScroll: Float): Float { + val draggingItem = draggingLayoutInfo ?: return 0f + val startOffset: Float + val endOffset: Float + val delta: Float + if (isVerticalScroll) { + startOffset = draggingItem.top + draggingItemTop + endOffset = startOffset + draggingItem.height + delta = draggingDelta.y + } else { + startOffset = draggingItem.left + draggingItemLeft + endOffset = startOffset + draggingItem.width + delta = draggingDelta.x + } + return when { + delta > 0 -> + (endOffset - viewportEndOffset).coerceAtLeast(0f) + delta < 0 -> + (startOffset - viewportStartOffset).coerceAtMost(0f) + else -> 0f + } + .let { interpolateOutOfBoundsScroll((endOffset - startOffset).toInt(), it, time, maxScroll) } + } + + companion object { private const val ACCELERATION_LIMIT_TIME_MS: Long = 1500 private val EaseOutQuadInterpolator: (Float) -> (Float) = { @@ -261,11 +319,10 @@ abstract class ReorderableState( time: Long, maxScroll: Float, ): Float { + if (viewSizeOutOfBounds == 0f) return 0f val outOfBoundsRatio = min(1f, 1f * viewSizeOutOfBounds.absoluteValue / viewSize) - val cappedScroll = - sign(viewSizeOutOfBounds) * maxScroll * EaseOutQuadInterpolator(outOfBoundsRatio) - val timeRatio = - if (time > ACCELERATION_LIMIT_TIME_MS) 1f else time.toFloat() / ACCELERATION_LIMIT_TIME_MS + val cappedScroll = sign(viewSizeOutOfBounds) * maxScroll * EaseOutQuadInterpolator(outOfBoundsRatio) + val timeRatio = if (time > ACCELERATION_LIMIT_TIME_MS) 1f else time.toFloat() / ACCELERATION_LIMIT_TIME_MS return (cappedScroll * EaseInQuintInterpolator(timeRatio)).let { if (it == 0f) { if (viewSizeOutOfBounds > 0) 1f else -1f @@ -275,4 +332,4 @@ abstract class ReorderableState( } } } -} \ No newline at end of file +}