androidkotlinandroid-jetpack-compose

Jetpack Compose: Drag and Drop Tasks Return to Original Column - How to Fix?


I am developing a test application with Jetpack Compose using drag and drop functionalities. I have 3 tasks that are in each assigned column: To Do, In Progress, and Done. When I try to move any task to another column, it changes but then returns to the initial column. What could be the issue?

Here is my code on GitHub: GitHub Repository

DragAndDrop.kt

internal val LocalDragTargetIfo = compositionLocalOf{ DragTargetInfo() }
@Composable
fun DragableScreen(modifier: Modifier = Modifier,content: @Composable BoxScope.() -> Unit){
    val state= remember {
        DragTargetInfo()
    }
    CompositionLocalProvider(LocalDragTargetIfo provides state){
        Box(modifier=Modifier.fillMaxSize()){
            content()
            if(state.isDagging){
                var targetSize by remember {
                    mutableStateOf(IntSize.Zero)
                }
                Box(
                    modifier = Modifier
                        .graphicsLayer {
                            val offset = (state.dragPosition + state.dragOffset)
                            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 / 2)
                        }
                        .onGloballyPositioned {
                            targetSize = it.size
                        }
                ){
                    state.draggableComposable?.invoke()
                }
            }
        }
    }
}
@Composable
fun <T> DragTrarget(
    modifier: Modifier = Modifier,
    datatoDrop: T,
    viewModel: MainViewModel,
    content: @Composable () -> Unit
) {
    var currentPosition by remember { mutableStateOf(Offset.Zero) }
    val currentState = LocalDragTargetIfo.current

    Box(
        modifier = modifier
            .onGloballyPositioned { currentPosition = it.localToWindow(Offset.Zero) }
            .pointerInput(Unit) {
                detectDragGesturesAfterLongPress(
                    onDragStart = {
                        viewModel.startDragging()
                        currentState.dataToDrop = datatoDrop
                        currentState.isDagging = true
                        currentState.dragPosition = currentPosition + it
                        currentState.draggableComposable = content
                    },
                    onDrag = { change, dragAmount ->
                        change.consume()
                        currentState.dragOffset += Offset(dragAmount.x, dragAmount.y)
                    },
                    onDragEnd = {
                        viewModel.stopDragging()
                        currentState.dragOffset = Offset.Zero
                        currentState.isDagging = false
                    },
                    onDragCancel = {
                        viewModel.stopDragging()
                        currentState.dragOffset = Offset.Zero
                        currentState.isDagging = false
                    }
                )
            }
    ) {
        content()
    }
}
@Composable
fun <T> DropItem(
    modifier: Modifier,
    content: @Composable BoxScope.(isInBound: Boolean, data: T?) -> Unit
) {
    val dragInfo = LocalDragTargetIfo.current
    val dragPosition = dragInfo.dragPosition
    val dragOffset = dragInfo.dragOffset
    var isCurretDropTarget by remember { mutableStateOf(false) }

    Box(
        modifier = modifier
            .background(Color.Red)
            .onGloballyPositioned {
                it.boundsInWindow().let { rect ->
                    isCurretDropTarget = rect.contains(dragPosition + dragOffset)
                }
            }
    ) {
        val data = if (isCurretDropTarget && !dragInfo.isDagging) {
            dragInfo.dataToDrop as T?
        } else {
            null
        }

        // Solo ejecuta el contenido si el arrastre ha terminado y está en el área de soltar
        if (!dragInfo.isDagging && isCurretDropTarget && data != null) {
            content(true, data)
        } else {
            content(false, null)
        }
    }
}
internal class DragTargetInfo {
    var isDagging:Boolean by mutableStateOf(false)
    var dragPosition by mutableStateOf(Offset.Zero)
    var dragOffset by mutableStateOf(Offset.Zero)
    var draggableComposable by mutableStateOf <(@Composable () -> Unit)?>(null)
    var dataToDrop by mutableStateOf<Any?>(null)
}

MainViewModel.kt

class MainViewModel : ViewModel() {
    var isCurrentlyDragging by mutableStateOf(false)
        private set

    val items = mutableStateListOf<Task>()

    var addedTasks = mutableStateListOf<Task>()
        private set
    fun addExampleTasks() {
        items.addAll(listOf(
            Task(1, "Task 1","Description 1", TaskStatus.TO_DO),
            Task(2, "Task 2","Description 2", TaskStatus.IN_PROGRESS),
            Task(3, "Task 3","Description 3",  TaskStatus.DONE)
        ))

    }

    fun startDragging() {
        isCurrentlyDragging = true
    }

    fun stopDragging() {
        isCurrentlyDragging = false
    }

    fun addTask(task: Task) {
        items.add(task)
    }

    fun updateTaskStatus(task: Task, newStatus: TaskStatus) {
        val index = items.indexOfFirst { it.id == task.id }
        if (index != -1) {
            val currentTask = items[index]
            if (currentTask.status != newStatus) {
                println("Updating task ${task.id} from ${currentTask.status} to $newStatus")
                // Actualizar el estado directamente
                items[index] = currentTask.copy(status = newStatus)
            }
        }
    }

}

MainScreen.kt

@Composable
fun MainScreen(mainViewModel: MainViewModel = viewModel()) {
    // Observar directamente el estado de items
    val tasks = mainViewModel.items
    // Llamar a un método para añadir tareas de ejemplo (solo si es necesario)
    LaunchedEffect(Unit) {
        if (tasks.isEmpty()) {
            mainViewModel.addExampleTasks()
        }
    }
    Row(modifier = Modifier.fillMaxSize()) {
        TaskColumn(
            title = "To Do",
            tasks = tasks.filter { it.status == TaskStatus.TO_DO },
            onTaskDropped = { task ->
                mainViewModel.updateTaskStatus(task, TaskStatus.TO_DO)
            },
            viewModel = mainViewModel,
            modifier = Modifier.weight(1f)
        )
        TaskColumn(
            title = "In Progress",
            tasks = tasks.filter { it.status == TaskStatus.IN_PROGRESS },
            onTaskDropped = { task ->
                mainViewModel.updateTaskStatus(task, TaskStatus.IN_PROGRESS)
            },
            viewModel = mainViewModel,
            modifier = Modifier.weight(1f)
        )
        TaskColumn(
            title = "Done",
            tasks = tasks.filter { it.status == TaskStatus.DONE },
            onTaskDropped = { task ->
                mainViewModel.updateTaskStatus(task, TaskStatus.DONE)
            },
            viewModel = mainViewModel,
            modifier = Modifier.weight(1f)
        )
    }
}
@Composable
fun TaskColumn(
    title: String,
    tasks: List<Task>,
    onTaskDropped: (Task) -> Unit,
    viewModel: MainViewModel,  // Asegúrate de recibir el ViewModel
    modifier: Modifier = Modifier
) {
    Column(
        modifier = modifier
            .fillMaxHeight()
            .padding(8.dp)
            .border(1.dp, Color.Black, shape = RoundedCornerShape(8.dp))
    ) {
        Text(title, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(8.dp))

        tasks.forEach { task ->
            DragTrarget(
                modifier = Modifier.wrapContentWidth().padding(4.dp),
                datatoDrop = task,
                viewModel = viewModel  // Pasar el ViewModel aquí
            ) {
                TaskItem(task)
            }
        }

        DropItem<Task>(
            modifier = Modifier.fillMaxSize()
        ) { isInBound, droppedTask ->
            if (isInBound && droppedTask != null) {
                println("Task dropped: ${droppedTask.title} to $title")
                onTaskDropped(droppedTask)
            }
        }
    }
}

@Composable
fun TaskItem(task: Task) {
    Box(
        modifier = Modifier
            .wrapContentWidth()
            .padding(8.dp)
            .background(Color.LightGray, shape = RoundedCornerShape(8.dp)) // Aquí se aplica el radio
            .border(1.dp, Color.LightGray, shape = RoundedCornerShape(8.dp)) // Asegúrate de aplicar el mismo shape al borde
            .padding(8.dp)
    ) {
        Column {
            Text("${task.title}:")
            Text("${task.description}")
        }
    }
}

Task.kt

data class Task (val id: Int, val title: String, val description: String, var status: TaskStatus)

enum class TaskStatus{
    TO_DO,IN_PROGRESS,DONE
}

Solution

  • You don’t need to build drag and drop functionality from scratch in jetpack compose because it supports this natively.

    I’ve updated your code to use compose drag and drop features and also made the code a bit simpler and more concise.

    Here’s my version:

    import android.content.ClipData
    import android.content.ClipDescription
    import android.os.Bundle
    import androidx.activity.ComponentActivity
    import androidx.activity.compose.setContent
    import androidx.activity.enableEdgeToEdge
    import androidx.compose.foundation.ExperimentalFoundationApi
    import androidx.compose.foundation.background
    import androidx.compose.foundation.border
    import androidx.compose.foundation.draganddrop.dragAndDropSource
    import androidx.compose.foundation.draganddrop.dragAndDropTarget
    import androidx.compose.foundation.gestures.detectTapGestures
    import androidx.compose.foundation.layout.Arrangement
    import androidx.compose.foundation.layout.Column
    import androidx.compose.foundation.layout.Row
    import androidx.compose.foundation.layout.Spacer
    import androidx.compose.foundation.layout.fillMaxHeight
    import androidx.compose.foundation.layout.fillMaxSize
    import androidx.compose.foundation.layout.height
    import androidx.compose.foundation.layout.padding
    import androidx.compose.foundation.shape.RoundedCornerShape
    import androidx.compose.material3.Scaffold
    import androidx.compose.material3.Text
    import androidx.compose.runtime.Composable
    import androidx.compose.runtime.key
    import androidx.compose.runtime.mutableStateListOf
    import androidx.compose.runtime.remember
    import androidx.compose.ui.Alignment
    import androidx.compose.ui.Modifier
    import androidx.compose.ui.draganddrop.DragAndDropEvent
    import androidx.compose.ui.draganddrop.DragAndDropTarget
    import androidx.compose.ui.draganddrop.DragAndDropTransferData
    import androidx.compose.ui.draganddrop.mimeTypes
    import androidx.compose.ui.draganddrop.toAndroidDragEvent
    import androidx.compose.ui.graphics.Color
    import androidx.compose.ui.input.key.Key
    import androidx.compose.ui.unit.dp
    import androidx.lifecycle.ViewModel
    import com.abdo21.mydragapp.ui.theme.MyDragAppTheme
    import androidx.lifecycle.viewmodel.compose.viewModel
    
    
    class MainActivity : ComponentActivity() {
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            enableEdgeToEdge()
            setContent {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    App(modifier = Modifier
                        .padding(innerPadding)
                    )
                }
            }
        }
    }
    
    enum class QueueType(val title: String) {
        TodoQueue("To Do"),
        InProgressQueue("In Progress"),
        DoneQueue("Done")
    }
    
    
    data class Task(
        val id: Int,
        val title: String,
        val description: String
    )
    
    class MyViewModel: ViewModel() {
    
        val todoTasks = mutableStateListOf<Task>(
            Task(id = 1, title = "Task 1", description = "Description 1"),
            Task(id = 2, title = "Task 4", description = "Description 4"),
        )
    
        val inProgressTasks = mutableStateListOf<Task>(
            Task(id = 3, title = "Task 2", description = "Description 2"),
            Task(id = 4, title = "Task 5", description = "Description 5"),
        )
    
        val doneTasks = mutableStateListOf<Task>(
            Task(id = 5, title = "Task 3", description = "Description 3"),
        )
    
        fun moveTask(
            task: Task,
            from: QueueType,
            to: QueueType
        ) {
            when(from) {
                QueueType.TodoQueue -> todoTasks.remove(task)
                QueueType.InProgressQueue -> inProgressTasks.remove(task)
                QueueType.DoneQueue -> doneTasks.remove(task)
            }
            when(to) {
                QueueType.TodoQueue -> todoTasks.add(task)
                QueueType.InProgressQueue -> inProgressTasks.add(task)
                QueueType.DoneQueue -> doneTasks.add(task)
            }
        }
    }
    
    data class DraggedData(
        val task: Task,
        val originQueue: QueueType,
    )
    
    @OptIn(ExperimentalFoundationApi::class)
    @Composable
    fun TaskCard(
        task: Task,
        queueType: QueueType,
        modifier: Modifier = Modifier
    ) {
        Column(
            modifier = modifier
                .dragAndDropSource {
                    detectTapGestures(
                        onLongPress = {
                            startTransfer(
                                DragAndDropTransferData(
                                    ClipData.newPlainText("Task", "Task"),
                                    localState = DraggedData(task, queueType)
                                )
                            )
                        }
                    )
                }
                .background(
                    color = Color.LightGray,
                    shape = RoundedCornerShape(8.dp)
                )
                .padding(vertical = 4.dp),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            Text(text = task.title)
            Text(text = task.description)
        }
    }
    
    
    @OptIn(ExperimentalFoundationApi::class)
    @Composable
    fun QueueTask(
        queueType: QueueType,
        tasks: List<Task>,
        modifier: Modifier = Modifier,
        onTaskDrop: (Task, QueueType) -> Unit
    ) {
        val dropTarget = remember {
            object : DragAndDropTarget {
                override fun onDrop(dragStartEvent: DragAndDropEvent): Boolean {
                    val dropEvent = dragStartEvent.toAndroidDragEvent()
                    val draggedTaskData = dropEvent.localState as? DraggedData
                    val droppedTask = draggedTaskData?.task
                    droppedTask?.let { onTaskDrop(it, draggedTaskData.originQueue) }
                    return true
                }
            }
        }
    
        Column(
            modifier = modifier
                .dragAndDropTarget (
                    shouldStartDragAndDrop = { dragStartEvent ->
                        dragStartEvent.mimeTypes().contains(ClipDescription.MIMETYPE_TEXT_PLAIN)
                    }, target = dropTarget
                )
                .border(
                    width = 1.dp,
                    color = Color.Black,
                    shape = RoundedCornerShape(8.dp)
                )
                .fillMaxHeight(),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text(text = queueType.title)
            Spacer(modifier = Modifier.height(4.dp))
            tasks.forEach { task ->
                key(task.id) {
                    TaskCard(
                        task = task,
                        queueType = queueType,
                        modifier = Modifier
                            .padding(vertical = 4.dp)
                    )
                }
            }
        }
    }
    
    @Composable
    fun App(
        modifier: Modifier = Modifier,
        viewModel: MyViewModel = viewModel()
    ) {
    
        val todoTasks = viewModel.todoTasks
        val inProgressTasks = viewModel.inProgressTasks
        val doneTasks = viewModel.doneTasks
    
        Row(
            modifier = modifier,
            horizontalArrangement = Arrangement.spacedBy(10.dp)
        ) {
            QueueTask(
                queueType = QueueType.TodoQueue,
                tasks = todoTasks,
                modifier = Modifier.weight(1f),
                onTaskDrop = { task, fromQueueType ->
                    viewModel.moveTask(task, fromQueueType, QueueType.TodoQueue)
                }
            )
            QueueTask(
                queueType = QueueType.InProgressQueue,
                tasks = inProgressTasks,
                modifier = Modifier.weight(1f),
                onTaskDrop = { task, fromQueueType ->
                    viewModel.moveTask(task, fromQueueType, QueueType.InProgressQueue)
                }
            )
            QueueTask(
                queueType = QueueType.DoneQueue,
                tasks = doneTasks,
                modifier = Modifier.weight(1f),
                onTaskDrop = { task, fromQueueType ->
                    viewModel.moveTask(task, fromQueueType, QueueType.DoneQueue)
                }
            )
        }
    }
    

    Demo:

    Demo