diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 3bb7468..a61c904 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -14,7 +14,7 @@ jobs: steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - name: Check out code - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.5.2 with: fetch-depth: '0' @@ -33,4 +33,4 @@ jobs: with: name: Reports path: '**/build/reports/*' - retention-days: 2 \ No newline at end of file + retention-days: 2 diff --git a/README.md b/README.md index 08be5ef..fab3403 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +**Note:** *gitflow will be used for this project. Make sure your PRs are against the develop branch.* + # Compose LazyList/Grid reorder [![Latest release](https://img.shields.io/github/v/release/aclassen/ComposeReorderable?color=brightgreen&label=latest%20release)](https://github.com/aclassen/ComposeReorderable/releases/latest) diff --git a/android/build.gradle.kts b/android/build.gradle.kts index dbb57f8..8de6444 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -6,14 +6,15 @@ plugins { dependencies { implementation(project(":reorderable")) - implementation("androidx.compose.runtime:runtime:1.3.3") - implementation("androidx.compose.material:material:1.3.1") - implementation("androidx.activity:activity-compose:1.6.1") - implementation("com.google.android.material:material:1.8.0") - implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1") - implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1") - implementation("androidx.navigation:navigation-compose:2.5.3") - implementation("io.coil-kt:coil-compose:2.2.2") + implementation("androidx.compose.runtime:runtime:1.6.0") + implementation("androidx.compose.material:material:1.6.0") + implementation("androidx.activity:activity-compose:1.8.2") + implementation("com.google.android.material:material:1.11.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") + implementation("androidx.navigation:navigation-compose:2.7.6") + implementation("io.coil-kt:coil-compose:2.5.0") + } android { @@ -21,13 +22,14 @@ android { sourceSets { map { it.java.srcDir("src/${it.name}/kotlin") } } + val minSdkVersion: Int by rootProject.extra val targetSdkVersion: Int by rootProject.extra val compileSdkVersion: Int by rootProject.extra - compileSdk = compileSdkVersion defaultConfig { minSdk = minSdkVersion targetSdk = targetSdkVersion + compileSdk = compileSdkVersion versionCode = 1 versionName = "1.0" } @@ -38,7 +40,8 @@ android { } kotlinOptions { - jvmTarget = "1.8" + jvmTarget = "11" } namespace = "org.burnoutcrew.android" -} \ No newline at end of file + buildToolsVersion = "34.0.0" +} 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 24bc977..b1897e4 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 @@ -16,12 +16,15 @@ package org.burnoutcrew.android.ui.reorderlist import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.ScrollableDefaults 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.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -29,6 +32,9 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.items import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable @@ -38,8 +44,10 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import org.burnoutcrew.reorderable.ReorderableItem +import org.burnoutcrew.reorderable.detectReorder import org.burnoutcrew.reorderable.detectReorderAfterLongPress import org.burnoutcrew.reorderable.rememberReorderableLazyGridState +import org.burnoutcrew.reorderable.rememberReorderableLazyVerticalStaggeredGridState import org.burnoutcrew.reorderable.reorderable @Composable @@ -49,10 +57,60 @@ fun ReorderGrid(vm: ReorderListViewModel = viewModel()) { vm = vm, modifier = Modifier.padding(vertical = 16.dp) ) - VerticalGrid(vm = vm) + VerticalGrid(vm = vm, modifier = Modifier.padding(vertical = 16.dp)) + VerticalStaggeredGrid(vm = vm, modifier = Modifier.padding(vertical = 16.dp)) } } +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun VerticalStaggeredGrid( + vm: ReorderListViewModel, + modifier: Modifier = Modifier, +) { + val state = rememberReorderableLazyVerticalStaggeredGridState( + onMove = vm::moveDog, + canDragOver = vm::isDogDragEnabled + ) + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Fixed(4), + state = state.gridState, + contentPadding = PaddingValues(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalItemSpacing = 4.dp, + modifier = modifier.reorderable(state), + ) { + items(items = vm.dogs, key = { it.key }) { item -> + if (item.isLocked) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(100.dp) + .background(MaterialTheme.colors.surface) + ) { + Text(item.title) + } + } else { + ReorderableItem(state, item.key) { isDragging -> + val elevation = animateDpAsState(if (isDragging) 8.dp else 0.dp) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .detectReorder(state) + .shadow(elevation.value) + .aspectRatio(1f) + .background(MaterialTheme.colors.primary) + ) { + Text(item.title) + } + } + } + } + } +} + + @Composable private fun HorizontalGrid( vm: ReorderListViewModel, @@ -92,7 +150,8 @@ private fun VerticalGrid( vm: ReorderListViewModel, modifier: Modifier = Modifier, ) { - val state = rememberReorderableLazyGridState(onMove = vm::moveDog, canDragOver = vm::isDogDragEnabled) + val state = + rememberReorderableLazyGridState(onMove = vm::moveDog, canDragOver = vm::isDogDragEnabled) LazyVerticalGrid( columns = GridCells.Fixed(4), state = state.gridState, 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 68f97f8..3fedbf2 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 @@ -22,8 +22,8 @@ import androidx.lifecycle.ViewModel import org.burnoutcrew.reorderable.ItemPosition class ReorderListViewModel : ViewModel() { - var cats by mutableStateOf(List(500) { ItemData("Cat $it", "id$it") }) - var dogs by mutableStateOf(List(500) { + var cats by mutableStateOf(List(6) { ItemData("Cat $it", "id$it") }) + var dogs by mutableStateOf(List(6) { if (it.mod(10) == 0) ItemData("Locked", "id$it", true) else ItemData("Dog $it", "id$it") }) @@ -40,4 +40,4 @@ class ReorderListViewModel : ViewModel() { } fun isDogDragEnabled(draggedOver: ItemPosition, dragging: ItemPosition) = dogs.getOrNull(draggedOver.index)?.isLocked != true -} \ No newline at end of file +} diff --git a/build.gradle.kts b/build.gradle.kts index d7710e3..b5b398e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,15 +1,15 @@ plugins { `maven-publish` - id("com.android.library") version "7.4.0" apply false - id("org.jetbrains.kotlin.multiplatform") version "1.8.0" apply false - id("org.jetbrains.kotlin.android") version "1.8.0" apply false - id("org.jetbrains.compose") version "1.3.0" apply false + id("com.android.library") version "8.2.0" apply false + id("org.jetbrains.kotlin.multiplatform") version "1.8.20" apply false + id("org.jetbrains.kotlin.android") version "1.8.20" apply false + id("org.jetbrains.compose") version "1.4.0" apply false } ext { - extra["compileSdkVersion"] = 33 + extra["compileSdkVersion"] = 34 extra["minSdkVersion"] = 21 - extra["targetSdkVersion"] = 33 + extra["targetSdkVersion"] = 34 } allprojects { @@ -18,4 +18,4 @@ allprojects { mavenCentral() maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") } -} \ No newline at end of file +} diff --git a/gradle.properties b/gradle.properties index 5a66a00..c9b6d9d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,4 +15,16 @@ kotlin.code.style=official org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M" android.useAndroidX=true -org.jetbrains.compose.experimental.jscanvas.enabled=true \ No newline at end of file +org.jetbrains.compose.experimental.jscanvas.enabled=true + +# Use of implicitly-created components in maven-publish. +# Starting with version 8.0, Android Gradle Plugin will no longer implicitly create +# components for the maven-publish plugin. You will have to adapt the publishing blocks +# to use the new API (and mark the project as migrated +# by adding android.disableAutomaticComponentCreation=true to the project's gradle.properties file). +# See: https://developer.android.com/build/publish-library +#android.disableAutomaticComponentCreation=true + +android.defaults.buildfeatures.buildconfig=true +android.nonTransitiveRClass=false +android.nonFinalResIds=false diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ae04661..15de902 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/DetectReorder.kt b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/DetectReorder.kt index d73f341..e875f6d 100644 --- a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/DetectReorder.kt +++ b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/DetectReorder.kt @@ -15,46 +15,44 @@ */ package org.burnoutcrew.reorderable +import androidx.compose.foundation.gestures.awaitDragOrCancellation +import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.gestures.forEachGesture +import androidx.compose.foundation.gestures.awaitLongPressOrCancellation +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.composed import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.AwaitPointerEventScope +import androidx.compose.ui.input.pointer.PointerId import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow -fun Modifier.detectReorder(state: ReorderableState<*>) = - this.then( - Modifier.pointerInput(Unit) { - forEachGesture { - awaitPointerEventScope { - val down = awaitFirstDown(requireUnconsumed = false) - var drag: PointerInputChange? - var overSlop = Offset.Zero - do { - drag = awaitPointerSlopOrCancellation(down.id, down.type) { change, over -> - change.consume() - overSlop = over - } - } while (drag != null && !drag.isConsumed) - if (drag != null) { - state.interactions.trySend(StartDrag(down.id, overSlop)) - } - } - } - } - ) - - -fun Modifier.detectReorderAfterLongPress(state: ReorderableState<*>) = - this.then( - Modifier.pointerInput(Unit) { - forEachGesture { - val down = awaitPointerEventScope { - awaitFirstDown(requireUnconsumed = false) - } - awaitLongPressOrCancellation(down)?.also { - state.interactions.trySend(StartDrag(down.id)) - } +fun Modifier.detectReorder(state: ReorderableState<*>) = detect(state){ + awaitDragOrCancellation(it) +} + +fun Modifier.detectReorderAfterLongPress(state: ReorderableState<*>) = detect(state) { + awaitLongPressOrCancellation(it) +} + + +private fun Modifier.detect(state: ReorderableState<*>, detect: suspend AwaitPointerEventScope.(PointerId)->PointerInputChange?) = composed { + + val itemPosition = remember { mutableStateOf(Offset.Zero) } + + Modifier.onGloballyPositioned { itemPosition.value = it.positionInWindow() }.pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + val start = detect(down.id) + + if (start != null) { + val relativePosition = itemPosition.value - state.layoutWindowPosition.value + start.position + state.onDragStart(relativePosition.x.toInt(), relativePosition.y.toInt()) } } - ) \ 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 deleted file mode 100644 index 24e90d9..0000000 --- a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/DragGesture.kt +++ /dev/null @@ -1,165 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.burnoutcrew.reorderable - -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.pointer.AwaitPointerEventScope -import androidx.compose.ui.input.pointer.PointerEvent -import androidx.compose.ui.input.pointer.PointerEventPass -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.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.platform.ViewConfiguration -import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.fastAll -import androidx.compose.ui.util.fastAny -import androidx.compose.ui.util.fastFirstOrNull -import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.withTimeout - -// Copied from DragGestureDetector , as long the pointer api isn`t ready. - -internal suspend fun AwaitPointerEventScope.awaitPointerSlopOrCancellation( - pointerId: PointerId, - pointerType: PointerType, - 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 } ?: return null - if (dragEvent.isConsumed) { - return null - } else if (dragEvent.changedToUpIgnoreConsumed()) { - val otherDown = event.changes.fastFirstOrNull { it.pressed } - if (otherDown == null) { - // This is the last "up" - return null - } else { - pointer = otherDown.id - } - } else { - offset += dragEvent.positionChange() - val distance = offset.getDistance() - var acceptedDrag = false - if (distance >= touchSlop) { - val touchSlopOffset = offset / distance * touchSlop - onPointerSlopReached(dragEvent, offset - touchSlopOffset) - if (dragEvent.isConsumed) { - acceptedDrag = true - } else { - offset = Offset.Zero - } - } - - if (acceptedDrag) { - return dragEvent - } else { - awaitPointerEvent(PointerEventPass.Final) - if (dragEvent.isConsumed) { - return null - } - } - } - } -} - -internal suspend fun PointerInputScope.awaitLongPressOrCancellation( - initialDown: PointerInputChange -): PointerInputChange? { - var longPress: PointerInputChange? = null - var currentDown = initialDown - val longPressTimeout = viewConfiguration.longPressTimeoutMillis - return try { - // wait for first tap up or long press - withTimeout(longPressTimeout) { - awaitPointerEventScope { - var finished = false - while (!finished) { - val event = awaitPointerEvent(PointerEventPass.Main) - if (event.changes.fastAll { it.changedToUpIgnoreConsumed() }) { - // All pointers are up - finished = true - } - - if ( - event.changes.fastAny { - it.isConsumed || it.isOutOfBounds(size, extendedTouchPadding) - } - ) { - finished = true // Canceled - } - - // Check for cancel by position consumption. We can look on the Final pass of - // the existing pointer event because it comes after the Main pass we checked - // above. - val consumeCheck = awaitPointerEvent(PointerEventPass.Final) - if (consumeCheck.changes.fastAny { it.isConsumed }) { - finished = true - } - if (!event.isPointerUp(currentDown.id)) { - longPress = event.changes.fastFirstOrNull { it.id == currentDown.id } - } else { - val newPressed = event.changes.fastFirstOrNull { it.pressed } - if (newPressed != null) { - currentDown = newPressed - longPress = currentDown - } else { - // should technically never happen as we checked it above - finished = true - } - } - } - } - } - null - } catch (_: TimeoutCancellationException) { - longPress ?: initialDown - } -} - -private fun PointerEvent.isPointerUp(pointerId: PointerId): Boolean = - changes.fastFirstOrNull { it.id == pointerId }?.pressed != true - -// This value was determined using experiments and common sense. -// We can't use zero slop, because some hypothetical desktop/mobile devices can send -// pointer events with a very high precision (but I haven't encountered any that send -// events with less than 1px precision) -private val mouseSlop = 0.125.dp -private val defaultTouchSlop = 18.dp // The default touch slop on Android devices -private val mouseToTouchSlopRatio = mouseSlop / defaultTouchSlop - -// TODO(demin): consider this as part of ViewConfiguration class after we make *PointerSlop* -// functions public (see the comment at the top of the file). -// After it will be a public API, we should get rid of `touchSlop / 144` and return absolute -// value 0.125.dp.toPx(). It is not possible right now, because we can't access density. -private fun ViewConfiguration.pointerSlop(pointerType: PointerType): Float { - return when (pointerType) { - PointerType.Mouse -> touchSlop * mouseToTouchSlopRatio - else -> touchSlop - } -} \ 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 e9d6d07..e20fa59 100644 --- a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/Reorderable.kt +++ b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/Reorderable.kt @@ -15,69 +15,38 @@ */ package org.burnoutcrew.reorderable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.drag -import androidx.compose.foundation.gestures.forEachGesture import androidx.compose.ui.Modifier -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.pointerInput import androidx.compose.ui.input.pointer.positionChange -import androidx.compose.ui.util.fastFirstOrNull +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow fun Modifier.reorderable( state: ReorderableState<*> ) = then( - Modifier.pointerInput(Unit) { - forEachGesture { - val dragStart = state.interactions.receive() - val down = awaitPointerEventScope { - currentEvent.changes.fastFirstOrNull { it.id == dragStart.id } - } - if (down != null && state.onDragStart(down.position.x.toInt(), down.position.y.toInt())) { - dragStart.offset?.apply { - state.onDrag(x.toInt(), y.toInt()) + Modifier.onGloballyPositioned { state.layoutWindowPosition.value = it.positionInWindow()}.pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + + val dragResult = drag(down.id) { + if (state.draggingItemIndex != null){ + state.onDrag(it.positionChange().x.toInt(), it.positionChange().y.toInt()) + it.consume() } - detectDrag( - down.id, - onDragEnd = { - state.onDragCanceled() - }, - onDragCancel = { - state.onDragCanceled() - }, - onDrag = { change, dragAmount -> - change.consume() - state.onDrag(dragAmount.x.toInt(), dragAmount.y.toInt()) - }) } - } - }) -internal suspend fun PointerInputScope.detectDrag( - down: PointerId, - onDragEnd: () -> Unit = { }, - onDragCancel: () -> Unit = { }, - onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit, -) { - awaitPointerEventScope { - if ( - drag(down) { - onDrag(it, it.positionChange()) - it.consume() - } - ) { - // consume up if we quit drag gracefully with the up - currentEvent.changes.forEach { - if (it.changedToUp()) it.consume() + if (dragResult) { + // consume up if we quit drag gracefully with the up + currentEvent.changes.forEach { + if (it.changedToUp()) it.consume() + } } - onDragEnd() - } else { - onDragCancel() + + state.onDragCanceled() } } -} - -internal data class StartDrag(val id: PointerId, val offset: Offset? = null) \ No newline at end of file +) \ 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 index bbc95d2..346a6b6 100644 --- a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableItem.kt +++ b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableItem.kt @@ -15,14 +15,22 @@ */ package org.burnoutcrew.reorderable +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring 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.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemScope import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.zIndex @OptIn(ExperimentalFoundationApi::class) @@ -34,7 +42,15 @@ fun LazyItemScope.ReorderableItem( index: Int? = null, orientationLocked: Boolean = true, content: @Composable BoxScope.(isDragging: Boolean) -> Unit -) = ReorderableItem(reorderableState, key, modifier, Modifier.animateItemPlacement(), orientationLocked, index, content) +) = ReorderableItem( + reorderableState, + key, + modifier, + Modifier.animateItemPlacement(), + orientationLocked, + index, + content +) @OptIn(ExperimentalFoundationApi::class) @Composable @@ -44,7 +60,49 @@ fun LazyGridItemScope.ReorderableItem( modifier: Modifier = Modifier, index: Int? = null, content: @Composable BoxScope.(isDragging: Boolean) -> Unit -) = ReorderableItem(reorderableState, key, modifier, Modifier.animateItemPlacement(), false, index, content) +) = ReorderableItem( + reorderableState, + key, + modifier, + Modifier.animateItemPlacement(), + false, + index, + content +) + + + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LazyStaggeredGridItemScope.ReorderableItem( + reorderableState: ReorderableState<*>, + key: Any?, + modifier: Modifier = Modifier, + index: Int? = null, + content: @Composable BoxScope.(isDragging: Boolean) -> Unit +) = ReorderableItem( + reorderableState, + key, + modifier, + Modifier.animateDraggeableItemPlacement(), + false, + index, + content +) + +/** + * XXX LazyGridItemScope.Modifier.animateItemPlacement is missing from LazyStaggeredGridItemScope + * XXX Replace this when added to the compose library. + * + * @param animationSpec a finite animation that will be used to animate the item placement. + */ +@ExperimentalFoundationApi +fun Modifier.animateDraggeableItemPlacement( + animationSpec: FiniteAnimationSpec = spring( + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = IntOffset.VisibilityThreshold + ) +): Modifier = this @Composable fun ReorderableItem( @@ -66,8 +124,10 @@ fun ReorderableItem( Modifier .zIndex(1f) .graphicsLayer { - translationX = if (!orientationLocked || !state.isVerticalScroll) state.draggingItemLeft else 0f - translationY = if (!orientationLocked || state.isVerticalScroll) state.draggingItemTop else 0f + translationX = + if (!orientationLocked || !state.isVerticalScroll) state.draggingItemLeft else 0f + translationY = + if (!orientationLocked || state.isVerticalScroll) state.draggingItemTop else 0f } } else { val cancel = if (index != null) { @@ -78,8 +138,10 @@ fun ReorderableItem( 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 + translationX = + if (!orientationLocked || !state.isVerticalScroll) state.dragCancelledAnimation.offset.x else 0f + translationY = + if (!orientationLocked || state.isVerticalScroll) state.dragCancelledAnimation.offset.y else 0f } } else { defaultDraggingModifier @@ -88,4 +150,4 @@ fun ReorderableItem( 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 b803861..a496fc4 100644 --- a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableLazyGridState.kt +++ b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableLazyGridState.kt @@ -34,6 +34,7 @@ fun rememberReorderableLazyGridState( onMove: (ItemPosition, ItemPosition) -> Unit, gridState: LazyGridState = rememberLazyGridState(), canDragOver: ((draggedOver: ItemPosition, dragging: ItemPosition) -> Boolean)? = null, + onDragStart: ((startIndex: Int, x: Int, y: Int) -> (Unit))? = null, onDragEnd: ((startIndex: Int, endIndex: Int) -> (Unit))? = null, maxScrollPerFrame: Dp = 20.dp, dragCancelledAnimation: DragCancelledAnimation = SpringDragCancelledAnimation() @@ -41,7 +42,7 @@ fun rememberReorderableLazyGridState( val maxScroll = with(LocalDensity.current) { maxScrollPerFrame.toPx() } val scope = rememberCoroutineScope() val state = remember(gridState) { - ReorderableLazyGridState(gridState, scope, maxScroll, onMove, canDragOver, onDragEnd, dragCancelledAnimation) + ReorderableLazyGridState(gridState, scope, maxScroll, onMove, canDragOver, onDragStart, onDragEnd, dragCancelledAnimation) } LaunchedEffect(state) { state.visibleItemsChanged() @@ -63,9 +64,10 @@ class ReorderableLazyGridState( maxScrollPerFrame: Float, onMove: (fromIndex: ItemPosition, toIndex: ItemPosition) -> (Unit), canDragOver: ((draggedOver: ItemPosition, dragging: ItemPosition) -> Boolean)? = null, + onDragStart: ((startIndex: Int, x: Int, y: Int) -> (Unit))? = null, onDragEnd: ((startIndex: Int, endIndex: Int) -> (Unit))? = null, dragCancelledAnimation: DragCancelledAnimation = SpringDragCancelledAnimation() -) : ReorderableState(scope, maxScrollPerFrame, onMove, canDragOver, onDragEnd, dragCancelledAnimation) { +) : ReorderableState(scope, maxScrollPerFrame, onMove, canDragOver, onDragStart, onDragEnd, dragCancelledAnimation) { override val isVerticalScroll: Boolean get() = gridState.layoutInfo.orientation == Orientation.Vertical override val LazyGridItemInfo.left: Int diff --git a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableLazyListState.kt b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableLazyListState.kt index a9dae61..f1f3a9c 100644 --- a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableLazyListState.kt +++ b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableLazyListState.kt @@ -38,6 +38,7 @@ fun rememberReorderableLazyListState( onMove: (ItemPosition, ItemPosition) -> Unit, listState: LazyListState = rememberLazyListState(), canDragOver: ((draggedOver: ItemPosition, dragging: ItemPosition) -> Boolean)? = null, + onDragStart: ((startIndex: Int, x: Int, y: Int) -> (Unit))? = null, onDragEnd: ((startIndex: Int, endIndex: Int) -> (Unit))? = null, maxScrollPerFrame: Dp = 20.dp, dragCancelledAnimation: DragCancelledAnimation = SpringDragCancelledAnimation() @@ -45,7 +46,7 @@ fun rememberReorderableLazyListState( val maxScroll = with(LocalDensity.current) { maxScrollPerFrame.toPx() } val scope = rememberCoroutineScope() val state = remember(listState) { - ReorderableLazyListState(listState, scope, maxScroll, onMove, canDragOver, onDragEnd, dragCancelledAnimation) + ReorderableLazyListState(listState, scope, maxScroll, onMove, canDragOver, onDragStart, onDragEnd, dragCancelledAnimation) } val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl LaunchedEffect(state) { @@ -73,6 +74,7 @@ class ReorderableLazyListState( maxScrollPerFrame: Float, onMove: (fromIndex: ItemPosition, toIndex: ItemPosition) -> (Unit), canDragOver: ((draggedOver: ItemPosition, dragging: ItemPosition) -> Boolean)? = null, + onDragStart: ((startIndex: Int, x: Int, y: Int) -> (Unit))? = null, onDragEnd: ((startIndex: Int, endIndex: Int) -> (Unit))? = null, dragCancelledAnimation: DragCancelledAnimation = SpringDragCancelledAnimation() ) : ReorderableState( @@ -80,6 +82,7 @@ class ReorderableLazyListState( maxScrollPerFrame, onMove, canDragOver, + onDragStart, onDragEnd, dragCancelledAnimation ) { diff --git a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableLazyStaggeredGridState.kt b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableLazyStaggeredGridState.kt new file mode 100644 index 0000000..88a922e --- /dev/null +++ b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableLazyStaggeredGridState.kt @@ -0,0 +1,169 @@ +/* + * 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.gestures.Orientation +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.lazy.grid.LazyGridItemInfo +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemInfo +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +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 + + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun rememberReorderableLazyHorizontalStaggeredGridState( + onMove: (ItemPosition, ItemPosition) -> Unit, + gridState: LazyStaggeredGridState = rememberLazyStaggeredGridState(), + canDragOver: ((draggedOver: ItemPosition, dragging: ItemPosition) -> Boolean)? = null, + onDragEnd: ((startIndex: Int, endIndex: Int) -> (Unit))? = null, + maxScrollPerFrame: Float = 20f, + dragCancelledAnimation: DragCancelledAnimation = SpringDragCancelledAnimation(), +) = rememberReorderableLazyStaggeredGridState( + onMove = onMove, + gridState = gridState, + canDragOver = canDragOver, + onDragEnd = onDragEnd, + maxScrollPerFrame = maxScrollPerFrame, + dragCancelledAnimation = dragCancelledAnimation, + orientation = Orientation.Horizontal +) + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun rememberReorderableLazyVerticalStaggeredGridState( + onMove: (ItemPosition, ItemPosition) -> Unit, + gridState: LazyStaggeredGridState = rememberLazyStaggeredGridState(), + canDragOver: ((draggedOver: ItemPosition, dragging: ItemPosition) -> Boolean)? = null, + onDragEnd: ((startIndex: Int, endIndex: Int) -> (Unit))? = null, + maxScrollPerFrame: Float = 20f, + dragCancelledAnimation: DragCancelledAnimation = SpringDragCancelledAnimation(), +) = rememberReorderableLazyStaggeredGridState( + onMove = onMove, + gridState = gridState, + canDragOver = canDragOver, + onDragEnd = onDragEnd, + maxScrollPerFrame = maxScrollPerFrame, + dragCancelledAnimation = dragCancelledAnimation, + orientation = Orientation.Vertical +) + + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun rememberReorderableLazyStaggeredGridState( + onMove: (ItemPosition, ItemPosition) -> Unit, + gridState: LazyStaggeredGridState = rememberLazyStaggeredGridState(), + canDragOver: ((draggedOver: ItemPosition, dragging: ItemPosition) -> Boolean)? = null, + onDragStart: ((startIndex: Int, x: Int, y: Int) -> (Unit))? = null, + onDragEnd: ((startIndex: Int, endIndex: Int) -> (Unit))? = null, + maxScrollPerFrame: Float = 20F, + dragCancelledAnimation: DragCancelledAnimation = SpringDragCancelledAnimation(), + orientation: Orientation +): ReorderableLazyStaggeredGridState { + val maxScroll = with(LocalDensity.current) { maxScrollPerFrame } + val scope = rememberCoroutineScope() + val state = remember(gridState) { + ReorderableLazyStaggeredGridState( + gridState=gridState, + scope = scope, + maxScrollPerFrame = maxScrollPerFrame, + onMove = onMove, + onDragStart = onDragStart, + canDragOver = canDragOver, + onDragEnd = onDragEnd, + dragCancelledAnimation = dragCancelledAnimation, + orientation = orientation + ) + } + LaunchedEffect(state) { + state.visibleItemsChanged() + .collect { state.onDrag(0, 0) } + } + + LaunchedEffect(state) { + while (true) { + val diff = state.scrollChannel.receive() + gridState.scrollBy(diff) + } + } + return state +} + +@OptIn(ExperimentalFoundationApi::class) +class ReorderableLazyStaggeredGridState( + val gridState: LazyStaggeredGridState, + scope: CoroutineScope, + maxScrollPerFrame: Float, + onMove: (fromIndex: ItemPosition, toIndex: ItemPosition) -> (Unit), + canDragOver: ((draggedOver: ItemPosition, dragging: ItemPosition) -> Boolean)? = null, + onDragStart: ((startIndex: Int, x: Int, y: Int) -> (Unit))? = null, + onDragEnd: ((startIndex: Int, endIndex: Int) -> (Unit))? = null, + dragCancelledAnimation: DragCancelledAnimation = SpringDragCancelledAnimation(), + val orientation: Orientation +) : ReorderableState( + scope = scope, + maxScrollPerFrame = maxScrollPerFrame, + onMove = onMove, + onDragStart = onDragStart, + canDragOver = canDragOver, + onDragEnd = onDragEnd, + dragCancelledAnimation = dragCancelledAnimation +) { + override val isVerticalScroll: Boolean + get() = orientation == Orientation.Vertical // XXX gridState.isVertical is not accessible + override val LazyStaggeredGridItemInfo.left: Int + get() = offset.x + override val LazyStaggeredGridItemInfo.right: Int + get() = offset.x + size.width + override val LazyStaggeredGridItemInfo.top: Int + get() = offset.y + override val LazyStaggeredGridItemInfo.bottom: Int + get() = offset.y + size.height + override val LazyStaggeredGridItemInfo.width: Int + get() = size.width + override val LazyStaggeredGridItemInfo.height: Int + get() = size.height + override val LazyStaggeredGridItemInfo.itemIndex: Int + get() = index + override val LazyStaggeredGridItemInfo.itemKey: Any + get() = key + 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 scrollToItem(index: Int, offset: Int) { + gridState.scrollToItem(index, offset) + } +} diff --git a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableState.kt b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableState.kt index f4b5c6e..8afb5be 100644 --- a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableState.kt +++ b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableState.kt @@ -41,9 +41,12 @@ abstract class ReorderableState( private val maxScrollPerFrame: Float, private val onMove: (fromIndex: ItemPosition, toIndex: ItemPosition) -> (Unit), private val canDragOver: ((draggedOver: ItemPosition, dragging: ItemPosition) -> Boolean)?, + private val onDragStart: ((startIndex: Int, x: Int, y: Int) -> (Unit))? = null, private val onDragEnd: ((startIndex: Int, endIndex: Int) -> (Unit))?, val dragCancelledAnimation: DragCancelledAnimation ) { + var layoutWindowPosition = mutableStateOf(Offset.Zero) + var draggingItemIndex by mutableStateOf(null) private set val draggingItemKey: Any? @@ -61,16 +64,15 @@ abstract class ReorderableState( protected abstract val firstVisibleItemScrollOffset: Int protected abstract val viewportStartOffset: Int protected abstract val viewportEndOffset: Int - internal val interactions = Channel() internal val scrollChannel = Channel() val draggingItemLeft: Float - get() = draggingLayoutInfo?.let { item -> + get() = if(draggingItemKey!=null) draggingLayoutInfo?.let { item -> (selected?.left ?: 0) + draggingDelta.x - item.left - } ?: 0f + } ?: 0f else 0f val draggingItemTop: Float - get() = draggingLayoutInfo?.let { item -> + get() = if(draggingItemKey!=null) draggingLayoutInfo?.let { item -> (selected?.top ?: 0) + draggingDelta.y - item.top - } ?: 0f + } ?: 0f else 0f abstract val isVerticalScroll: Boolean private val draggingLayoutInfo: T? get() = visibleItemsInfo @@ -105,6 +107,7 @@ abstract class ReorderableState( ?.also { selected = it draggingItemIndex = it.itemIndex + onDragStart?.invoke(it.itemIndex, offsetX, offsetY) } != null }