LoginSignup
1
3
記事投稿キャンペーン 「2024年!初アウトプットをしよう」

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

Posted at

はじめに

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

動作確認環境

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

screen-20240113-004719 (1).gif

応用編

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

1
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
3