Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Item loses drag state when dragged into a different list. #49

Closed
cj3g10 opened this issue Sep 11, 2024 · 9 comments
Closed

Item loses drag state when dragged into a different list. #49

cj3g10 opened this issue Sep 11, 2024 · 9 comments

Comments

@cj3g10
Copy link

cj3g10 commented Sep 11, 2024

I have two list of items inside the same Lazy Layout, and I am trying to set it up so that I can drag an item from one list to the other list.
However when ever I drag an item over to the other list, it seems that the item loses the drag state.

Screen_recording_20240911_172711.webm

Here is the source code for the above demo:

package sh.calvin.reorderable.demo.ui

import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.DragHandle
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.dp
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.demo.Item
import sh.calvin.reorderable.demo.ReorderHapticFeedbackType
import sh.calvin.reorderable.demo.rememberReorderHapticFeedback
import sh.calvin.reorderable.rememberReorderableLazyListState

val items1 = (0..3).map {
    Item(id = 1000 + it, text = "Group A #$it", size = if (it % 2 == 0) 70 else 100)
}
val items2 = (0..3).map {
    Item(id = 2000 + it, text = "Group B #$it", size = if (it % 2 == 0) 70 else 100)
}

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TestMultipleList() {
    val haptic = rememberReorderHapticFeedback()

    var listA by remember { mutableStateOf(items1) }
    var listB by remember { mutableStateOf(items2) }
    val lazyListState = rememberLazyListState()
    val reorderableLazyColumnState = rememberReorderableLazyListState(lazyListState) { from, to ->
        val item = if (from.contentType == "A") {
            listA.first { it.id == from.key }
        } else {
            listB.first { it.id == from.key }
        }

        if (to.contentType == "A") {
            val toIndex = listA.indexOfFirst { it.id == to.key }
            listA = listA.toMutableList().apply {
                remove(item)
                add(toIndex, item)
            }
            listB = listB.toMutableList().apply {
                remove(item)
            }
        } else {
            val toIndex = listB.indexOfFirst { it.id == to.key }
            listA = listA.toMutableList().apply {
                remove(item)
            }
            listB = listB.toMutableList().apply {
                remove(item)
                add(toIndex, item)
            }
        }

        haptic.performHapticFeedback(ReorderHapticFeedbackType.MOVE)
    }

    LazyColumn(
        modifier = Modifier.fillMaxSize(),
        state = lazyListState,
        verticalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        item {
            Text("Header", Modifier.padding(8.dp), MaterialTheme.colorScheme.onBackground)
        }
        items(
            items = listA,
            key = { it.id },
            contentType = { "A" }
        ) { item ->
            ReorderableItem(reorderableLazyColumnState, item.id) {
                val interactionSource = remember { MutableInteractionSource() }

                Card(
                    onClick = {},
                    modifier = Modifier
                        .height(item.size.dp)
                        .padding(horizontal = 8.dp),
                    interactionSource = interactionSource,
                ) {
                    Row(
                        Modifier.fillMaxSize(),
                        horizontalArrangement = Arrangement.SpaceBetween,
                        verticalAlignment = Alignment.CenterVertically,
                    ) {
                        Text(item.text, Modifier.padding(horizontal = 8.dp))
                        IconButton(
                            modifier = Modifier
                                .draggableHandle(
                                    onDragStarted = {
                                        haptic.performHapticFeedback(ReorderHapticFeedbackType.START)
                                    },
                                    onDragStopped = {
                                        haptic.performHapticFeedback(ReorderHapticFeedbackType.END)
                                    },
                                    interactionSource = interactionSource,
                                )
                                .clearAndSetSemantics { },
                            onClick = {},
                        ) {
                            Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
                        }
                    }
                }
            }
        }
        item(
            key = "Split"
        ) {
            Text(
                text = "Split",
                modifier = Modifier.padding(8.dp).animateItem(),
                color = MaterialTheme.colorScheme.onBackground
            )
        }
        items(
            items = listB,
            key = { it.id },
            contentType = { "B" }
        ) { item ->
            ReorderableItem(reorderableLazyColumnState, item.id) {
                val interactionSource = remember { MutableInteractionSource() }

                Card(
                    onClick = {},
                    modifier = Modifier
                        .height(item.size.dp)
                        .padding(horizontal = 8.dp),
                    interactionSource = interactionSource,
                ) {
                    Row(
                        Modifier.fillMaxSize(),
                        horizontalArrangement = Arrangement.SpaceBetween,
                        verticalAlignment = Alignment.CenterVertically,
                    ) {
                        Text(item.text, Modifier.padding(horizontal = 8.dp))
                        IconButton(
                            modifier = Modifier
                                .draggableHandle(
                                    onDragStarted = {
                                        haptic.performHapticFeedback(ReorderHapticFeedbackType.START)
                                    },
                                    onDragStopped = {
                                        haptic.performHapticFeedback(ReorderHapticFeedbackType.END)
                                    },
                                    interactionSource = interactionSource,
                                )
                                .clearAndSetSemantics { },
                            onClick = {},
                        ) {
                            Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
                        }
                    }
                }
            }
        }
        item {
            Text("Footer", Modifier.padding(8.dp), MaterialTheme.colorScheme.onBackground)
        }
    }
}
@Calvin-LL
Copy link
Owner

Thanks for the code. I'll take a look.

@Calvin-LL
Copy link
Owner

Calvin-LL commented Sep 11, 2024

I see, I guess I've never thought much about this use case. It seems like compose's items dispose of an item then recreates a new one when it moves across itemss where the list's length has changed and therefore losing the drag state.

Here's a piece of code that does what you're thinking of. I'll add an example in the README and demo app for this.

Screen_recording_20240911_154348.mp4
package sh.calvin.reorderable.demo.ui

import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.DragHandle
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.dp
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.ReorderableLazyListState
import sh.calvin.reorderable.demo.Item
import sh.calvin.reorderable.demo.ReorderHapticFeedbackType
import sh.calvin.reorderable.demo.rememberReorderHapticFeedback
import sh.calvin.reorderable.rememberReorderableLazyListState

@Composable
fun ComplexReorderableLazyColumnScreen() {
    TestMultipleList()
}

val items1 = (0..3).map {
    Item(id = 1000 + it, text = "Group A #$it", size = if (it % 2 == 0) 70 else 100)
}
val items2 = (0..3).map {
    Item(id = 2000 + it, text = "Group B #$it", size = if (it % 2 == 0) 70 else 100)
}

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TestMultipleList() {
    val haptic = rememberReorderHapticFeedback()

    var listA by remember { mutableStateOf(items1) }
    var listB by remember { mutableStateOf(items2) }

    val lazyListState = rememberLazyListState()
    val reorderableLazyColumnState = rememberReorderableLazyListState(lazyListState) { from, to ->
        val listAMutable = listA.toMutableList()
        val listBMutable = listB.toMutableList()

        val fromList =
            if (listAMutable.firstOrNull { it.id == from.key } != null) listAMutable else listBMutable
        val fromItem = fromList.first { it.id == from.key }

        if (to.key == "Header") {
            if (listA.firstOrNull { it.id == from.key } != null) {
                listBMutable.add(0, fromItem)
            } else {
                listAMutable.add(fromItem)
            }
            fromList.remove(fromItem)
        } else {
            val toList =
                if (listAMutable.firstOrNull { it.id == to.key } != null) listAMutable else listBMutable
            val toIndex = toList.indexOfFirst { it.id == to.key }

            fromList.remove(fromItem)
            toList.add(toIndex, fromItem)
        }

        listA = listAMutable
        listB = listBMutable

        haptic.performHapticFeedback(ReorderHapticFeedbackType.MOVE)
    }

    LazyColumn(
        modifier = Modifier.fillMaxSize(),
        state = lazyListState,
        verticalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        listOf(listA, listB).forEachIndexed { index, list ->
            if (index == 1) {
                item(key = "Header") {
                    ReorderableItem(reorderableLazyColumnState, key = "Header") {
                        Text(
                            "List $index",
                            Modifier
                                .fillMaxWidth()
                                .background(MaterialTheme.colorScheme.secondaryContainer)
                                .padding(8.dp),
                            MaterialTheme.colorScheme.onSecondaryContainer,
                        )
                    }
                }
            }

            items(
                items = list,
                key = { it.id },
            ) { item ->
                ItemCard(item, reorderableLazyColumnState)
            }
        }
        // The following doesn't work for some reason
//        items(
//            items = listA,
//            key = { it.id },
//        ) { item ->
//            ItemCard(item, reorderableLazyColumnState)
//        }
//        item(key = "Header") {
//            ReorderableItem(reorderableLazyColumnState, key = "Header") {
//                Text(
//                    "List $1",
//                    Modifier
//                        .fillMaxWidth()
//                        .background(MaterialTheme.colorScheme.secondaryContainer)
//                        .padding(8.dp),
//                    MaterialTheme.colorScheme.onSecondaryContainer,
//                )
//            }
//        }
//        items(
//            items = listB,
//            key = { it.id },
//        ) { item ->
//            ItemCard(item, reorderableLazyColumnState)
//        }
    }
}

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LazyItemScope.ItemCard(item: Item, reorderableLazyColumnState: ReorderableLazyListState) {
    val haptic = rememberReorderHapticFeedback()

    ReorderableItem(reorderableLazyColumnState, key = item.id) {
        val interactionSource = remember { MutableInteractionSource() }

        Card(
            onClick = {},
            modifier = Modifier
                .height(item.size.dp)
                .padding(horizontal = 8.dp),
            interactionSource = interactionSource,
        ) {
            Row(
                Modifier.fillMaxSize(),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically,
            ) {
                Text(item.text, Modifier.padding(horizontal = 8.dp))
                IconButton(
                    modifier = Modifier
                        .draggableHandle(
                            onDragStarted = {
                                haptic.performHapticFeedback(
                                    ReorderHapticFeedbackType.START
                                )
                            },
                            onDragStopped = {
                                haptic.performHapticFeedback(
                                    ReorderHapticFeedbackType.END
                                )
                            },
                            interactionSource = interactionSource,
                        )
                        .clearAndSetSemantics { },
                    onClick = {},
                ) {
                    Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
                }
            }
        }
    }
}

@Calvin-LL
Copy link
Owner

Calvin-LL commented Sep 11, 2024

Update: the code that is commented out above under // The following doesn't work for some reason actually works in compose for Android

@cj3g10
Copy link
Author

cj3g10 commented Sep 12, 2024

Thanks for your investigation. I had a hunch that the item was being disposed but I had no idea why. In fact, I still don't really understand why your solution of using listOf(listA, listB) and then using forEach to put each list into an items works, since the end result is still the same in that an item in an items is still moved into another items and both list's size still change.

I'm also not sure what you mean by the commented lines work in Compose for Android. My actual project is using AndroidX Compose and not Compose Multiplatform, and the commented lines don't work for me. For reference, I'm using the latest BOM so my Compose version is 1.7.

My actual use case is a grid instead of a list, and after making adjustments in your sample code, I can get this reordering between two lists to work on a LazyVerticalGrid using your demo app.
However I'm still encountering the same issue on my actual project. The difference between the two is that my actual project is not using remember { mutableStateOf() } but storing the lists in a StateFlow inside a ViewModel

And one more thing, when using reordering with a grid, I encountered a UI issue when dragging. But since this issue is unrelated to this, I'll create another thread for this issue. #50 (comment)

@Calvin-LL
Copy link
Owner

Hmm very interesting. I started an new Android project with the latest BOM and it works fine. I also have no clue why one works and the other doesn't since they should be equivalent.

@Calvin-LL
Copy link
Owner

Calvin-LL commented Sep 12, 2024

And I don't know if there's much I can do here since it seems like a compose bug and I can't stop it from being disposed.

@cj3g10
Copy link
Author

cj3g10 commented Sep 12, 2024

Don't worry about it. I just resolved my issue; the issue has nothing to do with StateFlow/remember { mutableStateOf() } but with how I set up my composables.
In my real use case, my items will show different UI depending on which list it's in so the composable is recomposed when it moves between lists.

Thanks for the help. BTW, do you know why your solution of using listOf(listA, listB) works? That's the one part I still don't understand yet.

@cj3g10 cj3g10 closed this as completed Sep 12, 2024
@Calvin-LL
Copy link
Owner

I have no clue. I've been experimenting with different versions of everything to see when the commented out part works/breaks and still haven't found anything. I'm very curious too, it breaks my whole mental model of how compose works.

@Calvin-LL
Copy link
Owner

I've created an issue on the Google Issue Tracker: https://issuetracker.google.com/issues/366123428

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants