From bb79e0bd86d1e58532d87c37b4f5b11a2e463ddd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Vesel=C3=BD?= Date: Mon, 24 Apr 2023 20:49:33 +0200 Subject: [PATCH 1/2] Compose 1.4, AGP 8.0, refactored forEachGesture with awaitEachGesture, refactored onDragStart detection --- android/build.gradle.kts | 12 +- build.gradle.kts | 8 +- gradle.properties | 6 +- gradle/wrapper/gradle-wrapper.properties | 2 +- .../burnoutcrew/reorderable/DetectReorder.kt | 68 ++++---- .../burnoutcrew/reorderable/DragGesture.kt | 165 ------------------ .../burnoutcrew/reorderable/Reorderable.kt | 79 ++++----- .../reorderable/ReorderableState.kt | 3 +- 8 files changed, 86 insertions(+), 257 deletions(-) delete mode 100644 reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/DragGesture.kt diff --git a/android/build.gradle.kts b/android/build.gradle.kts index dbb57f8..e2e0659 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -6,12 +6,12 @@ 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("androidx.compose.runtime:runtime:1.4.2") + implementation("androidx.compose.material:material:1.4.2") + implementation("androidx.activity:activity-compose:1.7.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.lifecycle:lifecycle-viewmodel-ktx:2.6.1") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1") implementation("androidx.navigation:navigation-compose:2.5.3") implementation("io.coil-kt:coil-compose:2.2.2") } @@ -38,7 +38,7 @@ android { } kotlinOptions { - jvmTarget = "1.8" + jvmTarget = "11" } namespace = "org.burnoutcrew.android" } \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index d7710e3..e262feb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,9 +1,9 @@ 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.0.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 { diff --git a/gradle.properties b/gradle.properties index 5a66a00..a61d528 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,4 +15,8 @@ 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 + +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..da1db5f 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.0-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..9bbf7e1 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) { pointerId -> + awaitLongPressOrCancellation(pointerId) +} + + +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..29f246f 100644 --- a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/Reorderable.kt +++ b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/Reorderable.kt @@ -15,69 +15,60 @@ */ 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.AwaitPointerEventScope 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) + + detectDrag( + down.id, + onDragEnd = state::onDragCanceled, + onDragCancel = state::onDragCanceled, + onDrag = { event, amount -> + if (state.draggingItemIndex != null){ + state.onDrag(amount.x.toInt(), amount.y.toInt()) + event.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( +internal suspend fun AwaitPointerEventScope.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() - } - onDragEnd() - } else { - onDragCancel() + if ( + drag(down) { + onDrag(it, it.positionChange()) } + ) { + // consume up if we quit drag gracefully with the up + currentEvent.changes.forEach { + if (it.changedToUp()) it.consume() + } + onDragEnd() + } else { + onDragCancel() } -} - -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/ReorderableState.kt b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableState.kt index f4b5c6e..44a954a 100644 --- a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableState.kt +++ b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableState.kt @@ -44,6 +44,8 @@ abstract class ReorderableState( 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,7 +63,6 @@ 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 -> From 9722e9bf26bf9ae71f1645d1000c4f621b2cffcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Vesel=C3=BD?= Date: Mon, 1 May 2023 10:42:07 +0200 Subject: [PATCH 2/2] Improve code readability --- .../burnoutcrew/reorderable/DetectReorder.kt | 4 +- .../burnoutcrew/reorderable/Reorderable.kt | 48 +++++-------------- 2 files changed, 15 insertions(+), 37 deletions(-) diff --git a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/DetectReorder.kt b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/DetectReorder.kt index 9bbf7e1..e875f6d 100644 --- a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/DetectReorder.kt +++ b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/DetectReorder.kt @@ -35,8 +35,8 @@ fun Modifier.detectReorder(state: ReorderableState<*>) = detect(state){ awaitDragOrCancellation(it) } -fun Modifier.detectReorderAfterLongPress(state: ReorderableState<*>) = detect(state) { pointerId -> - awaitLongPressOrCancellation(pointerId) +fun Modifier.detectReorderAfterLongPress(state: ReorderableState<*>) = detect(state) { + awaitLongPressOrCancellation(it) } diff --git a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/Reorderable.kt b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/Reorderable.kt index 29f246f..e20fa59 100644 --- a/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/Reorderable.kt +++ b/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/Reorderable.kt @@ -19,10 +19,6 @@ import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.drag import androidx.compose.ui.Modifier -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.changedToUp import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.positionChange @@ -36,39 +32,21 @@ fun Modifier.reorderable( awaitEachGesture { val down = awaitFirstDown(requireUnconsumed = false) - detectDrag( - down.id, - onDragEnd = state::onDragCanceled, - onDragCancel = state::onDragCanceled, - onDrag = { event, amount -> - if (state.draggingItemIndex != null){ - state.onDrag(amount.x.toInt(), amount.y.toInt()) - event.consume() - } + val dragResult = drag(down.id) { + if (state.draggingItemIndex != null){ + state.onDrag(it.positionChange().x.toInt(), it.positionChange().y.toInt()) + it.consume() } - ) + } - } - } -) + if (dragResult) { + // consume up if we quit drag gracefully with the up + currentEvent.changes.forEach { + if (it.changedToUp()) it.consume() + } + } -internal suspend fun AwaitPointerEventScope.detectDrag( - down: PointerId, - onDragEnd: () -> Unit = { }, - onDragCancel: () -> Unit = { }, - onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit, -) { - if ( - drag(down) { - onDrag(it, it.positionChange()) - } - ) { - // consume up if we quit drag gracefully with the up - currentEvent.changes.forEach { - if (it.changedToUp()) it.consume() + state.onDragCanceled() } - onDragEnd() - } else { - onDragCancel() } -} \ No newline at end of file +) \ No newline at end of file