はじめに
リスト内のアイテムのドラッグアンドドロップは結構見かけるんですが、任意のコンポーザブル同士のドラッグアンドドロップの事例を見つけられなかったので自力実装してみました。
探し方が悪かっただけで車輪の再発明になってるかもしれません。
動作確認環境
compileSdk = 34
targetSdk = 34
androidx.core:core-ktx:1.12.0
androidx.compose:compose-bom:2023.10.01
Pixel4で動作確認。
多分よほど古い環境でなければ動くのではないかと。
実装
コンポーザブル側
// 同一のDragAndDropGroupを持つDraggableからDroppableへのドラッグアンドドロップだけを処理する
class DragAndDropGroup {
val dropItems: MutableList<Pair<Rect, Any>> = mutableListOf()
fun addDropItems(rect: Rect, item: Any) {
dropItems.add(Pair(rect, item))
}
}
@Composable
fun Draggable(
dragAndDropGroup: DragAndDropGroup,
modifier: Modifier = Modifier,
onDragStart: () -> Unit = {},
onDrag: (Any?) -> Unit = {},
onDragEnd: (Any?) -> Unit = {},
content: @Composable () -> Unit
) {
var globalPosition by remember { mutableStateOf(Offset.Zero) }
var dragOffsetInView by remember { mutableStateOf(Offset.Zero) }
var travelDistance by remember { mutableStateOf(Offset.Zero) }
Box(
modifier = Modifier
.onGloballyPositioned { globalPosition = it.positionInRoot() }
.offset { IntOffset(travelDistance.x.toInt(), travelDistance.y.toInt()) }
.pointerInput(Unit) {
fun target(): Any? {
val position = globalPosition + dragOffsetInView + travelDistance
return dragAndDropGroup.dropItems.firstOrNull {
it.first.contains(position)
} ?.second
}
detectDragGestures(
onDragStart = {
dragOffsetInView = it
onDragStart()
},
onDrag = { change, dragAmount ->
change.consume()
travelDistance += dragAmount
onDrag(target())
},
onDragEnd = {
onDragEnd(target())
dragOffsetInView = Offset.Zero
travelDistance = Offset.Zero
}
)
}
) {
Box(modifier = modifier) {
content()
}
}
}
@Composable
fun Droppable(
dragAndDropGroup: DragAndDropGroup,
item: Any,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Box(
modifier = modifier.onGloballyPositioned {
dragAndDropGroup.addDropItems(it.boundsInRoot(), item)
}
) {
content()
}
}
ドラッグさせるだけなら公式にも記事があるので特に問題ないんですが、問題はドロップ側。
とはいえ、やっていることは安直にonGloballyPositioned
でドロップ先コンポーザブルの座標を取得しておき、ドラッグ座標と突き合わせをしているだけです。
ドロップ先コンポーザブルの座標を保存する場所としてDragAndDropGroup
を用意しています。複数のドラッグアンドドロップ可能なコンポーザブルの集合が存在するときのグルーピングも兼ねており、同一グループ内のDraggable
からDroppable
へのドラッグアンドドロップだけが処理されます。
利用例
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
val dragAndDropGroup1 = DragAndDropGroup()
var message by remember { mutableStateOf("") }
Column {
Draggable(
dragAndDropGroup = dragAndDropGroup1,
onDragEnd = {
if (it == "droppable") message = "dropped!"
},
) {
Text(text = "draggable")
}
Droppable(
dragAndDropGroup = dragAndDropGroup1,
item = "droppable"
) {
Text(text = "droppable")
}
Text(text = message)
}
}
応用編
data class Place(val name: String)
data class Person(val name: String)
data class Fruit(val name: String)
val places = listOf(Place("School"), Place("Office"))
val persons = listOf(Person("Alice"), Person("Bob"))
val fruits = listOf(Fruit("Apple"), Fruit("Banana"))
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
val dragAndDropGroup1 = DragAndDropGroup()
var targetDropItems by remember { mutableStateOf(emptyList<Any>()) }
var underDropItem by remember { mutableStateOf<Any?>(null) }
var message by remember { mutableStateOf("") }
Column {
Row {
places.forEach { place: Place ->
Droppable(
dragAndDropGroup = dragAndDropGroup1,
modifier = Modifier
.padding(20.dp)
.border(
width = 2.dp,
color = if (targetDropItems.contains(place)) {
if (underDropItem == place) Color.Blue else Color.Red
} else {
Color.Black
}
)
.padding(10.dp),
item = place
) {
Text(text = place.name)
}
}
}
Row {
persons.forEach { person ->
Draggable(
dragAndDropGroup = dragAndDropGroup1,
onDragStart = { targetDropItems = places },
onDrag = { underDropItem = it },
onDragEnd = {
when (it) {
is Place -> message = "${person.name} goes to ${it.name}"
}
targetDropItems = emptyList()
underDropItem = null
}
) {
Droppable(
dragAndDropGroup = dragAndDropGroup1,
modifier = Modifier
.padding(20.dp)
.border(
width = 2.dp,
color = if (targetDropItems.contains(person)) {
if (underDropItem == person) Color.Blue else Color.Red
} else {
Color.Black
}
)
.padding(10.dp),
item = person
) {
Text(text = person.name)
}
}
}
}
Row {
fruits.forEach { fruit ->
Draggable(
dragAndDropGroup = dragAndDropGroup1,
onDragStart = { targetDropItems = persons },
onDrag = { underDropItem = it },
onDragEnd = {
when (it) {
is Person -> message = "${it.name} eats ${fruit.name}"
}
targetDropItems = emptyList()
underDropItem = null
},
modifier = Modifier
.padding(20.dp)
.border(2.dp, Color.Black)
.padding(10.dp)
) {
Text(text = fruit.name)
}
}
}
Text(text = message)
}
}