Drag & Drop | Word Game in Jetpack Compose | Android Studio Tutorial Kotlin

Hello everyone, in this article we will learn how to impliment drag and drop functionality in our compose app. In modern app you see drag and drop in multiple places in the apps like rearranging items in the list or word or puzzle games where you have to pick the item and drop somewhere else. Those app use the drag & drop feature of Android Views and we will learn to make same functionality work in our compose app. As you can see in the image we will make a word game where you have unsolved arrangement of boxes with some empty spaces and you have to pick the correct word and place it in the exact required position and you game will be complete.

To make this game we need two types of views

  1. Draggable Views – this will be extension composable function which will take any compomosable inside it and make it draggable with any data you pass with it.
  2. Drop Target – this will also be a extension function and make any composable sensable about something is being dragged over it. Means any composable over which this function is called will be able to detect if something is being dragged over it or not.

First we need to create a internal class which will hold all the required state of our draggable item like

  1. isDragging : Boolean – check if the composable is currently being dragging or not
  2. dragStartOffset : Offset – maintain the initial offset from where the dragging started
  3. dragCurrentOffset :Offset – maintain the current offset where the draggable is currently moving
  4. draggableCompsable : Any composable you are dragging
  5. dataToDrop – You can pass any data with this draggable view and get it inside the Drop Target.
internal class DraggableItemInfo {
    var isDragging: Boolean by mutableStateOf(false)
    var dragStartOffset by mutableStateOf(Offset.Zero)
    var dragCurrentOffset by mutableStateOf(Offset.Zero)
    var draggableComposable by mutableStateOf<(@Composable () -> Unit)?>(null)
    var dataToDrop by mutableStateOf<Any?>(null)
}

we need to get the state of our draggableItemInfo all the time, so lets create a Compositon local for this which will maintain the state of our class all the time throughout composition.

internal val LocalDraggableItemInfo = compositionLocalOf { DraggableItemInfo() }

Now we will create the DraggableView composable function for our game. this function will take 3 inputs :

  1. modifier : Modifier – Modifier from outside
  2. dataToDrop : T – you can pass any data with this function
  3. content : Composable – this is the actual composable view which will be dragging

Inside this function we will use the Box composable inside which we show the outer composable we are getting from outside. We will use two important modifier of Box composable as follows:

  1. onGloballyPositioned: it retrieves layout information (position and size) of a composable and provide the layoutCoordinates
  2. PointerInput – to detect the detectDragGesturesAfterLongPress function which will detect dragging after long press on any view.
@Composable
fun <T> DraggableView(
    modifier: Modifier = Modifier,
    dataToDrop: T ,
    content: @Composable (() -> Unit)
) {
    var currentPosition by remember { mutableStateOf(Offset.Zero) }

    //access the current state of our DraggableInfo
    val currentState = LocalDraggableItemInfo.current

    Box(modifier = modifier
        .onGloballyPositioned {  layoutCoordinates -> //Retrieves layout information (position and size) of a composable.
            currentPosition = layoutCoordinates.localToWindow(
                Offset.Zero
            )
        }
        .pointerInput(Unit) {
            detectDragGesturesAfterLongPress(onDragStart = { startOffset->
                currentState.dataToDrop = dataToDrop
                currentState.isDragging = true
                currentState.dragStartOffset = currentPosition + startOffset
                currentState.draggableComposable = content
            }, onDrag = { change, dragAmount ->
                change.consume()
                currentState.dragCurrentOffset += Offset(dragAmount.x, dragAmount.y)
            }, onDragEnd = {
                currentState.isDragging = false
                currentState.dragCurrentOffset = Offset.Zero
            }, onDragCancel = {
                currentState.dragCurrentOffset = Offset.Zero
                currentState.isDragging = false
            })
        }) {
        content()
    }
}

Now lets create the DropTarget composable function to detect any dragging actions near it. This function will take only two argument one is the modifier and other is the content view pass to detect the dragging action. Inside this function we will be using the Box composable to show the drop Target and use the onGloabllyPositioned modifier to get the global position.

@Composable
fun <T> DropTarget(
    modifier: Modifier,
    content: @Composable() (BoxScope.(isInBound: Boolean, data: T?) -> Unit)
) {

    val dragInfo = LocalDraggableItemInfo.current
    val dragPosition = dragInfo.dragStartOffset
    val dragOffset = dragInfo.dragCurrentOffset
    var isCurrentDropTarget by remember {
        mutableStateOf(false)
    }

    Box(modifier = modifier.onGloballyPositioned { layoutCoordinates ->  //Retrieves layout information (position and size) of a composable.
        layoutCoordinates.boundsInWindow().let { rect ->
            isCurrentDropTarget = rect.contains(dragPosition + dragOffset)
        }
    }) {
        val data =
            if (isCurrentDropTarget && !dragInfo.isDragging) dragInfo.dataToDrop as T? else null
        content(isCurrentDropTarget, data)
    }
}

Now we will make our game screen which will be playground for all our views. Inside this screen we will show the both types of views, our Draggables and DropTarget views. In this we will also scale the draggable when user start dragging the view. We will use a Box Composable to show all our view inside a CompositionLocalProvider block to maintain local composition of our views

@Composable
fun GameScreen(
    modifier: Modifier = Modifier,
    content: @Composable BoxScope.() -> Unit
) {
    val state = remember { DraggableItemInfo() }
    CompositionLocalProvider(
        LocalDraggableItemInfo provides state
    ) {
        Box(modifier = modifier.fillMaxSize())
        {
            content()

            //here we will scale the draggable item when user start dragging
            if (state.isDragging) {
                var targetSize by remember {
                    mutableStateOf(IntSize.Zero)
                }
                Box(modifier = Modifier
                    .graphicsLayer {
                        val offset = (state.dragStartOffset + state.dragCurrentOffset)
                        scaleX = 1.3f
                        scaleY = 1.3f
                        alpha = if (targetSize == IntSize.Zero) 0f else .9f
                        translationX = offset.x.minus(targetSize.width / 2)
                        translationY = offset.y.minus(targetSize.height)
                    }
                    .onGloballyPositioned {
                        targetSize = it.size
                    }
                ) {
                    state.draggableComposable?.invoke()
                }
            }
        }
    }
}

We will be creating a screen like this one where at the bottom we have our draggable views whom we can drag after long press and above these are our drop targets. The grey boxes works as drop target means they can detect if some draggables views are being dragged over them. If you place the correct word at the target then it will set to the location and if not then it again moves to its original position.

To show view in this manner we will create a data class which will hold the info of each item and show them through a Array of Array or say grid view. Our data class will take 3 inputs as :

  1. name : String – the text you will show above the box
  2. id : String – this will keep the original text which should be dragged over it. We will use this variable when we drop some view on the target to check if the user has drop the required text box on the location
  3. color – Color of the box
data class DataItem(
    val name: String? = "",
    val id: String? = "",
    val color: Color = Color.Gray
)

Now to create the grid data we first need to understand a logic that if we create a grid of 3 by 3 then we need to show all the 9 boxes. So create our grid data in such a way so that we only show the required boxes. To impliment such a feature we will pass the value of name variable in data item in 3 ways as follows:

  1. “0” – if we set 0 as name then at that location we will not show any box
  2. “Letter”= if we pass any letter then we will show the box with text
  3. “#” – if we pass this character then we will show the drop target and also pass the id value as the expected character at this place.
val gridData: Array<Array<DataItem>> = arrayOf(
    arrayOf(DataItem("0"), DataItem("W", "W"), DataItem("0"), DataItem("A", "")),
    arrayOf(DataItem("G", "G"), DataItem("#", "O"), DataItem("A", "A"), DataItem("#", "T")),
    arrayOf(DataItem("0"), DataItem("N", "N"), DataItem("0"), DataItem("M", ""))
)

Now we need to create a composable function to show our required data from this special grid data array . We will be using the Row composable to show boxes in the row. We will loop through the data and with each iteration show a Row of data and the condition of data item. Then inside this row again we need to loop through the items in its row and compare the name of data. As we earlier decided that we will show the drop target when name is ‘ # ‘ so lets write logic for it.

When we start dragging a draggable view then is should have text name written on it. So we will compare that this name with the id field of our target view. There are 9 boxes for this example and each box has its own index in the grid. The idea is to find the index of the element above which we hover the draggable view so that we can drop the view to target if name matches. We create a function to find the index of any element from the grid.

// Return Pair of indices of the target element from the passed matrix
fun <T> findElementIndex(matrix: Array<Array<T>>, target: T): Pair<Int, Int>? {
    //loop in rows
    for (row in matrix.indices) {
        //loop in columns
        for (column in matrix[row].indices) {
            if (matrix[row][column] == target) {
                return Pair(row, column)  // Found the element, return its indices as a pair
            }
        }
    }
    return null  // Element not found
}

so lets write the rest of the code to show our grid view for our data.

//loop in columns
for (row in dataList) {
    Row(
        modifier = Modifier
            .wrapContentSize()
            .padding(1.dp),
        horizontalArrangement = Arrangement.spacedBy(1.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        //loop in rows
        for (item in row) {
            when (item.name) {
                "#" -> {
                    //Show drop target if applicable
                    DropTarget<DataItem>(
                        modifier = Modifier
                            .size(boxSize)
                    ) { isInBound, personItem ->

                        val color = if (isInBound)
                            Color.Red.copy(0.5f)
                        else
                            Color.Gray.copy(0.2f)

                        personItem?.let {
                            if (isInBound) {
                                val result =
                                    findElementIndex(
                                        data,
                                        DataItem(name = "#", id = personItem.name)
                                    ) ?: Pair(0, 0)

                                //if we didn't find the element then don't show the box at 0,0
                                if (result.first!=0 && result.second!=0){
                                    //we only update dataList if draggable box matches the drop target
                                    dataList = dataList.mapIndexed { rowIndex, row ->
                                        row.mapIndexed { colIndex, element ->
                                            if (rowIndex == result.first && colIndex == result.second) {
                                                DataItem(
                                                    name = personItem.name,
                                                    id = personItem.name,
                                                    Color.Blue.copy(alpha = 0.5f)
                                                )
                                            } else {
                                                element
                                            }
                                        }.toTypedArray()
                                    }.toTypedArray()
                                }

                            }
                        }

                        Box(
                            modifier = Modifier
                                .fillMaxSize()
                                .background(
                                    color,
                                    RoundedCornerShape(15.dp)
                                ),
                            contentAlignment = Alignment.Center
                        ) {
                            if (personItem != null) {
                                Text(
                                    text = personItem.name!!,
                                    style = MaterialTheme.typography.headlineLarge,
                                    color = Color.White
                                )
                            }

                        }
                    }
                }

                "0" -> {
                    //We have to keep this place empty
                    Box(
                        modifier = Modifier
                            .size(boxSize),
                        contentAlignment = Alignment.Center
                    ) {

                    }
                }

                else -> {
                    //Means We have to show the box
                    Box(
                        modifier = Modifier
                            .size(boxSize)
                            .background(
                                item.color,
                                RoundedCornerShape(15.dp)
                            ),
                        contentAlignment = Alignment.Center
                    ) {
                        Text(
                            text = item.name!!,
                            style = MaterialTheme.typography.headlineLarge,
                            color = Color.White
                        )
                    }
                }
            }

        }
    }
}

Now create data list for our draggable views and show them .

//show draggable views
Row(
   modifier = Modifier
              .fillMaxWidth()
              .padding(vertical =60.dp),
   verticalAlignment = Alignment.CenterVertically,
   horizontalArrangement = Arrangement.SpaceEvenly
) {
    dragItems.forEach { person ->
        DraggableView(
            dataToDrop = person
        ) {
            Box(
                modifier = Modifier
                    .size(boxSize)
                    .clip(RoundedCornerShape(15.dp))
                    .background(Color.Green.copy(alpha = 0.5f), RoundedCornerShape(15.dp)),
                contentAlignment = Alignment.Center,
            ) {
                Text(
                    text = person.name!!,
                    style = MaterialTheme.typography.headlineLarge,
                    color = Color.White,
                    fontWeight = FontWeight.SemiBold
                )
            }
        }
    }
}

GitHub gist for this video code : here

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top