Calvin-LL / Reorderable

Reorder items in Lists and Grids in Jetpack Compose and Compose Multiplatform with drag and drop.
Apache License 2.0
488 stars 16 forks source link

Unexpected stop of first item moving in intermediate state. #32

Closed zhelenskiy closed 3 weeks ago

zhelenskiy commented 6 months ago

I have issues with LazyRow when I add more items. The issue happens only when I add items and only when I move the first item.

https://github.com/Calvin-LL/Reorderable/assets/55230817/dabde38e-2ffd-4e35-8ab3-ebf798a0bae2

Here is working example when I move other items.

https://github.com/Calvin-LL/Reorderable/assets/55230817/740e6d75-5806-4ece-8327-cc2b205a5e6e

I'll try to figure out what happens or minimize the example later today, but if you already have any idea what happens, let me now.

The version is 2.0.1, the platform is Desktop. I test it on Mac OS 14.4.1.

Calvin-LL commented 6 months ago

Oh this is very strange. I have no clue how this is happening. I will wait for your minimized example. Is this on the latest version of reorderable?

zhelenskiy commented 6 months ago

Yes

Calvin-LL commented 6 months ago

Did this work with an older version of the library? Also does this have a invisible item before the first item like in #4? I'm assuming so because I don't see a jump after moving the first item.

zhelenskiy commented 6 months ago

No, I don't have any invisible items. I didn't test for older versions. But I reproduced it successfully with a smaller example.

https://github.com/Calvin-LL/Reorderable/assets/55230817/cd95d8a7-f3ef-4410-926f-39d587f9503d

import androidx.compose.animation.*
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.MaterialTheme.colors
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import kotlinx.coroutines.launch
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState

@Composable
@Preview
fun App() {
    MaterialTheme {
        TabRow()
    }
}

fun main() = application {
    Window(onCloseRequest = ::exitApplication) {
        App()
    }
}

typealias Id = Long

data class Tab(val id: Id)

@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun TabRow() {
    var tabModels: List<Tab> by remember { mutableStateOf(listOf(Tab(-1))) }
    val coroutineScope = rememberCoroutineScope()
    val tabRowScrollState = rememberLazyListState()
    val reorderableLazyColumnState =
        rememberReorderableLazyListState(tabRowScrollState) { from, to ->
            coroutineScope.launch {
                tabModels = tabModels.toMutableList().apply {
                    add(to.index, removeAt(from.index))
                }
            }
        }
    var chosenId by remember { mutableStateOf(tabModels.first().id) }
    BoxWithConstraints {
        AnimatedVisibility(
            visible = maxHeight > 300.dp,
            enter = expandVertically(),
            exit = shrinkVertically(),
        ) {
            LazyRow(
                state = tabRowScrollState,
                verticalAlignment = Alignment.CenterVertically,
                modifier = Modifier.fillMaxWidth()
            ) {
                itemsIndexed(
                    tabModels,
                    key = { _, model -> model.id }) { index, tabModel ->
                    ReorderableItem(
                        reorderableLazyColumnState,
                        key = tabModel.id
                    ) { isDragging ->
                        val isSelected = tabModel.id == chosenId
                        val chooseThis = { coroutineScope.launch { chosenId = tabModel.id } }
                        Row(
                            Modifier
                                .height(IntrinsicSize.Min).draggableHandle()
                        ) {
                            Tab(
                                selected = isSelected,
                                enabled = !isSelected,
                                modifier = Modifier
                                    .padding(horizontal = 2.dp)
                                    .clip(RoundedCornerShape(topEnd = 4.dp, topStart = 4.dp)),
                                onClick = { chooseThis() },
                            ) {
                                Box(Modifier.width(IntrinsicSize.Min)) {
                                    val textColor = if (isSelected) Color.Black else Color.Blue
                                    val maxTabWidth = 300.dp
                                    Row(
                                        verticalAlignment = Alignment.CenterVertically,
                                        modifier = Modifier.widthIn(max = maxTabWidth)
                                            .padding(vertical = 4.dp)
                                            .padding(start = 12.dp),
                                    ) {
                                        Spacer(Modifier.width(6.dp))
                                        val textStyle =
                                            TextStyle(color = textColor, fontSize = 18.sp)
                                        Box(Modifier.weight(1f)) {
                                            Text(
                                                text = tabModel.id.toString(),
                                                style = textStyle,
                                                maxLines = 1,
                                                softWrap = false,
                                                modifier = Modifier
                                                    .horizontalScroll(rememberScrollState())
                                            )
                                        }
                                        val expectedCloseSize by animateDpAsState(if (tabModels.size > 1) 48.dp else 8.dp)
                                        Box(Modifier.width(expectedCloseSize)) {
                                            androidx.compose.animation.AnimatedVisibility(
                                                tabModels.size > 1,
                                                enter = scaleIn() + fadeIn(),
                                                exit = scaleOut() + fadeOut(),
                                            ) {
                                                IconButton(
                                                    onClick = {
                                                    },
                                                    enabled = tabModels.size > 1,
                                                ) {
                                                    Icon(
                                                        imageVector = Icons.Default.Close,
                                                        tint = textColor,
                                                        contentDescription = "Close tab"
                                                    )
                                                }
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
                item {
                    IconButton(
                        onClick = {
                            coroutineScope.launch {
                                tabModels += Tab(tabModels.size.toLong())
                            }
                        }
                    ) {
                        Icon(
                            Icons.Default.Add,
                            contentDescription = "Add tab",
                        )
                    }
                }
            }
        }
    }
}
zhelenskiy commented 6 months ago

For 2.0.0 The library seems not to be working properly at all.

https://github.com/Calvin-LL/Reorderable/assets/55230817/e729bc23-1810-4428-b031-1c3efcaac237

zhelenskiy commented 6 months ago

With 2.0.1, the previous case works well.

https://github.com/Calvin-LL/Reorderable/assets/55230817/c2ae048b-5a6e-4f5a-b80a-63857bde593c

Calvin-LL commented 6 months ago

Thank you! I will take a look.

zhelenskiy commented 6 months ago

Just for my internal planning, when are you going to take a look at the issue?

Calvin-LL commented 6 months ago

Just got up. I think I see what was causing it last night, something in my library is causing your composable to constantly recompose. I will fix it in the next 48 hours but likely sooner. Sorry about the long timeline, I'm playing DEF CON CTF Qualifier this weekend.

zhelenskiy commented 6 months ago

Well, my experience shows that average ETA of response in GitHub issues is several days in the best case and several weeks or months in the others. So, having responses within several minutes (7 minutes for the first reply and several for the rest) is insanely fast. Your eta for the fix is also tens of times less than the average. So there're no complaints!

Good luck at the Qualifier! Let all the flags be with you 🙏

Calvin-LL commented 6 months ago

Just released v2.0.3! Let me know if that fixes it. Thank you again for reporting this issue 🙏.

zhelenskiy commented 6 months ago

It partially fixes. It is not stuck at the point anymore, but it stops after exchanging with the next element.

https://github.com/Calvin-LL/Reorderable/assets/55230817/7ff367df-83c4-4f24-a33d-c9e52033e51d

Calvin-LL commented 6 months ago

It partially fixes. It is not stuck at the point anymore, but it stops after exchanging with the next element.

https://github.com/Calvin-LL/Reorderable/assets/55230817/7ff367df-83c4-4f24-a33d-c9e52033e51d

Oh strange I'll take a look.

liltof commented 6 months ago

Hi, I also have the same issue (but with a vertical lazylist), and another one, I'm not sure if it's linked to this issue so I just comment here, maybe it can help: When I try to move an item to the last position, it's impossible, the item doesn't move, there is no unexpected stop but maybe the problem is the same. I can drag the last item to above, but not the before last to bellow Thank you for your time, this library is the best drag and drop lib I found, despite these issues :+1:

Calvin-LL commented 6 months ago

Hi, I also have the same issue (but with a vertical lazylist), and another one, I'm not sure if it's linked to this issue so I just comment here, maybe it can help: When I try to move an item to the last position, it's impossible, the item doesn't move, there is no unexpected stop but maybe the problem is the same. I can drag the last item to above, but not the before last to bellow Thank you for your time, this library is the best drag and drop lib I found, despite these issues 👍

That sounds like a different issue. Can you post a screen recording?

liltof commented 6 months ago

Here it is (the true/false) at the begining is just debug, I was checking "isDraging" to see if I did something wrong

https://github.com/Calvin-LL/Reorderable/assets/3226611/53e38acb-9837-45a1-bbdf-3d902abea86d

Calvin-LL commented 6 months ago

Here it is (the true/false) at the begining is just debug, I was checking "isDraging" to see if I did something wrong

Screen_recording_20240507_193401.mp4

That looks like a different issue. Does the ReorderableItem on the last item have (enable = true)? It would help me debug this if you can provide a simplified version of your code that can reproduce this bug in a new issue 🙏.

liltof commented 6 months ago

No the last one is like the other ones, nothin special: ReorderableItem(reorderableLazyListState, key = item.metaId.hashCode()) {

I will try to make a simple example and come back here

Calvin-LL commented 6 months ago

It partially fixes. It is not stuck at the point anymore, but it stops after exchanging with the next element.

Screen.Recording.2024-05-07.at.16.30.35.mov

Should work in v2.0.4

LOOHP commented 6 months ago

Here it is (the true/false) at the begining is just debug, I was checking "isDraging" to see if I did something wrong

Screen_recording_20240507_193401.mp4

I'm also having this problem where an item couldn't push the bottom item up and when the first item swaps position with the second item, the first item loses the drag. Both in a vertical lazy list. And I've just tried with 2.0.4.

(Upgraded from 1.5.2, which the first item losing drag problem already existed)

liltof commented 6 months ago

Ok I made an example, AND making it I found a workaround So the bottom issue seems to only happen when the last item is at the bottom of the screen (at least on android) So what I did is add a contentPadding to the list (which is exactly what I need in the specs I have so it's ok for me) the contentPadding is commented in the code below But I still have the issue with the top item, even in 2.0.4

package com.example.testsapplication

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.ExperimentalFoundationApi
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.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.testsapplication.ui.theme.TestsApplicationTheme
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            TestsApplicationTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    val listState = rememberLazyListState()
                    var itms by remember {
                        mutableStateOf(
                            listOf(
                            "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o")
                        )
                    }
                    val reorderableLazyListState = rememberReorderableLazyListState(lazyListState = listState) { from, to ->
                        val fromObject = itms[from.index]
                        val toObject = itms[to.index]
                        val newlist = itms.toMutableStateList()
                        newlist[from.index] = toObject
                        newlist[to.index] = fromObject
                        itms = newlist.toList()
                    }

                    LazyColumn(
                        modifier = Modifier
                            .padding(innerPadding)
                            .fillMaxSize(),
                        state = listState, // contentPadding = PaddingValues(bottom = 50.dp)
                    ) {

                        items(items = itms, key = {  item ->
                            item.hashCode()
                        }){ item->
                            ReorderableItem(reorderableLazyListState, key = item.hashCode()) { isDragging ->
                                val elevation = if (isDragging) 4.dp else 0.dp
                                Surface(shadowElevation = elevation) {
                                    Row(modifier = Modifier.fillMaxWidth().longPressDraggableHandle()) {
                                        Text(text = "$isDragging")
                                        Text(text = item, modifier = Modifier
                                            .padding(10.dp)
                                            .height(50.dp)
                                            )
                                    }
                                }

                            }
                        }
                    }
                }
            }
        }
    }
}
zhelenskiy commented 6 months ago

Nothing changed for me with 2.0.4. @Calvin-LL

https://github.com/Calvin-LL/Reorderable/assets/55230817/6e98bb4c-35d2-4d91-be62-89f369dfc1dd

Calvin-LL commented 6 months ago

@zhelenskiy I finally manage to reliably reproduce this bug. I have some mildly bad news. As this library currently works right now there's no way for me to fix it until Compose Foundation v1.7.0 gets into Compose Multiplatform (See #4 and #26). But you can add contentPadding = PaddingValues(horizontal = 1.dp) to the LazyRow as a workaround.

Compose Foundation 1.6.0-alpha08 to 1.6.0 took almost 3 months, the latest Compose Multiplatform 1.6.2 uses Compose Foundation 1.6.4 which is a 20-day gap. So expect Compose Foundation v1.7.0 to be in Compose Multiplatform in around 4 months at the current speed.

Unnecessary Details

When the first item swaps with the second, because of the small size difference in this case, the first item is pushed off the screen. Once the item is off screen, LazyRow disposes that item, dragging state is lost.

The Workaround

Adding a small content padding with contentPadding = PaddingValues(horizontal = 1.dp) keeps a little bit of the dragging item on screen so the dragging state is not lost.

liltof commented 6 months ago

That solution is OK for me, it also works with my LazyColumn (with a top contentPadding to 1.dp), I'll wait for the new Compose Foundation just to have the final solution but for the moment that's good. thanks! :+1:

LOOHP commented 6 months ago

The workaround works great, solved both top and bottom issues. Would be nice if I knew this sooner. Thanks!

zhelenskiy commented 6 months ago

Thank you for the workaround! However, I have another issue. When I start moving a tab backwards and forwards a lot, it disappears at some point in time. And find it always being placed at position 1.

https://github.com/Calvin-LL/Reorderable/assets/55230817/203b4552-1e38-4b9c-a7e8-848cd42b6291

Calvin-LL commented 6 months ago

I never considered the case where an item could be dragged off the viewport. (The gesture detector keeps reporting the items position even when it's outside the window) I will fix that.

zhelenskiy commented 6 months ago

Again, just for internal planning, is there any ETA?

Calvin-LL commented 6 months ago

I'll get it sorted out before the end of this week

Calvin-LL commented 6 months ago

@zhelenskiy try v2.1.0. If you try really hard you can still get an item to lose the dragging state by dragging it wildly outside the window back and forth, that's another bug that will be fixed when Compose Foundation v1.7.0 is out. Thank you so much for finding all these bugs.

zhelenskiy commented 6 months ago

Thanks! Is it also because of the same change in 1.7.0 or is the reason different?

Calvin-LL commented 6 months ago

Yes for the same reason as above. Even with contentPadding, if the conditions are just right, the same thing happens. It's just that when you drag the first visible item slowly, the scroll it triggers will most likely show the -1th visible item just enough to avoid losing the dragging state.

zhelenskiy commented 6 months ago

So, I'm looking for the update for the version of Foundation to become available! Thanks. Are you going to try using alpha versions for the library?

Calvin-LL commented 6 months ago

So, I'm looking for the update for the version of Foundation to become available! Thanks. Are you going to try using alpha versions for the library?

As far as I can't tell there's no way to use any other versions of Compose Foundation than the one that is bundled into Compose Multiplatform 😭. If this library was Android only then I would be able to release an alpha version with the foundation alpha.

zhelenskiy commented 6 months ago

I'm not sure, but it seems you can add the Compose dependencies explicitly, specifying their version in version catalogs. However, if there are no multiplatform artifacts, then we can do nothing 😿.

Calvin-LL commented 5 months ago

Good news! v1.7.0 is being released in June according to https://android-developers.googleblog.com/2024/05/whats-new-in-jetpack-compose-at-io-24.html, we should see v1.7.0 land in Compose Multiplatform some time in June/July.

Calvin-LL commented 3 months ago

I just released 2.4.0-alpha01 yesterday. It uses v1.7.0 so it should fix this issue. I'm waiting for Compose Multiplatform v1.7.0 to be stable to release a stable version.

zhelenskiy commented 3 months ago

Hi! I see the same issue reproducible in 2.3.0.

Calvin-LL commented 3 months ago

Can you share a screen recording?

zhelenskiy commented 3 months ago

https://github.com/user-attachments/assets/d83d96f6-9ee3-48b5-9934-2e5cdca5f01a

Calvin-LL commented 3 months ago

Any chance you're using launch in onMove? Try removing the launch.

https://github.com/Calvin-LL/Reorderable?tab=readme-ov-file#faq

zhelenskiy commented 3 months ago

Seems no chance.

Calvin-LL commented 3 months ago

Which version did this start happeneing? I haven't changed the core functions for a while. If this is recent, it might be an issue with compose.

zhelenskiy commented 3 months ago

I don't know, in this project I started with 2.3.0.

Calvin-LL commented 3 months ago

I'm so sorry to have to ask you once again for a minimal reproducible example 😢 . I can't seem to trigger this issue on my end.

zhelenskiy commented 3 months ago

One second, I'm polishing the rest, will supply within 30 minutes.

zhelenskiy commented 3 months ago

It took more time than expected, so I will send it later today.

Calvin-LL commented 3 months ago

Thank you ❤️

zhelenskiy commented 3 months ago

Check https://github.com/zhelenskiy/PersonalVault there are actually two artifacts of using reorderable. One is this, the second one is that some items start jumping vertically on appearance. If I remove reorderable, everything works fine.

https://github.com/user-attachments/assets/fc40a651-6e5e-4af3-bccc-fd86cb131f10

The more exact place is this one. To reproduce it, you need to add press '+' and add some folders and files in them.

Calvin-LL commented 3 months ago

Thanks. I will try to figure this out tomorrow.