diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml
index 5dfcdf4..e7498df 100644
--- a/.github/workflows/gradle.yml
+++ b/.github/workflows/gradle.yml
@@ -51,6 +51,13 @@ jobs:
- name: Gradle test
run: ./gradlew allTests
+ - name: Android instrumented tests
+ uses: reactivecircus/android-emulator-runner@v2
+ with:
+ api-level: 34
+ arch: x86_64
+ script: ./gradlew :compose-dnd:connectedDebugAndroidTest
+
deploy:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: build
diff --git a/compose-dnd/build.gradle.kts b/compose-dnd/build.gradle.kts
index e2f7b8b..0714564 100644
--- a/compose-dnd/build.gradle.kts
+++ b/compose-dnd/build.gradle.kts
@@ -59,20 +59,24 @@ kotlin {
iosSimulatorArm64()
sourceSets.commonMain.dependencies {
- implementation(compose.runtime)
- implementation(compose.foundation)
- implementation(compose.material)
+ implementation(libs.compose.foundation)
+ implementation(libs.compose.ui)
}
sourceSets.commonTest.dependencies {
implementation(kotlin("test"))
}
- @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
sourceSets.named("desktopTest").dependencies {
- implementation(compose.uiTest)
+ implementation(libs.compose.ui.test)
implementation(compose.desktop.currentOs)
}
+
+ sourceSets.androidInstrumentedTest.dependencies {
+ implementation(libs.compose.ui.test)
+ implementation(libs.androidx.ui.test.junit4)
+ implementation(libs.androidx.runner)
+ }
}
android {
@@ -85,6 +89,7 @@ android {
minSdk = libs.versions.android.minSdk
.get()
.toInt()
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
diff --git a/compose-dnd/src/androidInstrumentedTest/AndroidManifest.xml b/compose-dnd/src/androidInstrumentedTest/AndroidManifest.xml
new file mode 100644
index 0000000..0e00a9c
--- /dev/null
+++ b/compose-dnd/src/androidInstrumentedTest/AndroidManifest.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
diff --git a/compose-dnd/src/androidInstrumentedTest/kotlin/com/mohamedrejeb/compose/dnd/CrossListDragTest.kt b/compose-dnd/src/androidInstrumentedTest/kotlin/com/mohamedrejeb/compose/dnd/CrossListDragTest.kt
new file mode 100644
index 0000000..d716dce
--- /dev/null
+++ b/compose-dnd/src/androidInstrumentedTest/kotlin/com/mohamedrejeb/compose/dnd/CrossListDragTest.kt
@@ -0,0 +1,296 @@
+/*
+ * Copyright 2025, Mohamed Ben Rejeb and the Compose Dnd project contributors
+ *
+ * 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 com.mohamedrejeb.compose.dnd
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.test.runComposeUiTest
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.dp
+import com.mohamedrejeb.compose.dnd.annotation.ExperimentalDndApi
+import com.mohamedrejeb.compose.dnd.drag.DropStrategy
+import com.mohamedrejeb.compose.dnd.drag.isDragging
+import com.mohamedrejeb.compose.dnd.reorder.reorderableItem
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+@OptIn(ExperimentalTestApi::class, ExperimentalDndApi::class)
+class CrossListDragTest {
+
+ private data class Item(val id: String)
+
+ /**
+ * Two side-by-side LazyColumns sharing one DragAndDropState.
+ * Drag an item from the left column to the right column.
+ */
+ @Test
+ fun dragItem_fromLeftColumn_toRightColumn() = runComposeUiTest {
+ var leftItems by mutableStateOf(listOf(Item("L1"), Item("L2")))
+ var rightItems by mutableStateOf(listOf(Item("R1"), Item("R2")))
+ var density = Density(1f)
+
+ setContent {
+ density = LocalDensity.current
+ val dndState = rememberDragAndDropState- ()
+
+ DragAndDropContainer(
+ state = dndState,
+ modifier = Modifier.width(400.dp).height(400.dp),
+ ) {
+ Row {
+ // Left column (200dp wide)
+ LazyColumn(
+ modifier = Modifier.width(200.dp).fillMaxHeight(),
+ ) {
+ items(leftItems, key = { it.id }) { item ->
+ val isDragging = dndState.isDragging(item.id)
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ .graphicsLayer { alpha = if (isDragging) 0f else 1f }
+ .reorderableItem(
+ key = item.id,
+ data = item,
+ state = dndState,
+ dropStrategy = DropStrategy.CenterDistance,
+ onDragEnter = { state ->
+ val draggedItem = state.data
+ if (draggedItem.id == item.id) return@reorderableItem
+ val targetIdx = leftItems.indexOfFirst { it.id == item.id }
+ if (targetIdx != -1) {
+ leftItems = leftItems
+ .filter { it.id != draggedItem.id }
+ .toMutableList()
+ .apply { add(targetIdx.coerceAtMost(size), draggedItem) }
+ rightItems = rightItems.filter { it.id != draggedItem.id }
+ }
+ },
+ draggableContent = {
+ Box(Modifier.fillMaxWidth().height(100.dp))
+ },
+ )
+ .testTag("item-${item.id}"),
+ )
+ }
+ }
+
+ // Right column (200dp wide)
+ LazyColumn(
+ modifier = Modifier.width(200.dp).fillMaxHeight(),
+ ) {
+ items(rightItems, key = { it.id }) { item ->
+ val isDragging = dndState.isDragging(item.id)
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ .graphicsLayer { alpha = if (isDragging) 0f else 1f }
+ .reorderableItem(
+ key = item.id,
+ data = item,
+ state = dndState,
+ dropStrategy = DropStrategy.CenterDistance,
+ onDragEnter = { state ->
+ val draggedItem = state.data
+ if (draggedItem.id == item.id) return@reorderableItem
+ val targetIdx = rightItems.indexOfFirst { it.id == item.id }
+ if (targetIdx != -1) {
+ rightItems = rightItems
+ .filter { it.id != draggedItem.id }
+ .toMutableList()
+ .apply { add(targetIdx.coerceAtMost(size), draggedItem) }
+ leftItems = leftItems.filter { it.id != draggedItem.id }
+ }
+ },
+ draggableContent = {
+ Box(Modifier.fillMaxWidth().height(100.dp))
+ },
+ )
+ .testTag("item-${item.id}"),
+ )
+ }
+ }
+ }
+ }
+ }
+
+ waitForIdle()
+
+ // Verify initial state
+ assertEquals(listOf("L1", "L2"), leftItems.map { it.id })
+ assertEquals(listOf("R1", "R2"), rightItems.map { it.id })
+
+ // Drag L1 from left column to right column (200dp to the right, onto R1)
+ val horizontalPx = with(density) { 200.dp.toPx() }
+
+ onNodeWithTag("item-L1").performTouchInput {
+ immediateDrag(
+ start = center,
+ end = Offset(center.x + horizontalPx, center.y),
+ )
+ up()
+ }
+
+ waitForIdle()
+
+ // L1 should have moved to the right column
+ assertTrue(
+ "L1 should no longer be in left column",
+ leftItems.none { it.id == "L1" },
+ )
+ assertTrue(
+ "L1 should be in right column",
+ rightItems.any { it.id == "L1" },
+ )
+ }
+
+ /**
+ * Drag an item within the same column (reorder) and verify
+ * the other column is unaffected.
+ */
+ @Test
+ fun dragItem_withinSameColumn_otherColumnUnaffected() = runComposeUiTest {
+ var leftItems by mutableStateOf(listOf(Item("L1"), Item("L2"), Item("L3")))
+ var rightItems by mutableStateOf(listOf(Item("R1"), Item("R2")))
+ var density = Density(1f)
+
+ setContent {
+ density = LocalDensity.current
+ val dndState = rememberDragAndDropState
- ()
+
+ DragAndDropContainer(
+ state = dndState,
+ modifier = Modifier.width(400.dp).height(400.dp),
+ ) {
+ Row {
+ LazyColumn(
+ modifier = Modifier.width(200.dp).fillMaxHeight(),
+ ) {
+ items(leftItems, key = { it.id }) { item ->
+ val isDragging = dndState.isDragging(item.id)
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ .graphicsLayer { alpha = if (isDragging) 0f else 1f }
+ .reorderableItem(
+ key = item.id,
+ data = item,
+ state = dndState,
+ dropStrategy = DropStrategy.CenterDistance,
+ onDragEnter = { state ->
+ val draggedItem = state.data
+ if (draggedItem.id == item.id) return@reorderableItem
+ val targetIdx = leftItems.indexOfFirst { it.id == item.id }
+ if (targetIdx != -1) {
+ leftItems = leftItems
+ .filter { it.id != draggedItem.id }
+ .toMutableList()
+ .apply { add(targetIdx.coerceAtMost(size), draggedItem) }
+ rightItems = rightItems.filter { it.id != draggedItem.id }
+ }
+ },
+ draggableContent = {
+ Box(Modifier.fillMaxWidth().height(100.dp))
+ },
+ )
+ .testTag("item-${item.id}"),
+ )
+ }
+ }
+
+ LazyColumn(
+ modifier = Modifier.width(200.dp).fillMaxHeight(),
+ ) {
+ items(rightItems, key = { it.id }) { item ->
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ .reorderableItem(
+ key = item.id,
+ data = item,
+ state = dndState,
+ dropStrategy = DropStrategy.CenterDistance,
+ onDragEnter = { state ->
+ val draggedItem = state.data
+ if (draggedItem.id == item.id) return@reorderableItem
+ val targetIdx = rightItems.indexOfFirst { it.id == item.id }
+ if (targetIdx != -1) {
+ rightItems = rightItems
+ .filter { it.id != draggedItem.id }
+ .toMutableList()
+ .apply { add(targetIdx.coerceAtMost(size), draggedItem) }
+ leftItems = leftItems.filter { it.id != draggedItem.id }
+ }
+ },
+ draggableContent = {
+ Box(Modifier.fillMaxWidth().height(100.dp))
+ },
+ )
+ .testTag("item-${item.id}"),
+ )
+ }
+ }
+ }
+ }
+ }
+
+ waitForIdle()
+
+ // Drag L1 down onto L2 (reorder within left column)
+ val distancePx = with(density) { 100.dp.toPx() }
+
+ onNodeWithTag("item-L1").performTouchInput {
+ immediateDrag(
+ start = center,
+ end = Offset(center.x, center.y + distancePx),
+ )
+ up()
+ }
+
+ waitForIdle()
+
+ // Left column should be reordered
+ assertEquals("L2 should be first in left", "L2", leftItems[0].id)
+ assertEquals("L1 should be second in left", "L1", leftItems[1].id)
+
+ // Right column should be unchanged
+ assertEquals("Right column should be unchanged", listOf("R1", "R2"), rightItems.map { it.id })
+ }
+}
diff --git a/compose-dnd/src/androidInstrumentedTest/kotlin/com/mohamedrejeb/compose/dnd/DragAxisTest.kt b/compose-dnd/src/androidInstrumentedTest/kotlin/com/mohamedrejeb/compose/dnd/DragAxisTest.kt
new file mode 100644
index 0000000..66810d8
--- /dev/null
+++ b/compose-dnd/src/androidInstrumentedTest/kotlin/com/mohamedrejeb/compose/dnd/DragAxisTest.kt
@@ -0,0 +1,227 @@
+/*
+ * Copyright 2025, Mohamed Ben Rejeb and the Compose Dnd project contributors
+ *
+ * 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 com.mohamedrejeb.compose.dnd
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.test.runComposeUiTest
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.dp
+import com.mohamedrejeb.compose.dnd.drag.DragAxis
+import com.mohamedrejeb.compose.dnd.drag.DraggableItem
+import com.mohamedrejeb.compose.dnd.drop.dropTarget
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+@OptIn(ExperimentalTestApi::class)
+class DragAxisTest {
+
+ @Test
+ fun verticalAxis_onlyAllowsVerticalDrag_dropsOnVerticalTarget() = runComposeUiTest {
+ var droppedVertical = false
+ var density = Density(1f)
+
+ setContent {
+ density = LocalDensity.current
+ val state = rememberDragAndDropState()
+
+ DragAndDropContainer(
+ state = state,
+ modifier = Modifier.size(400.dp),
+ ) {
+ Column {
+ DraggableItem(
+ state = state,
+ key = "item",
+ data = "test",
+ dragAxis = DragAxis.Vertical,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ .testTag("draggable"),
+ ) {
+ Box(Modifier.fillMaxWidth().height(100.dp))
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(200.dp)
+ .dropTarget(
+ key = "target-below",
+ state = state,
+ onDrop = { droppedVertical = true },
+ ),
+ )
+ }
+ }
+ }
+
+ waitForIdle()
+
+ val distancePx = with(density) { 200.dp.toPx() }
+
+ // Drag diagonally — only vertical component should apply
+ onNodeWithTag("draggable").performTouchInput {
+ immediateDrag(
+ start = center,
+ end = Offset(center.x + distancePx, center.y + distancePx),
+ )
+ up()
+ }
+
+ waitForIdle()
+ assertTrue("Should drop on vertical target even with diagonal gesture", droppedVertical)
+ }
+
+ @Test
+ fun horizontalAxis_verticalDragComponent_isZeroed() = runComposeUiTest {
+ var hoveredKey: Any? = null
+ var density = Density(1f)
+
+ setContent {
+ density = LocalDensity.current
+ val state = rememberDragAndDropState()
+
+ DragAndDropContainer(
+ state = state,
+ modifier = Modifier.size(400.dp),
+ ) {
+ hoveredKey = state.hoveredDropTargetKey
+
+ Column {
+ DraggableItem(
+ state = state,
+ key = "item",
+ data = "test",
+ dragAxis = DragAxis.Horizontal,
+ modifier = Modifier
+ .width(100.dp)
+ .height(100.dp)
+ .testTag("draggable"),
+ ) {
+ Box(Modifier.width(100.dp).height(100.dp))
+ }
+
+ // Target below — horizontal drag should not reach it
+ // because vertical component is zeroed
+ Box(Modifier.height(50.dp)) // gap
+ Box(
+ modifier = Modifier
+ .width(100.dp)
+ .height(100.dp)
+ .dropTarget(
+ key = "target-below",
+ state = state,
+ ),
+ )
+ }
+ }
+ }
+
+ waitForIdle()
+
+ val distancePx = with(density) { 200.dp.toPx() }
+
+ // Drag purely downward — horizontal axis zeroes Y, so item stays in place
+ onNodeWithTag("draggable").performTouchInput {
+ immediateDrag(
+ start = center,
+ end = Offset(center.x, center.y + distancePx),
+ )
+ }
+
+ waitForIdle()
+ // The item didn't actually move vertically, so it can't reach the target
+ assertTrue(
+ "Should NOT hover target-below with horizontal axis and vertical drag",
+ hoveredKey == null || hoveredKey != "target-below",
+ )
+
+ onNodeWithTag("draggable").performTouchInput { up() }
+ waitForIdle()
+ }
+
+ @Test
+ fun horizontalAxis_dropsOnHorizontalTarget() = runComposeUiTest {
+ var droppedRight = false
+ var density = Density(1f)
+
+ setContent {
+ density = LocalDensity.current
+ val state = rememberDragAndDropState()
+
+ DragAndDropContainer(
+ state = state,
+ modifier = Modifier.size(400.dp),
+ ) {
+ Row {
+ DraggableItem(
+ state = state,
+ key = "item",
+ data = "test",
+ dragAxis = DragAxis.Horizontal,
+ modifier = Modifier
+ .width(100.dp)
+ .height(100.dp)
+ .testTag("draggable"),
+ ) {
+ Box(Modifier.width(100.dp).height(100.dp))
+ }
+
+ Box(
+ modifier = Modifier
+ .width(200.dp)
+ .height(100.dp)
+ .dropTarget(
+ key = "target-right",
+ state = state,
+ onDrop = { droppedRight = true },
+ ),
+ )
+ }
+ }
+ }
+
+ waitForIdle()
+
+ val distancePx = with(density) { 200.dp.toPx() }
+
+ onNodeWithTag("draggable").performTouchInput {
+ immediateDrag(
+ start = center,
+ end = Offset(center.x + distancePx, center.y),
+ )
+ up()
+ }
+
+ waitForIdle()
+ assertTrue("Should drop on horizontal target", droppedRight)
+ }
+}
diff --git a/compose-dnd/src/androidInstrumentedTest/kotlin/com/mohamedrejeb/compose/dnd/DragStateTest.kt b/compose-dnd/src/androidInstrumentedTest/kotlin/com/mohamedrejeb/compose/dnd/DragStateTest.kt
new file mode 100644
index 0000000..b015331
--- /dev/null
+++ b/compose-dnd/src/androidInstrumentedTest/kotlin/com/mohamedrejeb/compose/dnd/DragStateTest.kt
@@ -0,0 +1,313 @@
+/*
+ * Copyright 2025, Mohamed Ben Rejeb and the Compose Dnd project contributors
+ *
+ * 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 com.mohamedrejeb.compose.dnd
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.test.runComposeUiTest
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.dp
+import com.mohamedrejeb.compose.dnd.drag.DraggableItem
+import com.mohamedrejeb.compose.dnd.drag.isDragging
+import com.mohamedrejeb.compose.dnd.drop.dropTarget
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+@OptIn(ExperimentalTestApi::class)
+class DragStateTest {
+
+ @Test
+ fun isActiveDrag_trueWhileDragging_falseAfterRelease() = runComposeUiTest {
+ var isActive = false
+ var density = Density(1f)
+
+ setContent {
+ density = LocalDensity.current
+ val state = rememberDragAndDropState()
+ isActive = state.isActiveDrag
+
+ DragAndDropContainer(
+ state = state,
+ modifier = Modifier.size(400.dp),
+ ) {
+ DraggableItem(
+ state = state,
+ key = "item",
+ data = "test",
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ .testTag("draggable"),
+ ) {
+ Box(Modifier.fillMaxWidth().height(100.dp))
+ }
+ }
+ }
+
+ waitForIdle()
+ assertFalse("Should not be dragging initially", isActive)
+
+ val distancePx = with(density) { 50.dp.toPx() }
+
+ onNodeWithTag("draggable").performTouchInput {
+ immediateDrag(
+ start = center,
+ end = Offset(center.x, center.y + distancePx),
+ )
+ }
+ waitForIdle()
+ assertTrue("Should be dragging while pointer is held", isActive)
+
+ onNodeWithTag("draggable").performTouchInput { up() }
+ waitForIdle()
+ assertFalse("Should not be dragging after release", isActive)
+ }
+
+ @Test
+ fun isDragging_trueForDraggedItem_falseForOthers() = runComposeUiTest {
+ var isDraggingA = false
+ var isDraggingB = false
+ var density = Density(1f)
+
+ setContent {
+ density = LocalDensity.current
+ val state = rememberDragAndDropState()
+ isDraggingA = state.isDragging("A")
+ isDraggingB = state.isDragging("B")
+
+ DragAndDropContainer(
+ state = state,
+ modifier = Modifier.size(400.dp),
+ ) {
+ Column {
+ DraggableItem(
+ state = state,
+ key = "A",
+ data = "item-a",
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ .testTag("item-A"),
+ ) {
+ Box(Modifier.fillMaxWidth().height(100.dp))
+ }
+
+ DraggableItem(
+ state = state,
+ key = "B",
+ data = "item-b",
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ .testTag("item-B"),
+ ) {
+ Box(Modifier.fillMaxWidth().height(100.dp))
+ }
+ }
+ }
+ }
+
+ waitForIdle()
+ assertFalse("A should not be dragging initially", isDraggingA)
+ assertFalse("B should not be dragging initially", isDraggingB)
+
+ val distancePx = with(density) { 30.dp.toPx() }
+
+ // Drag item A
+ onNodeWithTag("item-A").performTouchInput {
+ immediateDrag(
+ start = center,
+ end = Offset(center.x, center.y + distancePx),
+ )
+ }
+ waitForIdle()
+
+ assertTrue("A should be dragging", isDraggingA)
+ assertFalse("B should NOT be dragging", isDraggingB)
+
+ onNodeWithTag("item-A").performTouchInput { up() }
+ waitForIdle()
+ }
+
+ @Test
+ fun enabled_false_preventsDrag() = runComposeUiTest {
+ var isActive = false
+ var density = Density(1f)
+
+ setContent {
+ density = LocalDensity.current
+ val state = rememberDragAndDropState()
+ isActive = state.isActiveDrag
+
+ DragAndDropContainer(
+ state = state,
+ modifier = Modifier.size(400.dp),
+ ) {
+ DraggableItem(
+ state = state,
+ key = "item",
+ data = "test",
+ enabled = false,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ .testTag("draggable"),
+ ) {
+ Box(Modifier.fillMaxWidth().height(100.dp))
+ }
+ }
+ }
+
+ waitForIdle()
+
+ val distancePx = with(density) { 50.dp.toPx() }
+
+ onNodeWithTag("draggable").performTouchInput {
+ immediateDrag(
+ start = center,
+ end = Offset(center.x, center.y + distancePx),
+ )
+ }
+ waitForIdle()
+
+ assertFalse("Drag should not start when enabled=false", isActive)
+
+ onNodeWithTag("draggable").performTouchInput { up() }
+ waitForIdle()
+ }
+
+ @Test
+ fun draggedItemData_matchesDraggedItem() = runComposeUiTest {
+ var draggedData: String? = null
+ var draggedKey: Any? = null
+ var density = Density(1f)
+
+ setContent {
+ density = LocalDensity.current
+ val state = rememberDragAndDropState()
+ draggedData = state.draggedItem?.data
+ draggedKey = state.draggedItem?.key
+
+ DragAndDropContainer(
+ state = state,
+ modifier = Modifier.size(400.dp),
+ ) {
+ DraggableItem(
+ state = state,
+ key = "my-key",
+ data = "my-data",
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ .testTag("draggable"),
+ ) {
+ Box(Modifier.fillMaxWidth().height(100.dp))
+ }
+ }
+ }
+
+ waitForIdle()
+ assertNull("No dragged data initially", draggedData)
+ assertNull("No dragged key initially", draggedKey)
+
+ val distancePx = with(density) { 50.dp.toPx() }
+
+ onNodeWithTag("draggable").performTouchInput {
+ immediateDrag(
+ start = center,
+ end = Offset(center.x, center.y + distancePx),
+ )
+ }
+ waitForIdle()
+
+ assertEquals("Dragged data should match", "my-data", draggedData)
+ assertEquals("Dragged key should match", "my-key", draggedKey)
+
+ onNodeWithTag("draggable").performTouchInput { up() }
+ waitForIdle()
+ }
+
+ @Test
+ fun hoveredDropTargetKey_clearsAfterDragEnd() = runComposeUiTest {
+ var hoveredKey: Any? = "initial"
+ var density = Density(1f)
+
+ setContent {
+ density = LocalDensity.current
+ val state = rememberDragAndDropState()
+ hoveredKey = state.hoveredDropTargetKey
+
+ DragAndDropContainer(
+ state = state,
+ modifier = Modifier.size(400.dp),
+ ) {
+ Column {
+ DraggableItem(
+ state = state,
+ key = "item",
+ data = "test",
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ .testTag("draggable"),
+ ) {
+ Box(Modifier.fillMaxWidth().height(100.dp))
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(200.dp)
+ .dropTarget(key = "target", state = state),
+ )
+ }
+ }
+ }
+
+ waitForIdle()
+ assertNull("No hovered key initially", hoveredKey)
+
+ val distancePx = with(density) { 200.dp.toPx() }
+
+ // Drag onto target
+ onNodeWithTag("draggable").performTouchInput {
+ immediateDrag(
+ start = center,
+ end = Offset(center.x, center.y + distancePx),
+ )
+ }
+ waitForIdle()
+ assertEquals("Should hover target", "target", hoveredKey)
+
+ // Release
+ onNodeWithTag("draggable").performTouchInput { up() }
+ waitForIdle()
+ assertNull("Hovered key should clear after drop", hoveredKey)
+ }
+}
diff --git a/compose-dnd/src/androidInstrumentedTest/kotlin/com/mohamedrejeb/compose/dnd/DragToTargetTest.kt b/compose-dnd/src/androidInstrumentedTest/kotlin/com/mohamedrejeb/compose/dnd/DragToTargetTest.kt
new file mode 100644
index 0000000..b8a419f
--- /dev/null
+++ b/compose-dnd/src/androidInstrumentedTest/kotlin/com/mohamedrejeb/compose/dnd/DragToTargetTest.kt
@@ -0,0 +1,470 @@
+/*
+ * Copyright 2025, Mohamed Ben Rejeb and the Compose Dnd project contributors
+ *
+ * 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 com.mohamedrejeb.compose.dnd
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.test.runComposeUiTest
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.dp
+import com.mohamedrejeb.compose.dnd.drag.DraggableItem
+import com.mohamedrejeb.compose.dnd.drop.dropTarget
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+@OptIn(ExperimentalTestApi::class)
+class DragToTargetTest {
+
+ @Test
+ fun dragItem_toDropTarget_triggersOnDrop() = runComposeUiTest {
+ var dropped = false
+ var droppedData: Int? = null
+ var density = Density(1f)
+
+ setContent {
+ density = LocalDensity.current
+ val state = rememberDragAndDropState()
+
+ DragAndDropContainer(
+ state = state,
+ modifier = Modifier.size(400.dp),
+ ) {
+ Column {
+ DraggableItem(
+ state = state,
+ key = "item",
+ data = 42,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ .testTag("draggable"),
+ ) {
+ Box(Modifier.fillMaxWidth().height(100.dp))
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(200.dp)
+ .dropTarget(
+ key = "target",
+ state = state,
+ onDrop = {
+ dropped = true
+ droppedData = it.data
+ },
+ )
+ .testTag("target"),
+ )
+ }
+ }
+ }
+
+ waitForIdle()
+
+ val distancePx = with(density) { 200.dp.toPx() }
+
+ onNodeWithTag("draggable").performTouchInput {
+ immediateDrag(
+ start = center,
+ end = Offset(center.x, center.y + distancePx),
+ )
+ up()
+ }
+
+ waitForIdle()
+ assertTrue("onDrop should have been called", dropped)
+ assertEquals("Dropped data should be 42", 42, droppedData)
+ }
+
+ @Test
+ fun dragItem_released_outsideTarget_doesNotDrop() = runComposeUiTest {
+ var dropped = false
+ var density = Density(1f)
+
+ setContent {
+ density = LocalDensity.current
+ val state = rememberDragAndDropState()
+
+ DragAndDropContainer(
+ state = state,
+ modifier = Modifier.size(400.dp),
+ ) {
+ Column {
+ DraggableItem(
+ state = state,
+ key = "item",
+ data = 1,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ .testTag("draggable"),
+ ) {
+ Box(Modifier.fillMaxWidth().height(100.dp))
+ }
+
+ // Gap with no drop target
+ Box(Modifier.fillMaxWidth().height(200.dp))
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ .dropTarget(
+ key = "target",
+ state = state,
+ onDrop = { dropped = true },
+ ),
+ )
+ }
+ }
+ }
+
+ waitForIdle()
+
+ // Drag only 50dp — stays within the gap, not reaching the target
+ val distancePx = with(density) { 50.dp.toPx() }
+
+ onNodeWithTag("draggable").performTouchInput {
+ immediateDrag(
+ start = center,
+ end = Offset(center.x, center.y + distancePx),
+ )
+ up()
+ }
+
+ waitForIdle()
+ assertFalse("onDrop should NOT be called when not over target", dropped)
+ }
+
+ @Test
+ fun dragItem_onDragEnter_calledWhenHoveringTarget() = runComposeUiTest {
+ var enterCount = 0
+ var density = Density(1f)
+
+ setContent {
+ density = LocalDensity.current
+ val state = rememberDragAndDropState()
+
+ DragAndDropContainer(
+ state = state,
+ modifier = Modifier.size(400.dp),
+ ) {
+ Column {
+ DraggableItem(
+ state = state,
+ key = "item",
+ data = 1,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ .testTag("draggable"),
+ ) {
+ Box(Modifier.fillMaxWidth().height(100.dp))
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(200.dp)
+ .dropTarget(
+ key = "target",
+ state = state,
+ onDragEnter = { enterCount++ },
+ ),
+ )
+ }
+ }
+ }
+
+ waitForIdle()
+
+ val distancePx = with(density) { 200.dp.toPx() }
+
+ onNodeWithTag("draggable").performTouchInput {
+ immediateDrag(
+ start = center,
+ end = Offset(center.x, center.y + distancePx),
+ )
+ }
+
+ waitForIdle()
+ assertTrue("onDragEnter should have been called", enterCount > 0)
+
+ onNodeWithTag("draggable").performTouchInput { up() }
+ waitForIdle()
+ }
+
+ @Test
+ fun dragItem_onDragExit_calledWhenLeavingTarget() = runComposeUiTest {
+ var exitCount = 0
+ var density = Density(1f)
+
+ setContent {
+ density = LocalDensity.current
+ val state = rememberDragAndDropState()
+
+ DragAndDropContainer(
+ state = state,
+ modifier = Modifier.size(400.dp),
+ ) {
+ Column {
+ DraggableItem(
+ state = state,
+ key = "item",
+ data = 1,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ .testTag("draggable"),
+ ) {
+ Box(Modifier.fillMaxWidth().height(100.dp))
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ .dropTarget(
+ key = "target",
+ state = state,
+ onDragExit = { exitCount++ },
+ ),
+ )
+
+ // Area below the target
+ Box(Modifier.fillMaxWidth().height(200.dp))
+ }
+ }
+ }
+
+ waitForIdle()
+
+ // Drag through the target and past it
+ val distancePx = with(density) { 300.dp.toPx() }
+
+ onNodeWithTag("draggable").performTouchInput {
+ immediateDrag(
+ start = center,
+ end = Offset(center.x, center.y + distancePx),
+ )
+ }
+
+ waitForIdle()
+ assertTrue("onDragExit should have been called", exitCount > 0)
+
+ onNodeWithTag("draggable").performTouchInput { up() }
+ waitForIdle()
+ }
+
+ @Test
+ fun dragItem_hoveredDropTargetKey_updatesOnHover() = runComposeUiTest {
+ var hoveredKey: Any? = null
+ var density = Density(1f)
+
+ setContent {
+ density = LocalDensity.current
+ val state = rememberDragAndDropState()
+
+ DragAndDropContainer(
+ state = state,
+ modifier = Modifier.size(400.dp),
+ ) {
+ hoveredKey = state.hoveredDropTargetKey
+
+ Column {
+ DraggableItem(
+ state = state,
+ key = "item",
+ data = 1,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ .testTag("draggable"),
+ ) {
+ Box(Modifier.fillMaxWidth().height(100.dp))
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(200.dp)
+ .dropTarget(
+ key = "my-target",
+ state = state,
+ ),
+ )
+ }
+ }
+ }
+
+ waitForIdle()
+
+ val distancePx = with(density) { 200.dp.toPx() }
+
+ onNodeWithTag("draggable").performTouchInput {
+ immediateDrag(
+ start = center,
+ end = Offset(center.x, center.y + distancePx),
+ )
+ }
+
+ waitForIdle()
+ assertEquals("Should be hovering over my-target", "my-target", hoveredKey)
+
+ onNodeWithTag("draggable").performTouchInput { up() }
+ waitForIdle()
+ }
+
+ @Test
+ fun dragItem_canDropFalse_doesNotAcceptDrop() = runComposeUiTest {
+ var dropped = false
+ var density = Density(1f)
+
+ setContent {
+ density = LocalDensity.current
+ val state = rememberDragAndDropState()
+
+ DragAndDropContainer(
+ state = state,
+ modifier = Modifier.size(400.dp),
+ ) {
+ Column {
+ DraggableItem(
+ state = state,
+ key = "item",
+ data = 1,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ .testTag("draggable"),
+ ) {
+ Box(Modifier.fillMaxWidth().height(100.dp))
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(200.dp)
+ .dropTarget(
+ key = "target",
+ state = state,
+ canDrop = false,
+ onDrop = { dropped = true },
+ ),
+ )
+ }
+ }
+ }
+
+ waitForIdle()
+
+ val distancePx = with(density) { 200.dp.toPx() }
+
+ onNodeWithTag("draggable").performTouchInput {
+ immediateDrag(
+ start = center,
+ end = Offset(center.x, center.y + distancePx),
+ )
+ up()
+ }
+
+ waitForIdle()
+ assertFalse("onDrop should NOT be called when canDrop=false", dropped)
+ }
+
+ @Test
+ fun dragItem_dropTargetRestriction_skipsDisallowedTarget() = runComposeUiTest {
+ var droppedOnB = false
+ var hoveredKey: Any? = null
+ var density = Density(1f)
+
+ setContent {
+ density = LocalDensity.current
+ val state = rememberDragAndDropState()
+
+ DragAndDropContainer(
+ state = state,
+ modifier = Modifier.size(400.dp),
+ ) {
+ hoveredKey = state.hoveredDropTargetKey
+
+ Column {
+ // Item restricted to "target-a" only
+ DraggableItem(
+ state = state,
+ key = "item",
+ data = 1,
+ dropTargets = listOf("target-a"),
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ .testTag("draggable"),
+ ) {
+ Box(Modifier.fillMaxWidth().height(100.dp))
+ }
+
+ // Target B — item should NOT interact with it
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(200.dp)
+ .dropTarget(
+ key = "target-b",
+ state = state,
+ onDrop = { droppedOnB = true },
+ ),
+ )
+ }
+ }
+ }
+
+ waitForIdle()
+
+ val distancePx = with(density) { 200.dp.toPx() }
+
+ // Drag onto target-b
+ onNodeWithTag("draggable").performTouchInput {
+ immediateDrag(
+ start = center,
+ end = Offset(center.x, center.y + distancePx),
+ )
+ }
+
+ waitForIdle()
+ // Item shouldn't hover target-b because it's not in dropTargets
+ assertTrue(
+ "Should NOT hover target-b (restricted to target-a only)",
+ hoveredKey != "target-b",
+ )
+
+ onNodeWithTag("draggable").performTouchInput { up() }
+ waitForIdle()
+ assertFalse("Should NOT drop on target-b", droppedOnB)
+ }
+}
diff --git a/compose-dnd/src/androidInstrumentedTest/kotlin/com/mohamedrejeb/compose/dnd/DropStrategyTest.kt b/compose-dnd/src/androidInstrumentedTest/kotlin/com/mohamedrejeb/compose/dnd/DropStrategyTest.kt
new file mode 100644
index 0000000..c3c1661
--- /dev/null
+++ b/compose-dnd/src/androidInstrumentedTest/kotlin/com/mohamedrejeb/compose/dnd/DropStrategyTest.kt
@@ -0,0 +1,353 @@
+/*
+ * Copyright 2025, Mohamed Ben Rejeb and the Compose Dnd project contributors
+ *
+ * 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 com.mohamedrejeb.compose.dnd
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.test.runComposeUiTest
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.dp
+import com.mohamedrejeb.compose.dnd.drag.DraggableItem
+import com.mohamedrejeb.compose.dnd.drag.DropStrategy
+import com.mohamedrejeb.compose.dnd.drop.dropTarget
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+@OptIn(ExperimentalTestApi::class)
+class DropStrategyTest {
+
+ /**
+ * CenterDistance: when the dragged item's center is closer to target A's center,
+ * it should hover A even if it overlaps more with target B.
+ *
+ * Layout:
+ * [draggable 100x100] at (0,0)
+ * [target-small 100x50] at (0,100) ← center at y=125
+ * [target-large 100x200] at (0,150) ← center at y=250
+ *
+ * Drag down 80dp → item center at y=130, closer to target-small center (125)
+ * than target-large center (250).
+ */
+ @Test
+ fun centerDistance_selectsClosestCenter() = runComposeUiTest {
+ var hoveredKey: Any? = null
+ var density = Density(1f)
+
+ setContent {
+ density = LocalDensity.current
+ val state = rememberDragAndDropState()
+
+ DragAndDropContainer(
+ state = state,
+ modifier = Modifier.size(400.dp),
+ ) {
+ hoveredKey = state.hoveredDropTargetKey
+
+ Column {
+ DraggableItem(
+ state = state,
+ key = "item",
+ data = "test",
+ dropStrategy = DropStrategy.CenterDistance,
+ modifier = Modifier
+ .width(100.dp)
+ .height(100.dp)
+ .testTag("draggable"),
+ ) {
+ Box(Modifier.size(100.dp))
+ }
+
+ // Small target right below
+ Box(
+ modifier = Modifier
+ .width(100.dp)
+ .height(50.dp)
+ .dropTarget(key = "target-small", state = state),
+ )
+
+ // Large target further down
+ Box(
+ modifier = Modifier
+ .width(100.dp)
+ .height(200.dp)
+ .dropTarget(key = "target-large", state = state),
+ )
+ }
+ }
+ }
+
+ waitForIdle()
+
+ val distancePx = with(density) { 80.dp.toPx() }
+
+ onNodeWithTag("draggable").performTouchInput {
+ immediateDrag(
+ start = center,
+ end = Offset(center.x, center.y + distancePx),
+ )
+ }
+
+ waitForIdle()
+ assertEquals(
+ "CenterDistance should pick target-small (closer center)",
+ "target-small",
+ hoveredKey,
+ )
+
+ onNodeWithTag("draggable").performTouchInput { up() }
+ waitForIdle()
+ }
+
+ /**
+ * SurfacePercentage: when the dragged item overlaps more (as a %) with target A
+ * than target B, it should hover A.
+ *
+ * Layout:
+ * [draggable 100x100] at (0,0)
+ * [target-A 100x100] at (0,100)
+ * [target-B 100x100] at (0,200)
+ *
+ * Drag down 120dp → item occupies y=120..220
+ * Overlap with A (y=100..200): 80px → 80% of item
+ * Overlap with B (y=200..300): 20px → 20% of item
+ * → Should pick target-A
+ */
+ @Test
+ fun surfacePercentage_selectsLargestOverlapPercentage() = runComposeUiTest {
+ var hoveredKey: Any? = null
+ var density = Density(1f)
+
+ setContent {
+ density = LocalDensity.current
+ val state = rememberDragAndDropState()
+
+ DragAndDropContainer(
+ state = state,
+ modifier = Modifier.size(400.dp),
+ ) {
+ hoveredKey = state.hoveredDropTargetKey
+
+ Column {
+ DraggableItem(
+ state = state,
+ key = "item",
+ data = "test",
+ dropStrategy = DropStrategy.SurfacePercentage,
+ modifier = Modifier
+ .width(100.dp)
+ .height(100.dp)
+ .testTag("draggable"),
+ ) {
+ Box(Modifier.size(100.dp))
+ }
+
+ Box(
+ modifier = Modifier
+ .width(100.dp)
+ .height(100.dp)
+ .dropTarget(key = "target-A", state = state),
+ )
+
+ Box(
+ modifier = Modifier
+ .width(100.dp)
+ .height(100.dp)
+ .dropTarget(key = "target-B", state = state),
+ )
+ }
+ }
+ }
+
+ waitForIdle()
+
+ // Drag 120dp down — mostly overlapping target-A
+ val distancePx = with(density) { 120.dp.toPx() }
+
+ onNodeWithTag("draggable").performTouchInput {
+ immediateDrag(
+ start = center,
+ end = Offset(center.x, center.y + distancePx),
+ )
+ }
+
+ waitForIdle()
+ assertEquals(
+ "SurfacePercentage should pick target-A (larger overlap %)",
+ "target-A",
+ hoveredKey,
+ )
+
+ onNodeWithTag("draggable").performTouchInput { up() }
+ waitForIdle()
+ }
+
+ /**
+ * SurfacePercentage: when dragged past the midpoint between two targets,
+ * the hover should switch to the second target.
+ */
+ @Test
+ fun surfacePercentage_switchesWhenPastMidpoint() = runComposeUiTest {
+ var hoveredKey: Any? = null
+ var density = Density(1f)
+
+ setContent {
+ density = LocalDensity.current
+ val state = rememberDragAndDropState()
+
+ DragAndDropContainer(
+ state = state,
+ modifier = Modifier.size(400.dp),
+ ) {
+ hoveredKey = state.hoveredDropTargetKey
+
+ Column {
+ DraggableItem(
+ state = state,
+ key = "item",
+ data = "test",
+ dropStrategy = DropStrategy.SurfacePercentage,
+ modifier = Modifier
+ .width(100.dp)
+ .height(100.dp)
+ .testTag("draggable"),
+ ) {
+ Box(Modifier.size(100.dp))
+ }
+
+ Box(
+ modifier = Modifier
+ .width(100.dp)
+ .height(100.dp)
+ .dropTarget(key = "target-A", state = state),
+ )
+
+ Box(
+ modifier = Modifier
+ .width(100.dp)
+ .height(100.dp)
+ .dropTarget(key = "target-B", state = state),
+ )
+ }
+ }
+ }
+
+ waitForIdle()
+
+ // Drag 180dp — mostly overlapping target-B now
+ val distancePx = with(density) { 180.dp.toPx() }
+
+ onNodeWithTag("draggable").performTouchInput {
+ immediateDrag(
+ start = center,
+ end = Offset(center.x, center.y + distancePx),
+ )
+ }
+
+ waitForIdle()
+ assertEquals(
+ "SurfacePercentage should switch to target-B past midpoint",
+ "target-B",
+ hoveredKey,
+ )
+
+ onNodeWithTag("draggable").performTouchInput { up() }
+ waitForIdle()
+ }
+
+ /**
+ * zIndex: when two targets overlap, the one with higher zIndex wins.
+ */
+ @Test
+ fun zIndex_higherPriorityTargetWins() = runComposeUiTest {
+ var droppedKey: String? = null
+ var density = Density(1f)
+
+ setContent {
+ density = LocalDensity.current
+ val state = rememberDragAndDropState()
+
+ DragAndDropContainer(
+ state = state,
+ modifier = Modifier.size(400.dp),
+ ) {
+ Column {
+ DraggableItem(
+ state = state,
+ key = "item",
+ data = "test",
+ modifier = Modifier
+ .width(100.dp)
+ .height(100.dp)
+ .testTag("draggable"),
+ ) {
+ Box(Modifier.size(100.dp))
+ }
+
+ // Two overlapping targets at the same position
+ Box(modifier = Modifier.width(100.dp).height(200.dp)) {
+ Box(
+ modifier = Modifier
+ .size(200.dp)
+ .dropTarget(
+ key = "low-z",
+ state = state,
+ zIndex = 0f,
+ onDrop = { droppedKey = "low-z" },
+ ),
+ )
+ Box(
+ modifier = Modifier
+ .size(200.dp)
+ .dropTarget(
+ key = "high-z",
+ state = state,
+ zIndex = 1f,
+ onDrop = { droppedKey = "high-z" },
+ ),
+ )
+ }
+ }
+ }
+ }
+
+ waitForIdle()
+
+ val distancePx = with(density) { 200.dp.toPx() }
+
+ onNodeWithTag("draggable").performTouchInput {
+ immediateDrag(
+ start = center,
+ end = Offset(center.x, center.y + distancePx),
+ )
+ up()
+ }
+
+ waitForIdle()
+ assertEquals("Higher zIndex target should win", "high-z", droppedKey)
+ }
+}
diff --git a/compose-dnd/src/androidInstrumentedTest/kotlin/com/mohamedrejeb/compose/dnd/ReorderTest.kt b/compose-dnd/src/androidInstrumentedTest/kotlin/com/mohamedrejeb/compose/dnd/ReorderTest.kt
new file mode 100644
index 0000000..adef4d5
--- /dev/null
+++ b/compose-dnd/src/androidInstrumentedTest/kotlin/com/mohamedrejeb/compose/dnd/ReorderTest.kt
@@ -0,0 +1,282 @@
+/*
+ * Copyright 2025, Mohamed Ben Rejeb and the Compose Dnd project contributors
+ *
+ * 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 com.mohamedrejeb.compose.dnd
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.test.runComposeUiTest
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.dp
+import com.mohamedrejeb.compose.dnd.annotation.ExperimentalDndApi
+import com.mohamedrejeb.compose.dnd.drag.DropStrategy
+import com.mohamedrejeb.compose.dnd.drag.isDragging
+import com.mohamedrejeb.compose.dnd.reorder.reorderableItem
+import com.mohamedrejeb.compose.dnd.scroll.dragScrollPin
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+@OptIn(ExperimentalTestApi::class, ExperimentalDndApi::class)
+class ReorderTest {
+
+ private data class SizedItem(
+ val id: String,
+ val heightDp: Int,
+ )
+
+ /**
+ * Helper: sets up a LazyColumn with reorderable items and runs the [interaction].
+ */
+ private fun reorderTest(
+ initialItems: List,
+ containerHeightDp: Int = 600,
+ useDragScrollPin: Boolean = false,
+ dragAfterLongPress: Boolean = true,
+ interaction: ReorderTestScope.() -> Unit,
+ assertions: (items: List, reorderCount: Int, scrollIndex: Int, scrollOffset: Int) -> Unit,
+ ) = runComposeUiTest {
+ var items by mutableStateOf(initialItems)
+ var reorderCount by mutableIntStateOf(0)
+ var scrollIndex = 0
+ var scrollOffset = 0
+ var density = Density(1f)
+
+ setContent {
+ density = LocalDensity.current
+ val dndState = rememberDragAndDropState()
+ val listState = rememberLazyListState()
+
+ scrollIndex = listState.firstVisibleItemIndex
+ scrollOffset = listState.firstVisibleItemScrollOffset
+
+ DragAndDropContainer(
+ state = dndState,
+ modifier = Modifier.width(300.dp).height(containerHeightDp.dp),
+ ) {
+ val listModifier = Modifier
+ .width(300.dp)
+ .height(containerHeightDp.dp)
+ .let {
+ if (useDragScrollPin) {
+ it.dragScrollPin(state = dndState, lazyListState = listState)
+ } else {
+ it
+ }
+ }
+
+ LazyColumn(
+ state = listState,
+ modifier = listModifier.testTag("lazyColumn"),
+ ) {
+ items(items, key = { it.id }) { item ->
+ val isDragging = dndState.isDragging(item.id)
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(item.heightDp.dp)
+ .graphicsLayer { alpha = if (isDragging) 0f else 1f }
+ .reorderableItem(
+ key = item.id,
+ data = item,
+ state = dndState,
+ dropStrategy = DropStrategy.CenterDistance,
+ dragAfterLongPress = dragAfterLongPress,
+ onDragEnter = { state ->
+ val draggedItem = state.data
+ if (draggedItem.id == item.id) return@reorderableItem
+ val targetIndex =
+ items.indexOfFirst { it.id == item.id }
+ if (targetIndex == -1) return@reorderableItem
+ reorderCount++
+ items = items
+ .filter { it.id != draggedItem.id }
+ .toMutableList()
+ .apply {
+ add(targetIndex.coerceAtMost(size), draggedItem)
+ }
+ },
+ draggableContent = {
+ Box(Modifier.fillMaxWidth().height(item.heightDp.dp))
+ },
+ )
+ .testTag("item-${item.id}"),
+ )
+ }
+ }
+ }
+ }
+
+ waitForIdle()
+
+ val scope = ReorderTestScope(density, this)
+ scope.interaction()
+
+ waitForIdle()
+
+ assertions(items, reorderCount, scrollIndex, scrollOffset)
+ }
+
+ /**
+ * Scope providing density-aware helpers for test interactions.
+ */
+ private class ReorderTestScope(
+ val density: Density,
+ private val uiTest: androidx.compose.ui.test.ComposeUiTest,
+ ) {
+ fun dpToPx(dp: Int): Float = with(density) { dp.dp.toPx() }
+
+ fun dragItem(
+ tag: String,
+ dyDp: Int,
+ longPress: Boolean = true,
+ release: Boolean = true,
+ ) {
+ val distancePx = dpToPx(dyDp)
+ uiTest.onNodeWithTag(tag).performTouchInput {
+ if (longPress) {
+ longPressDrag(
+ start = center,
+ end = Offset(center.x, center.y + distancePx),
+ )
+ } else {
+ immediateDrag(
+ start = center,
+ end = Offset(center.x, center.y + distancePx),
+ )
+ }
+ }
+ uiTest.waitForIdle()
+ if (release) {
+ uiTest.onNodeWithTag(tag).performTouchInput { up() }
+ uiTest.waitForIdle()
+ }
+ }
+ }
+
+ // -- Basic reorder tests --
+
+ @Test
+ fun reorder_twoSameSizedItems_swapsOrder() = reorderTest(
+ initialItems = listOf(SizedItem("A", 100), SizedItem("B", 100)),
+ interaction = { dragItem("item-A", dyDp = 100) },
+ assertions = { items, reorderCount, _, _ ->
+ assertTrue("Reorder should have triggered", reorderCount > 0)
+ assertEquals("B should be first", "B", items[0].id)
+ assertEquals("A should be second", "A", items[1].id)
+ },
+ )
+
+ @Test
+ fun reorder_differentSizedItems_swapsOrder() = reorderTest(
+ initialItems = listOf(SizedItem("A", 100), SizedItem("B", 200)),
+ interaction = { dragItem("item-A", dyDp = 150) },
+ assertions = { items, reorderCount, _, _ ->
+ assertTrue("Reorder should have triggered", reorderCount > 0)
+ assertEquals("B should be first", "B", items[0].id)
+ assertEquals("A should be second", "A", items[1].id)
+ },
+ )
+
+ @Test
+ fun reorder_threeItems_dragFirstPastSecond() = reorderTest(
+ initialItems = listOf(
+ SizedItem("A", 100),
+ SizedItem("B", 100),
+ SizedItem("C", 100),
+ ),
+ interaction = { dragItem("item-A", dyDp = 100) },
+ assertions = { items, reorderCount, _, _ ->
+ assertTrue("Reorder should have triggered", reorderCount > 0)
+ assertEquals("B should be first", "B", items[0].id)
+ assertEquals("A should be second", "A", items[1].id)
+ assertEquals("C should be third", "C", items[2].id)
+ },
+ )
+
+ @Test
+ fun reorder_dragSecondToFirst() = reorderTest(
+ initialItems = listOf(SizedItem("A", 100), SizedItem("B", 100)),
+ interaction = { dragItem("item-B", dyDp = -100) },
+ assertions = { items, reorderCount, _, _ ->
+ assertTrue("Reorder should have triggered", reorderCount > 0)
+ assertEquals("B should be first", "B", items[0].id)
+ assertEquals("A should be second", "A", items[1].id)
+ },
+ )
+
+ @Test
+ fun reorder_immediateDrag_swapsOrder() = reorderTest(
+ initialItems = listOf(SizedItem("A", 100), SizedItem("B", 100)),
+ dragAfterLongPress = false,
+ interaction = { dragItem("item-A", dyDp = 100, longPress = false) },
+ assertions = { items, reorderCount, _, _ ->
+ assertTrue("Reorder should have triggered", reorderCount > 0)
+ assertEquals("B should be first", "B", items[0].id)
+ assertEquals("A should be second", "A", items[1].id)
+ },
+ )
+
+ @Test
+ fun reorder_smallDrag_doesNotReorder() = reorderTest(
+ initialItems = listOf(SizedItem("A", 100), SizedItem("B", 100)),
+ interaction = { dragItem("item-A", dyDp = 20) },
+ assertions = { items, reorderCount, _, _ ->
+ assertEquals("No reorder should happen with small drag", 0, reorderCount)
+ assertEquals("A should still be first", "A", items[0].id)
+ assertEquals("B should still be second", "B", items[1].id)
+ },
+ )
+
+ // -- Scroll pin tests --
+
+ @Test
+ fun reorder_differentSizedItems_withPin_scrollDoesNotJump() = reorderTest(
+ initialItems = listOf(
+ SizedItem("A", 100),
+ SizedItem("B", 200),
+ SizedItem("C", 150),
+ SizedItem("D", 100),
+ SizedItem("E", 120),
+ ),
+ containerHeightDp = 300,
+ useDragScrollPin = true,
+ interaction = { dragItem("item-A", dyDp = 150) },
+ assertions = { items, reorderCount, scrollIndex, scrollOffset ->
+ assertTrue("Reorder should have triggered", reorderCount > 0)
+ assertEquals("B should be first", "B", items[0].id)
+ assertEquals("Scroll index should remain 0", 0, scrollIndex)
+ assertEquals("Scroll offset should remain 0", 0, scrollOffset)
+ },
+ )
+}
diff --git a/compose-dnd/src/androidInstrumentedTest/kotlin/com/mohamedrejeb/compose/dnd/TestUtils.kt b/compose-dnd/src/androidInstrumentedTest/kotlin/com/mohamedrejeb/compose/dnd/TestUtils.kt
new file mode 100644
index 0000000..e76014d
--- /dev/null
+++ b/compose-dnd/src/androidInstrumentedTest/kotlin/com/mohamedrejeb/compose/dnd/TestUtils.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2025, Mohamed Ben Rejeb and the Compose Dnd project contributors
+ *
+ * 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 com.mohamedrejeb.compose.dnd
+
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.test.TouchInjectionScope
+
+/**
+ * Long-press then drag to a target position without releasing.
+ * Coordinates are in the node's local coordinate space.
+ */
+fun TouchInjectionScope.longPressDrag(
+ start: Offset,
+ end: Offset,
+ longPressMs: Long = 600,
+ steps: Int = 20,
+ stepDelayMs: Long = 16,
+) {
+ down(start)
+ advanceEventTime(longPressMs)
+ val dx = (end.x - start.x) / steps
+ val dy = (end.y - start.y) / steps
+ for (i in 1..steps) {
+ advanceEventTime(stepDelayMs)
+ moveTo(Offset(start.x + dx * i, start.y + dy * i))
+ }
+}
+
+/**
+ * Immediate drag (no long press) to a target position without releasing.
+ */
+fun TouchInjectionScope.immediateDrag(
+ start: Offset,
+ end: Offset,
+ steps: Int = 20,
+ stepDelayMs: Long = 16,
+) {
+ down(start)
+ val dx = (end.x - start.x) / steps
+ val dy = (end.y - start.y) / steps
+ for (i in 1..steps) {
+ advanceEventTime(stepDelayMs)
+ moveTo(Offset(start.x + dx * i, start.y + dy * i))
+ }
+}
diff --git a/compose-dnd/src/desktopTest/kotlin/com/mohamedrejeb/compose/dnd/DragScrollPinTest.kt b/compose-dnd/src/desktopTest/kotlin/com/mohamedrejeb/compose/dnd/DragScrollPinTest.kt
new file mode 100644
index 0000000..84a1a4e
--- /dev/null
+++ b/compose-dnd/src/desktopTest/kotlin/com/mohamedrejeb/compose/dnd/DragScrollPinTest.kt
@@ -0,0 +1,376 @@
+/*
+ * Copyright 2025, Mohamed Ben Rejeb and the Compose Dnd project contributors
+ *
+ * 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 com.mohamedrejeb.compose.dnd
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.TouchInjectionScope
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.test.runComposeUiTest
+import androidx.compose.ui.unit.dp
+import com.mohamedrejeb.compose.dnd.annotation.ExperimentalDndApi
+import com.mohamedrejeb.compose.dnd.drag.DropStrategy
+import com.mohamedrejeb.compose.dnd.drag.isDragging
+import com.mohamedrejeb.compose.dnd.reorder.reorderableItem
+import com.mohamedrejeb.compose.dnd.scroll.dragScrollPin
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotEquals
+import kotlin.test.assertTrue
+
+@OptIn(ExperimentalTestApi::class, ExperimentalDndApi::class)
+class DragScrollPinTest {
+
+ private data class SizedItem(
+ val id: String,
+ val heightDp: Int,
+ )
+
+ /**
+ * Simulate a long-press drag by holding the pointer, then moving slowly.
+ * Does NOT release — pointer stays held.
+ */
+ private fun TouchInjectionScope.simulateLongPressDrag(
+ start: Offset,
+ end: Offset,
+ longPressMs: Long = 600,
+ steps: Int = 20,
+ stepDelayMs: Long = 16,
+ ) {
+ down(start)
+ advanceEventTime(longPressMs)
+ val dx = (end.x - start.x) / steps
+ val dy = (end.y - start.y) / steps
+ for (i in 1..steps) {
+ advanceEventTime(stepDelayMs)
+ moveTo(Offset(start.x + dx * i, start.y + dy * i))
+ }
+ }
+
+ /**
+ * Helper: sets up a LazyColumn with reorderable different-sized items.
+ * Viewport is intentionally small (300dp) so items overflow — this is
+ * required to trigger Compose's key-based scroll anchoring.
+ */
+ private fun runReorderTest(
+ useDragScrollPin: Boolean,
+ onResult: (reorderCount: Int, items: List, scrollIndex: Int, scrollOffset: Int) -> Unit,
+ ) = runComposeUiTest {
+ // Items total: 100+200+150+100+120 = 670dp — overflows 300dp viewport
+ val initialItems = listOf(
+ SizedItem("A", 100),
+ SizedItem("B", 200),
+ SizedItem("C", 150),
+ SizedItem("D", 100),
+ SizedItem("E", 120),
+ )
+ var items by mutableStateOf(initialItems)
+ var reorderCount = 0
+ var scrollIndex = 0
+ var scrollOffset = 0
+
+ setContent {
+ val dndState = rememberDragAndDropState()
+ val listState = rememberLazyListState()
+
+ scrollIndex = listState.firstVisibleItemIndex
+ scrollOffset = listState.firstVisibleItemScrollOffset
+
+ DragAndDropContainer(
+ state = dndState,
+ modifier = Modifier.width(300.dp).height(300.dp),
+ ) {
+ val baseModifier = Modifier.width(300.dp).height(300.dp)
+ val listModifier = if (useDragScrollPin) {
+ baseModifier.dragScrollPin(
+ state = dndState,
+ lazyListState = listState,
+ )
+ } else {
+ baseModifier
+ }
+
+ LazyColumn(
+ state = listState,
+ modifier = listModifier.testTag("lazyColumn"),
+ ) {
+ items(items, key = { it.id }) { item ->
+ val isDragging = dndState.isDragging(item.id)
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(item.heightDp.dp)
+ .graphicsLayer { alpha = if (isDragging) 0f else 1f }
+ .reorderableItem(
+ key = item.id,
+ data = item,
+ state = dndState,
+ dropStrategy = DropStrategy.CenterDistance,
+ dragAfterLongPress = true,
+ onDragEnter = { state ->
+ val draggedItem = state.data
+ if (draggedItem.id == item.id) return@reorderableItem
+ val targetIndex = items.indexOfFirst { it.id == item.id }
+ if (targetIndex == -1) return@reorderableItem
+ reorderCount++
+ items = items
+ .filter { it.id != draggedItem.id }
+ .toMutableList()
+ .apply { add(targetIndex.coerceAtMost(size), draggedItem) }
+ },
+ draggableContent = {
+ Box(Modifier.fillMaxWidth().height(item.heightDp.dp))
+ },
+ )
+ .testTag("item-${item.id}"),
+ )
+ }
+ }
+ }
+ }
+
+ waitForIdle()
+
+ // Drag item A (100dp) down onto item B (200dp) and HOLD
+ onNodeWithTag("item-A").performTouchInput {
+ simulateLongPressDrag(
+ start = center,
+ end = Offset(center.x, center.y + 200f),
+ )
+ }
+
+ // Let several frames settle — anchoring may re-apply across frames
+ waitForIdle()
+ mainClock.advanceTimeBy(500)
+ waitForIdle()
+
+ println("[TEST] useDragScrollPin=$useDragScrollPin reorderCount=$reorderCount items=${items.map { it.id }} scroll=($scrollIndex, $scrollOffset)")
+ onResult(reorderCount, items, scrollIndex, scrollOffset)
+
+ // Release
+ onNodeWithTag("item-A").performTouchInput { up() }
+ waitForIdle()
+ }
+
+ /**
+ * Reproducer: WITHOUT dragScrollPin, dragging a small item (100dp) onto a
+ * larger item (200dp) causes a scroll jump due to key-based anchoring.
+ */
+ @Test
+ fun reorder_differentSizedItems_WITHOUT_pin_scrollJumps() {
+ runReorderTest(useDragScrollPin = false) { reorderCount, _, scrollIndex, scrollOffset ->
+ assertTrue(reorderCount > 0, "Reorder should have triggered (was: $reorderCount)")
+ val scrollJumped = scrollIndex != 0 || scrollOffset != 0
+ assertTrue(scrollJumped, "Scroll should jump without pin (confirms test reproduces the issue)")
+ }
+ }
+
+ /**
+ * WITH dragScrollPin, the scroll should remain pinned at (0, 0).
+ */
+ @Test
+ fun reorder_differentSizedItems_WITH_pin_scrollStaysPinned() {
+ runReorderTest(useDragScrollPin = true) { reorderCount, _, scrollIndex, scrollOffset ->
+ assertTrue(reorderCount > 0, "Reorder should have triggered (was: $reorderCount)")
+ assertEquals(0, scrollIndex, "Scroll index should remain 0 after reorder")
+ assertEquals(0, scrollOffset, "Scroll offset should remain 0 after reorder")
+ }
+ }
+
+ // -- Multi-column tests (Kanban scenario) --
+
+ private data class KanbanColumn(
+ val id: String,
+ val items: List,
+ )
+
+ /**
+ * Mimics the Kanban layout: multiple LazyColumns side-by-side sharing one
+ * DragAndDropState. Drag happens in the first column; verifies that ALL
+ * columns' scroll positions remain stable.
+ */
+ private fun runMultiColumnReorderTest(
+ useDragScrollPin: Boolean,
+ onResult: (reorderCount: Int, col1Scroll: Pair, col2Scroll: Pair) -> Unit,
+ ) = runComposeUiTest {
+ val initialColumns = listOf(
+ KanbanColumn("col1", listOf(
+ SizedItem("A", 100),
+ SizedItem("B", 200),
+ SizedItem("C", 150),
+ SizedItem("D", 100),
+ SizedItem("E", 120),
+ )),
+ KanbanColumn("col2", listOf(
+ SizedItem("F", 120),
+ SizedItem("G", 180),
+ SizedItem("H", 100),
+ SizedItem("I", 150),
+ SizedItem("J", 130),
+ )),
+ )
+ var columns by mutableStateOf(initialColumns)
+ var reorderCount = 0
+ var col1ScrollIndex = 0
+ var col1ScrollOffset = 0
+ var col2ScrollIndex = 0
+ var col2ScrollOffset = 0
+
+ setContent {
+ val dndState = rememberDragAndDropState()
+
+ DragAndDropContainer(
+ state = dndState,
+ modifier = Modifier.width(600.dp).height(300.dp),
+ ) {
+ Row {
+ columns.forEach { column ->
+ val colState = rememberLazyListState()
+
+ // Track each column's scroll
+ when (column.id) {
+ "col1" -> {
+ col1ScrollIndex = colState.firstVisibleItemIndex
+ col1ScrollOffset = colState.firstVisibleItemScrollOffset
+ }
+ "col2" -> {
+ col2ScrollIndex = colState.firstVisibleItemIndex
+ col2ScrollOffset = colState.firstVisibleItemScrollOffset
+ }
+ }
+
+ val baseModifier = Modifier.width(280.dp).fillMaxHeight()
+ val listModifier = if (useDragScrollPin) {
+ baseModifier.dragScrollPin(
+ state = dndState,
+ lazyListState = colState,
+ )
+ } else {
+ baseModifier
+ }
+
+ LazyColumn(
+ state = colState,
+ modifier = listModifier.testTag("column-${column.id}"),
+ ) {
+ items(column.items, key = { it.id }) { item ->
+ val isDragging = dndState.isDragging(item.id)
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(item.heightDp.dp)
+ .graphicsLayer { alpha = if (isDragging) 0f else 1f }
+ .reorderableItem(
+ key = item.id,
+ data = item,
+ state = dndState,
+ dropStrategy = DropStrategy.CenterDistance,
+ dragAfterLongPress = true,
+ onDragEnter = { state ->
+ val draggedItem = state.data
+ if (draggedItem.id == item.id) return@reorderableItem
+
+ reorderCount++
+ columns = columns.map { col ->
+ val targetIndex = col.items.indexOfFirst { it.id == item.id }
+ if (targetIndex == -1) {
+ col.copy(items = col.items.filter { it.id != draggedItem.id })
+ } else {
+ col.copy(
+ items = col.items
+ .filter { it.id != draggedItem.id }
+ .toMutableList()
+ .apply { add(targetIndex.coerceAtMost(size), draggedItem) }
+ )
+ }
+ }
+ },
+ draggableContent = {
+ Box(Modifier.fillMaxWidth().height(item.heightDp.dp))
+ },
+ )
+ .testTag("item-${item.id}"),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ waitForIdle()
+
+ // Drag item A down onto item B within column 1
+ onNodeWithTag("item-A").performTouchInput {
+ simulateLongPressDrag(
+ start = center,
+ end = Offset(center.x, center.y + 200f),
+ )
+ }
+
+ waitForIdle()
+ mainClock.advanceTimeBy(500)
+ waitForIdle()
+
+ println("[MULTI-COL] pin=$useDragScrollPin reorders=$reorderCount col1=($col1ScrollIndex,$col1ScrollOffset) col2=($col2ScrollIndex,$col2ScrollOffset)")
+ println("[MULTI-COL] col1 items: ${columns[0].items.map { it.id }}")
+ onResult(reorderCount, Pair(col1ScrollIndex, col1ScrollOffset), Pair(col2ScrollIndex, col2ScrollOffset))
+
+ onNodeWithTag("item-A").performTouchInput { up() }
+ waitForIdle()
+ }
+
+ @Test
+ fun multiColumn_WITHOUT_pin_scrollJumps() {
+ runMultiColumnReorderTest(useDragScrollPin = false) { reorderCount, col1Scroll, col2Scroll ->
+ assertTrue(reorderCount > 0, "Reorder should have triggered")
+ val col1Jumped = col1Scroll.first != 0 || col1Scroll.second != 0
+ val col2Jumped = col2Scroll.first != 0 || col2Scroll.second != 0
+ println("[MULTI-COL] WITHOUT pin: col1Jumped=$col1Jumped col2Jumped=$col2Jumped")
+ assertTrue(col1Jumped || col2Jumped, "At least one column should have scrolled without pin")
+ }
+ }
+
+ @Test
+ fun multiColumn_WITH_pin_scrollStaysPinned() {
+ runMultiColumnReorderTest(useDragScrollPin = true) { reorderCount, col1Scroll, col2Scroll ->
+ assertTrue(reorderCount > 0, "Reorder should have triggered")
+ assertEquals(0, col1Scroll.first, "Col1 scroll index should remain 0")
+ assertEquals(0, col1Scroll.second, "Col1 scroll offset should remain 0")
+ assertEquals(0, col2Scroll.first, "Col2 scroll index should remain 0")
+ assertEquals(0, col2Scroll.second, "Col2 scroll offset should remain 0")
+ }
+ }
+}
diff --git a/compose-dnd/src/desktopTest/kotlin/com/mohamedrejeb/compose/dnd/DragToTargetDesktopTest.kt b/compose-dnd/src/desktopTest/kotlin/com/mohamedrejeb/compose/dnd/DragToTargetDesktopTest.kt
new file mode 100644
index 0000000..1216bac
--- /dev/null
+++ b/compose-dnd/src/desktopTest/kotlin/com/mohamedrejeb/compose/dnd/DragToTargetDesktopTest.kt
@@ -0,0 +1,317 @@
+/*
+ * Copyright 2025, Mohamed Ben Rejeb and the Compose Dnd project contributors
+ *
+ * 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 com.mohamedrejeb.compose.dnd
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.TouchInjectionScope
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.test.runComposeUiTest
+import androidx.compose.ui.unit.dp
+import com.mohamedrejeb.compose.dnd.drag.DraggableItem
+import com.mohamedrejeb.compose.dnd.drop.dropTarget
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+@OptIn(ExperimentalTestApi::class)
+class DragToTargetDesktopTest {
+
+ private fun TouchInjectionScope.simulateDrag(
+ start: Offset,
+ end: Offset,
+ steps: Int = 20,
+ stepDelayMs: Long = 16,
+ ) {
+ down(start)
+ val dx = (end.x - start.x) / steps
+ val dy = (end.y - start.y) / steps
+ for (i in 1..steps) {
+ advanceEventTime(stepDelayMs)
+ moveTo(Offset(start.x + dx * i, start.y + dy * i))
+ }
+ }
+
+ @Test
+ fun dragItem_toDropTarget_triggersOnDrop() = runComposeUiTest {
+ var dropped = false
+ var droppedData: Int? = null
+
+ setContent {
+ val state = rememberDragAndDropState()
+
+ DragAndDropContainer(
+ state = state,
+ modifier = Modifier.size(400.dp),
+ ) {
+ Column {
+ DraggableItem(
+ state = state,
+ key = "item",
+ data = 42,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ .testTag("draggable"),
+ ) {
+ Box(Modifier.fillMaxWidth().height(100.dp))
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(200.dp)
+ .dropTarget(
+ key = "target",
+ state = state,
+ onDrop = {
+ dropped = true
+ droppedData = it.data
+ },
+ ),
+ )
+ }
+ }
+ }
+
+ waitForIdle()
+
+ onNodeWithTag("draggable").performTouchInput {
+ simulateDrag(
+ start = center,
+ end = Offset(center.x, center.y + 300f),
+ )
+ up()
+ }
+
+ waitForIdle()
+ assertTrue(dropped, "onDrop should have been called")
+ assertEquals(42, droppedData, "Dropped data should be 42")
+ }
+
+ @Test
+ fun dragItem_released_outsideTarget_doesNotDrop() = runComposeUiTest {
+ var dropped = false
+
+ setContent {
+ val state = rememberDragAndDropState()
+
+ DragAndDropContainer(
+ state = state,
+ modifier = Modifier.size(400.dp),
+ ) {
+ Column {
+ DraggableItem(
+ state = state,
+ key = "item",
+ data = 1,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ .testTag("draggable"),
+ ) {
+ Box(Modifier.fillMaxWidth().height(100.dp))
+ }
+
+ Box(Modifier.fillMaxWidth().height(200.dp))
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ .dropTarget(
+ key = "target",
+ state = state,
+ onDrop = { dropped = true },
+ ),
+ )
+ }
+ }
+ }
+
+ waitForIdle()
+
+ onNodeWithTag("draggable").performTouchInput {
+ simulateDrag(
+ start = center,
+ end = Offset(center.x, center.y + 50f),
+ )
+ up()
+ }
+
+ waitForIdle()
+ assertFalse(dropped, "onDrop should NOT be called when not over target")
+ }
+
+ @Test
+ fun dragItem_onDragEnter_calledWhenHoveringTarget() = runComposeUiTest {
+ var enterCount = 0
+
+ setContent {
+ val state = rememberDragAndDropState()
+
+ DragAndDropContainer(
+ state = state,
+ modifier = Modifier.size(400.dp),
+ ) {
+ Column {
+ DraggableItem(
+ state = state,
+ key = "item",
+ data = 1,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ .testTag("draggable"),
+ ) {
+ Box(Modifier.fillMaxWidth().height(100.dp))
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(200.dp)
+ .dropTarget(
+ key = "target",
+ state = state,
+ onDragEnter = { enterCount++ },
+ ),
+ )
+ }
+ }
+ }
+
+ waitForIdle()
+
+ onNodeWithTag("draggable").performTouchInput {
+ simulateDrag(
+ start = center,
+ end = Offset(center.x, center.y + 300f),
+ )
+ }
+
+ waitForIdle()
+ assertTrue(enterCount > 0, "onDragEnter should have been called")
+
+ onNodeWithTag("draggable").performTouchInput { up() }
+ waitForIdle()
+ }
+
+ @Test
+ fun enabled_false_preventsDrag() = runComposeUiTest {
+ var isActive = false
+
+ setContent {
+ val state = rememberDragAndDropState()
+ isActive = state.isActiveDrag
+
+ DragAndDropContainer(
+ state = state,
+ modifier = Modifier.size(400.dp),
+ ) {
+ DraggableItem(
+ state = state,
+ key = "item",
+ data = "test",
+ enabled = false,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ .testTag("draggable"),
+ ) {
+ Box(Modifier.fillMaxWidth().height(100.dp))
+ }
+ }
+ }
+
+ waitForIdle()
+
+ onNodeWithTag("draggable").performTouchInput {
+ simulateDrag(
+ start = center,
+ end = Offset(center.x, center.y + 50f),
+ )
+ }
+ waitForIdle()
+
+ assertFalse(isActive, "Drag should not start when enabled=false")
+
+ onNodeWithTag("draggable").performTouchInput { up() }
+ waitForIdle()
+ }
+
+ @Test
+ fun hoveredDropTargetKey_clearsAfterDragEnd() = runComposeUiTest {
+ var hoveredKey: Any? = "initial"
+
+ setContent {
+ val state = rememberDragAndDropState()
+ hoveredKey = state.hoveredDropTargetKey
+
+ DragAndDropContainer(
+ state = state,
+ modifier = Modifier.size(400.dp),
+ ) {
+ Column {
+ DraggableItem(
+ state = state,
+ key = "item",
+ data = "test",
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ .testTag("draggable"),
+ ) {
+ Box(Modifier.fillMaxWidth().height(100.dp))
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(200.dp)
+ .dropTarget(key = "target", state = state),
+ )
+ }
+ }
+ }
+
+ waitForIdle()
+ assertNull(hoveredKey, "No hovered key initially")
+
+ onNodeWithTag("draggable").performTouchInput {
+ simulateDrag(
+ start = center,
+ end = Offset(center.x, center.y + 300f),
+ )
+ }
+ waitForIdle()
+ assertEquals("target", hoveredKey, "Should hover target")
+
+ onNodeWithTag("draggable").performTouchInput { up() }
+ waitForIdle()
+ assertNull(hoveredKey, "Hovered key should clear after drop")
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 3196366..77882c9 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,6 +1,8 @@
[versions]
kotlin = "2.3.20"
compose = "1.10.3"
+material3 = "1.10.0-alpha05"
+material-icons-extended = "1.7.3"
agp = "8.13.2"
android-minSdk = "23"
android-compileSdk = "36"
@@ -8,15 +10,25 @@ android-targetSdk = "36"
androidx-activityCompose = "1.13.0"
androidx-core-ktx = "1.18.0"
jetbrains-navigation-compose = "2.9.2"
+runner = "1.7.0"
spotless = "8.4.0"
vanniktech-maven-publish = "0.36.0"
[libraries]
+androidx-runner = { module = "androidx.test:runner", version.ref = "runner" }
+androidx-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
spotless-gradle = { module = "com.diffplug.spotless:spotless-plugin-gradle", version.ref = "spotless" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
+compose-foundation = { group = "org.jetbrains.compose.foundation", name = "foundation", version.ref = "compose" }
+compose-ui = { group = "org.jetbrains.compose.ui", name = "ui", version.ref = "compose" }
+compose-components-resources = { group = "org.jetbrains.compose.components", name = "components-resources", version.ref = "compose" }
+compose-material3 = { group = "org.jetbrains.compose.material3", name = "material3", version.ref = "material3" }
+compose-material-icons-extended = { group = "org.jetbrains.compose.material", name = "material-icons-extended", version.ref = "material-icons-extended" }
+compose-ui-test = { group = "org.jetbrains.compose.ui", name = "ui-test", version.ref = "compose" }
+
vanniktech-maven-publish = { module = "com.vanniktech.maven.publish:com.vanniktech.maven.publish.gradle.plugin", version.ref = "vanniktech-maven-publish" }
jetbrains-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "jetbrains-navigation-compose" }
diff --git a/sample/common/build.gradle.kts b/sample/common/build.gradle.kts
index 4adeec4..bdac7ac 100644
--- a/sample/common/build.gradle.kts
+++ b/sample/common/build.gradle.kts
@@ -63,9 +63,9 @@ kotlin {
sourceSets.commonMain.dependencies {
implementation(projects.composeDnd)
- implementation(compose.foundation)
- implementation(compose.material3)
- implementation(compose.materialIconsExtended)
+ implementation(libs.compose.foundation)
+ implementation(libs.compose.material3)
+ implementation(libs.compose.material.icons.extended)
implementation(libs.jetbrains.navigation.compose)
}
diff --git a/sample/web/build.gradle.kts b/sample/web/build.gradle.kts
index 47a1991..9549538 100644
--- a/sample/web/build.gradle.kts
+++ b/sample/web/build.gradle.kts
@@ -20,6 +20,6 @@ kotlin {
sourceSets.commonMain.dependencies {
implementation(projects.sample.common)
- implementation(compose.foundation)
+ implementation(libs.compose.foundation)
}
}