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