Calvin-LL / Reorderable

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

Animation issue when swapping the first item in a lazy layout #4

Closed MiniEmerald closed 2 weeks ago

MiniEmerald commented 10 months ago

In the simple lazy column sample, swapping the first item instantly moves most of it outside the screen for a split second.

Tested in the android emulator on Android 14

Screen_recording_20231212_215838.webm

Calvin-LL commented 10 months ago

Unfortunately that is one of the drawbacks of using .animateItemPlacement(). This happens whenever the position of the first visible item it changed.

I'll leave this issue open to see if anyone can find a solution.

Details

This video shows what happens when the first and second items are swapped in a LazyColumn where every item that has .animateItemPlacement().

https://github.com/Calvin-LL/Reorderable/assets/8357970/2ecf60a8-f9fa-4a92-ba1f-ffe5f7db4cb8

As you can see, the position of Item #0 stays the same while all the other items move around it.

Turns out .animateItemPlacement() will always keep the first visible item in place as an anchor.

But when we are dragging Item #1 and swapping it with Item #0, we don't want Item #1 to disappear above Item #0.

So I decided to do what this Android demo does in here. When an item is dragged to replace the first visible item:

  1. scroll to the item that will replace the first visible item so that it will remain the first visible item after the swap (in the case in your video, that's Item #1)
  2. call the callback that will actually swap the dragging item with the first visible item

And similarly, when the first visible item is dragged to replace another item:

  1. scroll to the target item so that it will remain the first visible item after the swap
  2. call the callback that will actually swap the first visible item with the target item

All this means that whenever the position of the first visible item changes, items will move around in a weird way.

spiral123 commented 10 months ago

Firstly, thanks for the library - it's very useful.

I have a workaround for the visual glitching when moving over the first item.

In my app I'm adding a dummy element as the first item in the reorderable list. When I render the list I ensure that the dummy ReorderableItem has enabled = false and render it as a 1.dp high empty row. The remaining list items are resolved normally.

For example, Items.kt now becomes:

data class Item(val id: UUID = UUID.randomUUID(), val sequence: Int, val size: Int)

val items = (-1..200).map {
    Item(sequence = it, size = if (it % 2 == 0) 50 else 100)
}

and SimpleReorderableLazyColumnScreen.kt would be:

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SimpleReorderableLazyColumnScreen() {
  val view = LocalView.current

  var list by remember { mutableStateOf(items) }
  val lazyListState = rememberLazyListState()
  val reorderableLazyColumnState = rememberReorderableLazyColumnState(lazyListState) { from, to ->
    list = list.toMutableList().apply {
      add(to.index, removeAt(from.index))
    }

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
      view.performHapticFeedback(HapticFeedbackConstants.SEGMENT_FREQUENT_TICK)
    }
  }

  LazyColumn(
    modifier = Modifier.fillMaxSize(),
    state = lazyListState,
    contentPadding = PaddingValues(8.dp),
    verticalArrangement = Arrangement.spacedBy(8.dp)
  ) {
    items(list, key = { it.id }) {
      ReorderableItem(
        reorderableLazyListState = reorderableLazyColumnState,
        key = it.id,
        enabled = it.sequence > -1,
      ) { isDragging ->
        val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)

        if (it.sequence < 0) {
          Row(modifier = Modifier.height(1.dp)) { }
        } else {
          Card(
            modifier = Modifier.height(it.size.dp),
            shadowElevation = elevation,
          ) {
            Text("Item #${it.sequence}", Modifier.padding(horizontal = 8.dp))
            IconButton(
              modifier = Modifier.draggableHandle(
                onDragStarted = {
                  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
                    view.performHapticFeedback(HapticFeedbackConstants.DRAG_START)
                  }
                },
                onDragStopped = {
                  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
                    view.performHapticFeedback(HapticFeedbackConstants.GESTURE_END)
                  }
                },
              ),
              onClick = {},
            ) {
              Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
            }
          }
        }
      }
    }
  }
}
dhng22 commented 9 months ago

@spiral123 's workaround was right, i made a shorter solution for the LazyList:

val reorderableLazyListState = rememberReorderableLazyColumnState { from, to ->
        currentList.swapPosition(from.index - 1, to.index - 1)
        ...
}
LazyColumn(modifier = modifier,
           state = reorderableLazyListState.state) {
    item {
        ReorderableItem(reorderableLazyListState,
                        "dummy",
                        enabled = false,
                        modifier = Modifier.fillMaxWidth().height(Dp.Hairline)) {}
    }
    items(currentList, key = { it.id }) { item ->
        ReorderableItem(reorderableLazyListState, item.id) {
            Item(item) { 
                  ...
            }
        }
    }
 }

Make a dummy item with enable = false then minus from and to index by 1 and you're good to go

PatricioRios commented 7 months ago

Can't you make the first element (partially not visible), non-draggable?, I have used the dhng22 solution, but even so when I move the lazy a little the problem still appears

mak1nt0sh commented 7 months ago

Is this the issue? It just got marked as fixed, so maybe the patch will be live soon https://issuetracker.google.com/issues/209652366

Calvin-LL commented 7 months ago

Is this the issue? It just got marked as fixed, so maybe the patch will be live soon https://issuetracker.google.com/issues/209652366

Thank you for sharing this. I think this is the issue. I will update the library when this comes out. (the issue you mentioned took more than 2 years to get a fix ☠️)

vganin commented 6 months ago

Can't you make the first element (partially not visible), non-draggable?, I have used the dhng22 solution, but even so when I move the lazy a little the problem still appears

Dp.Hairline isn't working for me either, but 1.dp does. Probably Compose at that time wasn't optimising empty layout nodes out, but now it does. Also, we don't need extra disabled ReorderableItem, dummy view is enough. So, simplifying it even further, this worked for me:

item {
    Spacer(Modifier.height(1.dp))
}

And the rest is the same.

Calvin-LL commented 6 months ago

Is this the issue? It just got marked as fixed, so maybe the patch will be live soon https://issuetracker.google.com/issues/209652366

Thank you for sharing this. I think this is the issue. I will update the library when this comes out. (the issue you mentioned took more than 2 years to get a fix ☠️)

Looks like this will be released in Compose Foundation v1.7.0 which is currently in alpha. https://developer.android.com/jetpack/androidx/releases/compose-foundation#1.7.0-alpha07

I'll update this library once v1.7.0 make it to compose multiplatform.

zhelenskiy commented 5 months ago

Is it this problem or not?

https://github.com/Calvin-LL/Reorderable/assets/55230817/22684d9a-228f-4391-9ee7-5e54e10b7bf0

Calvin-LL commented 5 months ago

That doesn't look right. Can you share a minimal reproducible example?

zhelenskiy commented 5 months ago

https://github.com/Calvin-LL/Reorderable/assets/55230817/bfe28bd1-6f39-41c8-94e1-fda388d65eea

It can be partially reproduced with my previous example if you decrease the size of the window.

zhelenskiy commented 5 months ago

https://github.com/Calvin-LL/Reorderable/assets/55230817/166036b1-c91d-4ce3-baf8-0476d0bbd560

Or you can just make the tabs wider:

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.items
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(),
                contentPadding = PaddingValues(horizontal = 1.dp),
            ) {
                items(tabModels, key = { model -> model.id }) { tabModel ->
                    ReorderableItem(
                        reorderableLazyColumnState,
                        key = tabModel.id
                    ) { _ ->
                        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))
                                    .width(200.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 5 months ago

It also scrolls in this case for some reason.

https://github.com/Calvin-LL/Reorderable/assets/55230817/223bc73e-93f9-4e3e-8f90-db3437336dcd

Calvin-LL commented 5 months ago

@zhelenskiy try 2.1.1-dev. I think this should be fixed there. Just published it so it might take a few minutes to be available.

zhelenskiy commented 5 months ago

Still reproducible there

Calvin-LL commented 5 months ago

Just the flashing or still can't move the second item to the first position?

zhelenskiy commented 5 months ago

https://github.com/Calvin-LL/Reorderable/assets/55230817/5f4309a2-8ea7-4d03-8660-ed9a89e9dcb9

zhelenskiy commented 5 months ago

By the way, the version is not available on GitHub.

Calvin-LL commented 5 months ago

Hmm I will investigate further tomorrow. It's not on GitHub because I made it just for you ❤️.

Calvin-LL commented 5 months ago

@zhelenskiy give 2.1.1-dev2 a try. You should be able to drag the second item to the first position now.

zhelenskiy commented 5 months ago

I can. But it is still jumping and scrolling right.

https://github.com/Calvin-LL/Reorderable/assets/55230817/b94951e9-77f5-4b01-bb2b-2a7ab07871a2

Calvin-LL commented 5 months ago

Yeah I think that's the expected behavior right now. Will be fixed when Foundation v1.7.0

zhelenskiy commented 5 months ago

Both scrolling and jumping? Is there any workaround?

zhelenskiy commented 5 months ago

When you have a little of space, it is still almost impossible to move the tab. It starts scrolling too fast, may drop dragging.

https://github.com/Calvin-LL/Reorderable/assets/55230817/5bfb3d72-0961-4404-b807-fe882d2d7bfa

Calvin-LL commented 5 months ago

Both scrolling and jumping? Is there any workaround?

Yeah they jumping will happen whenever the first item moves

Calvin-LL commented 5 months ago

When you have a little of space, it is still almost impossible to move the tab. It starts scrolling too fast, may drop dragging.

Oh hmm I will need to think about this one. I think I can make the drag speed depend on the item size.

Calvin-LL commented 5 months ago

When you have a little of space, it is still almost impossible to move the tab. It starts scrolling too fast, may drop dragging.

I'll get back to you by the end of the upcoming Friday.

Calvin-LL commented 5 months ago

@zhelenskiy should be fixed in 2.1.1. Thanks again for finding these bugs ❤️ .

zhelenskiy commented 5 months ago

It is much better! But it still drops drag when I try to move the first item.

https://github.com/Calvin-LL/Reorderable/assets/55230817/f4ec840f-6d69-45ba-8251-bd3cc7ed53ec

Calvin-LL commented 5 months ago

It is much better! But it still drops drag when I try to move the first item.

Yeah so the problem is it's either this or scrolling too fast as in https://github.com/Calvin-LL/Reorderable/issues/4#issuecomment-2105485414. It's caused by the fact that moving the first visible item causes strange things to happen. This too will be solved in Foundation 1.7.0.

zhelenskiy commented 5 months ago

Ok, thanks! Let's wait then.

zhelenskiy commented 5 months ago

Moving the first item does not work on Android.

https://github.com/Calvin-LL/Reorderable/assets/55230817/d1b27523-ce01-4ac4-b707-620b2f9e616d

Calvin-LL commented 5 months ago

~Is onMoved called at all?~ Is there a chance the wrong item is being moved in onMove? Looks like the "Foxing" tab is being moved instead.

zhelenskiy commented 5 months ago

Yes, it seems to be what happened.

zhelenskiy commented 5 months ago

I logged move indexes during this strange movement: 0 -> 1 0 -> 2

Calvin-LL commented 5 months ago

Is there a coroutine launch in onMove? Try if removing that helps.

zhelenskiy commented 5 months ago

What do you expect me to write then?

rememberReorderableLazyListState(tabRowScrollState) { from, to ->
    println(from.index to to.index)
    coroutineScope.launch {
        viewModel.move(from.index, to.index)
    }
}
Calvin-LL commented 5 months ago

Try

rememberReorderableLazyListState(tabRowScrollState) { from, to ->
    println(from.index to to.index)
    viewModel.move(from.index, to.index)
}
zhelenskiy commented 5 months ago

Oh, it is already suspend.

zhelenskiy commented 5 months ago

Now that works fine! Thanks!

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.

Lawand-11 commented 4 months ago

Is that going to fix this issue? If so, that's great since I plan to launch my app in August, and it uses your library. Appreciate all the hard work!

Calvin-LL commented 4 months ago

Is that going to fix this issue? If so, that's great since I plan to launch my app in August, and it uses your library. Appreciate all the hard work!

Yes this issue will be fixed when Compose Foundation v1.7.0 is out.

idineshgovind commented 4 months ago

Possible to get beta release from your side? with compose "v1.7.0-beta02", TIA!

Calvin-LL commented 4 months ago

Possible to get beta release from your side? with compose "v1.7.0-beta02", TIA!

I would love to but it's not possible. We'll have to wait for foundation to land in compose multiplatform.

See https://github.com/Calvin-LL/Reorderable/issues/32#issuecomment-2103413672

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.

Calvin-LL commented 2 weeks ago

v2.4.0 is out