Jetpack Composeでドラッグアンドドロップを実装する

# はじめに
リスト内のアイテムのドラッグアンドドロップは結構見かけるんですが、任意のコンポーザブル同士のドラッグアンドドロップの事例を見つけられなかったので自力実装してみました。
探し方が悪かっただけで車輪の再発明になってるかもしれません。

# 動作確認環境
compileSdk = 34
targetSdk = 34
androidx.core:core-ktx:1.12.0
androidx.compose:compose-bom:2023.10.01

Pixel4で動作確認。
多分よほど古い環境でなければ動くのではないかと。

# 実装

### コンポーザブル側

```kotlin
// 同一の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()
    }
}
```

ドラッグさせるだけなら[公式にも記事がある](https://developer.android.com/jetpack/compose/touch-input/pointer-input/drag-swipe-fling)ので特に問題ないんですが、問題はドロップ側。
とはいえ、やっていることは安直に`onGloballyPositioned`でドロップ先コンポーザブルの座標を取得しておき、ドラッグ座標と突き合わせをしているだけです。
ドロップ先コンポーザブルの座標を保存する場所として`DragAndDropGroup`を用意しています。複数のドラッグアンドドロップ可能なコンポーザブルの集合が存在するときのグルーピングも兼ねており、同一グループ内の`Draggable`から`Droppable`へのドラッグアンドドロップだけが処理されます。

### 利用例

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

![screen-20240113-004719 (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/144437/8d52424a-96ab-824c-1b79-34159e61b126.gif)

### 応用編

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

![screen-20240113-005025 (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/144437/8c28453a-307a-ad52-1c44-b5befa9b30e4.gif)