Skip to content

Commit b4f5879

Browse files
authored
Merge pull request #66 from MohamedRejeb/0.5.x
test: add instrumented tests for cross-list drag, axis constraints, and drag-scroll pin behavior
2 parents 4d75509 + 018c3dc commit b4f5879

15 files changed

Lines changed: 2733 additions & 9 deletions

File tree

.github/workflows/gradle.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@ jobs:
5151
- name: Gradle test
5252
run: ./gradlew allTests
5353

54+
- name: Android instrumented tests
55+
uses: reactivecircus/android-emulator-runner@v2
56+
with:
57+
api-level: 34
58+
arch: x86_64
59+
script: ./gradlew :compose-dnd:connectedDebugAndroidTest
60+
5461
deploy:
5562
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
5663
needs: build

compose-dnd/build.gradle.kts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,20 +59,24 @@ kotlin {
5959
iosSimulatorArm64()
6060

6161
sourceSets.commonMain.dependencies {
62-
implementation(compose.runtime)
63-
implementation(compose.foundation)
64-
implementation(compose.material)
62+
implementation(libs.compose.foundation)
63+
implementation(libs.compose.ui)
6564
}
6665

6766
sourceSets.commonTest.dependencies {
6867
implementation(kotlin("test"))
6968
}
7069

71-
@OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
7270
sourceSets.named("desktopTest").dependencies {
73-
implementation(compose.uiTest)
71+
implementation(libs.compose.ui.test)
7472
implementation(compose.desktop.currentOs)
7573
}
74+
75+
sourceSets.androidInstrumentedTest.dependencies {
76+
implementation(libs.compose.ui.test)
77+
implementation(libs.androidx.ui.test.junit4)
78+
implementation(libs.androidx.runner)
79+
}
7680
}
7781

7882
android {
@@ -85,6 +89,7 @@ android {
8589
minSdk = libs.versions.android.minSdk
8690
.get()
8791
.toInt()
92+
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
8893
}
8994

9095
compileOptions {
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
3+
<application>
4+
<activity
5+
android:name="androidx.activity.ComponentActivity"
6+
android:exported="true" />
7+
</application>
8+
</manifest>
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
/*
2+
* Copyright 2025, Mohamed Ben Rejeb and the Compose Dnd project contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.mohamedrejeb.compose.dnd
17+
18+
import androidx.compose.foundation.layout.Box
19+
import androidx.compose.foundation.layout.Row
20+
import androidx.compose.foundation.layout.fillMaxHeight
21+
import androidx.compose.foundation.layout.fillMaxWidth
22+
import androidx.compose.foundation.layout.height
23+
import androidx.compose.foundation.layout.width
24+
import androidx.compose.foundation.lazy.LazyColumn
25+
import androidx.compose.foundation.lazy.items
26+
import androidx.compose.runtime.getValue
27+
import androidx.compose.runtime.mutableStateOf
28+
import androidx.compose.runtime.setValue
29+
import androidx.compose.ui.Modifier
30+
import androidx.compose.ui.geometry.Offset
31+
import androidx.compose.ui.graphics.graphicsLayer
32+
import androidx.compose.ui.platform.LocalDensity
33+
import androidx.compose.ui.platform.testTag
34+
import androidx.compose.ui.test.ExperimentalTestApi
35+
import androidx.compose.ui.test.onNodeWithTag
36+
import androidx.compose.ui.test.performTouchInput
37+
import androidx.compose.ui.test.runComposeUiTest
38+
import androidx.compose.ui.unit.Density
39+
import androidx.compose.ui.unit.dp
40+
import com.mohamedrejeb.compose.dnd.annotation.ExperimentalDndApi
41+
import com.mohamedrejeb.compose.dnd.drag.DropStrategy
42+
import com.mohamedrejeb.compose.dnd.drag.isDragging
43+
import com.mohamedrejeb.compose.dnd.reorder.reorderableItem
44+
import org.junit.Assert.assertEquals
45+
import org.junit.Assert.assertTrue
46+
import org.junit.Test
47+
48+
@OptIn(ExperimentalTestApi::class, ExperimentalDndApi::class)
49+
class CrossListDragTest {
50+
51+
private data class Item(val id: String)
52+
53+
/**
54+
* Two side-by-side LazyColumns sharing one DragAndDropState.
55+
* Drag an item from the left column to the right column.
56+
*/
57+
@Test
58+
fun dragItem_fromLeftColumn_toRightColumn() = runComposeUiTest {
59+
var leftItems by mutableStateOf(listOf(Item("L1"), Item("L2")))
60+
var rightItems by mutableStateOf(listOf(Item("R1"), Item("R2")))
61+
var density = Density(1f)
62+
63+
setContent {
64+
density = LocalDensity.current
65+
val dndState = rememberDragAndDropState<Item>()
66+
67+
DragAndDropContainer(
68+
state = dndState,
69+
modifier = Modifier.width(400.dp).height(400.dp),
70+
) {
71+
Row {
72+
// Left column (200dp wide)
73+
LazyColumn(
74+
modifier = Modifier.width(200.dp).fillMaxHeight(),
75+
) {
76+
items(leftItems, key = { it.id }) { item ->
77+
val isDragging = dndState.isDragging(item.id)
78+
79+
Box(
80+
modifier = Modifier
81+
.fillMaxWidth()
82+
.height(100.dp)
83+
.graphicsLayer { alpha = if (isDragging) 0f else 1f }
84+
.reorderableItem(
85+
key = item.id,
86+
data = item,
87+
state = dndState,
88+
dropStrategy = DropStrategy.CenterDistance,
89+
onDragEnter = { state ->
90+
val draggedItem = state.data
91+
if (draggedItem.id == item.id) return@reorderableItem
92+
val targetIdx = leftItems.indexOfFirst { it.id == item.id }
93+
if (targetIdx != -1) {
94+
leftItems = leftItems
95+
.filter { it.id != draggedItem.id }
96+
.toMutableList()
97+
.apply { add(targetIdx.coerceAtMost(size), draggedItem) }
98+
rightItems = rightItems.filter { it.id != draggedItem.id }
99+
}
100+
},
101+
draggableContent = {
102+
Box(Modifier.fillMaxWidth().height(100.dp))
103+
},
104+
)
105+
.testTag("item-${item.id}"),
106+
)
107+
}
108+
}
109+
110+
// Right column (200dp wide)
111+
LazyColumn(
112+
modifier = Modifier.width(200.dp).fillMaxHeight(),
113+
) {
114+
items(rightItems, key = { it.id }) { item ->
115+
val isDragging = dndState.isDragging(item.id)
116+
117+
Box(
118+
modifier = Modifier
119+
.fillMaxWidth()
120+
.height(100.dp)
121+
.graphicsLayer { alpha = if (isDragging) 0f else 1f }
122+
.reorderableItem(
123+
key = item.id,
124+
data = item,
125+
state = dndState,
126+
dropStrategy = DropStrategy.CenterDistance,
127+
onDragEnter = { state ->
128+
val draggedItem = state.data
129+
if (draggedItem.id == item.id) return@reorderableItem
130+
val targetIdx = rightItems.indexOfFirst { it.id == item.id }
131+
if (targetIdx != -1) {
132+
rightItems = rightItems
133+
.filter { it.id != draggedItem.id }
134+
.toMutableList()
135+
.apply { add(targetIdx.coerceAtMost(size), draggedItem) }
136+
leftItems = leftItems.filter { it.id != draggedItem.id }
137+
}
138+
},
139+
draggableContent = {
140+
Box(Modifier.fillMaxWidth().height(100.dp))
141+
},
142+
)
143+
.testTag("item-${item.id}"),
144+
)
145+
}
146+
}
147+
}
148+
}
149+
}
150+
151+
waitForIdle()
152+
153+
// Verify initial state
154+
assertEquals(listOf("L1", "L2"), leftItems.map { it.id })
155+
assertEquals(listOf("R1", "R2"), rightItems.map { it.id })
156+
157+
// Drag L1 from left column to right column (200dp to the right, onto R1)
158+
val horizontalPx = with(density) { 200.dp.toPx() }
159+
160+
onNodeWithTag("item-L1").performTouchInput {
161+
immediateDrag(
162+
start = center,
163+
end = Offset(center.x + horizontalPx, center.y),
164+
)
165+
up()
166+
}
167+
168+
waitForIdle()
169+
170+
// L1 should have moved to the right column
171+
assertTrue(
172+
"L1 should no longer be in left column",
173+
leftItems.none { it.id == "L1" },
174+
)
175+
assertTrue(
176+
"L1 should be in right column",
177+
rightItems.any { it.id == "L1" },
178+
)
179+
}
180+
181+
/**
182+
* Drag an item within the same column (reorder) and verify
183+
* the other column is unaffected.
184+
*/
185+
@Test
186+
fun dragItem_withinSameColumn_otherColumnUnaffected() = runComposeUiTest {
187+
var leftItems by mutableStateOf(listOf(Item("L1"), Item("L2"), Item("L3")))
188+
var rightItems by mutableStateOf(listOf(Item("R1"), Item("R2")))
189+
var density = Density(1f)
190+
191+
setContent {
192+
density = LocalDensity.current
193+
val dndState = rememberDragAndDropState<Item>()
194+
195+
DragAndDropContainer(
196+
state = dndState,
197+
modifier = Modifier.width(400.dp).height(400.dp),
198+
) {
199+
Row {
200+
LazyColumn(
201+
modifier = Modifier.width(200.dp).fillMaxHeight(),
202+
) {
203+
items(leftItems, key = { it.id }) { item ->
204+
val isDragging = dndState.isDragging(item.id)
205+
206+
Box(
207+
modifier = Modifier
208+
.fillMaxWidth()
209+
.height(100.dp)
210+
.graphicsLayer { alpha = if (isDragging) 0f else 1f }
211+
.reorderableItem(
212+
key = item.id,
213+
data = item,
214+
state = dndState,
215+
dropStrategy = DropStrategy.CenterDistance,
216+
onDragEnter = { state ->
217+
val draggedItem = state.data
218+
if (draggedItem.id == item.id) return@reorderableItem
219+
val targetIdx = leftItems.indexOfFirst { it.id == item.id }
220+
if (targetIdx != -1) {
221+
leftItems = leftItems
222+
.filter { it.id != draggedItem.id }
223+
.toMutableList()
224+
.apply { add(targetIdx.coerceAtMost(size), draggedItem) }
225+
rightItems = rightItems.filter { it.id != draggedItem.id }
226+
}
227+
},
228+
draggableContent = {
229+
Box(Modifier.fillMaxWidth().height(100.dp))
230+
},
231+
)
232+
.testTag("item-${item.id}"),
233+
)
234+
}
235+
}
236+
237+
LazyColumn(
238+
modifier = Modifier.width(200.dp).fillMaxHeight(),
239+
) {
240+
items(rightItems, key = { it.id }) { item ->
241+
Box(
242+
modifier = Modifier
243+
.fillMaxWidth()
244+
.height(100.dp)
245+
.reorderableItem(
246+
key = item.id,
247+
data = item,
248+
state = dndState,
249+
dropStrategy = DropStrategy.CenterDistance,
250+
onDragEnter = { state ->
251+
val draggedItem = state.data
252+
if (draggedItem.id == item.id) return@reorderableItem
253+
val targetIdx = rightItems.indexOfFirst { it.id == item.id }
254+
if (targetIdx != -1) {
255+
rightItems = rightItems
256+
.filter { it.id != draggedItem.id }
257+
.toMutableList()
258+
.apply { add(targetIdx.coerceAtMost(size), draggedItem) }
259+
leftItems = leftItems.filter { it.id != draggedItem.id }
260+
}
261+
},
262+
draggableContent = {
263+
Box(Modifier.fillMaxWidth().height(100.dp))
264+
},
265+
)
266+
.testTag("item-${item.id}"),
267+
)
268+
}
269+
}
270+
}
271+
}
272+
}
273+
274+
waitForIdle()
275+
276+
// Drag L1 down onto L2 (reorder within left column)
277+
val distancePx = with(density) { 100.dp.toPx() }
278+
279+
onNodeWithTag("item-L1").performTouchInput {
280+
immediateDrag(
281+
start = center,
282+
end = Offset(center.x, center.y + distancePx),
283+
)
284+
up()
285+
}
286+
287+
waitForIdle()
288+
289+
// Left column should be reordered
290+
assertEquals("L2 should be first in left", "L2", leftItems[0].id)
291+
assertEquals("L1 should be second in left", "L1", leftItems[1].id)
292+
293+
// Right column should be unchanged
294+
assertEquals("Right column should be unchanged", listOf("R1", "R2"), rightItems.map { it.id })
295+
}
296+
}

0 commit comments

Comments
 (0)