avatarElye

Summary

The article discusses the author's experience and lessons learned while debugging the SwipeToDismiss feature in Jetpack Compose over three days, highlighting common pitfalls and their solutions.

Abstract

The author shares insights gained from the challenging process of debugging the SwipeToDismiss gesture in Jetpack Compose. Initially, the author encountered issues such as multiple items being removed due to incorrect placement of dismiss state checks and the removal of the wrong list item due to missing unique keys in a LazyColumn. The article also addresses a bug where the dismiss action referred to an outdated item, leading to crashes, and provides a fix using rememberUpdatedState. Additionally, the author offers optimization tips for the SwipeToDismiss background rendering and emphasizes the importance of setting item backgrounds and programmatically scrolling to the top of a list after data updates to maintain a consistent user experience.

Opinions

  • The author believes that it is crucial to avoid placing logical operations in code that is executed on every recomposition, suggesting the use of remember lambdas instead.
  • They advocate for the explicit assignment of unique keys to items in a LazyColumn to ensure correct state management during item removal or swapping.
  • The author points out that relying on the screen background color for UI components can lead to design issues and recommends setting explicit background colors.
  • They highlight the necessity of using rememberUpdatedState to ensure that the correct item is referenced after data updates, preventing potential crashes.
  • The author suggests that some UI code should be conditionally executed to optimize performance, such as avoiding unnecessary rendering of the SwipeToDismiss background when not in use.
  • They stress the importance of considering the user's perspective in UI design, particularly in ensuring that the UI behaves intuitively after data refreshes.

Learning Android Development

Lessons Learned After 3 Days Debugging Jetpack Compose SwipeToDismiss

Lots of Useful Jetpack Compose and Design Relevant Learning Obtained After The Pain of Finding Them Out

Photo by Tim Gouw on Unsplash

Swipe to dismiss is a common feature for any App these days. It should be easy, especially with Jetpack Compose, right?

True, it is supposed to be easy. I even shared a blog on it

Unfortunately, there are some little traps I fell into that altogether took me about 3 days before I can comprehend them well.

My final design works well as shown below

You can get the code here https://github.com/elye/demo_android_jetpack_compose_list_update

But it took me quite a bit of exploration before I can reach there. I’m glad I got through as I learned other bits along the way.

It is recommended you read the Jetpack Compose Swipe To Dismiss Made Easy first to get some context if you haven’t any experience with it.

If you are already familiar with Compose and think you would be able to grasp it, then proceed.

1. Don’t check for Dismiss State and perform action outside rememberDismissState

When I first look for ways to work on SwapToDismiss, I found this seemingly promising top answer in StackOverflow.

I follow it and code somewhat like below, with the check for isDismissed outside of rememberDismissState, and remove the record when detecting the dismissed state.

LazyColumn(
    modifier = Modifier.fillMaxHeight(),
    state = lazyListState
) {
    items(
        items = todoListState.value,
        itemContent = { item ->
            val dismissState = rememberDismissState()
            if (dismissState.isDismissed(DismissDirection.EndToStart) ||
                dismissState.isDismissed(DismissDirection.StartToEnd)){
                    viewModel.removeRecord(item)
            }
            SwipeToDismiss(....)
        }
}

I got a weird result that removes more than one line at a time and crashes sometimes.

Explanation

The reason is, in the below code, it gets triggered on every single recomposition.

val dismissState = rememberDismissState()
if (dismissState.isDismissed(DismissDirection.EndToStart) ||
    dismissState.isDismissed(DismissDirection.StartToEnd)){
    viewModel.removeRecord(item)
}

Hence the removeRecord is triggered multiple times over and over, causing multiple rows removed, even though only a single swipe is performed.

The Fix

I need to action (the item row removal) within rememberDismissState

val dismissState = rememberDismissState(
    confirmStateChange = {
        if (it == DismissValue.DismissedToStart || it == DismissValue.DismissedToEnd) {
            viewModel.removeRecord(item)
            true
        } else false
    }
)

This will ensure only a single call to removeRecord for the particular row and is not affected by the multiple recomposition call of the UI code.

2. The need for KEY for Lazy Column for SwipeToDismiss to work

Even after fixing that, my code as below, I still have problems.

LazyColumn(
    modifier = Modifier.fillMaxHeight(),
    state = lazyListState
) {
    items(
        items = todoListState.value,
        itemContent = { item ->
            val dismissState = rememberDismissState(
                confirmStateChange = {
                    if (it == DismissValue.DismissedToStart || 
                        it == DismissValue.DismissedToEnd) {
                            viewModel.removeRecord(item)
                        true
                } else false
            }
            SwipeToDismiss(....)
        }
}

When I swipe to dismiss a row, it still looks like it triggers two rows removal, though, for the actual data, only one row is removed.

The other row is not removed (i.e the physical data is still there) but was in the state of dismissed. Hence it is shown in the background instead of the row data (in red).

And even after loading new data, the row still stays in the same dismissed state (in red background color).

Explanation

After investigation, I realized this is because I didn’t supply the KEY to the LazyColumn.

For a simple LazyColumn, the KEY parameter is optional. Without providing the KEY, each row is assigned a KEY of its row number.

LazyColumn(...) {
    items(
        items = todoListState.value,
        itemContent = { ... }
    )
}

This means if we remove item row 2, row 3 will be moved up to and get the KEY of 2.

The Fix

To fix the issue, we should assign KEY to our LazyColumn

LazyColumn(...) {
    items(
        items = todoListState.value,
         key = { todoItem -> todoItem.id },
        itemContent = { ... }
    )
}

In such cases, when we don’t remove or swap any row within the LazyColumn, it works fine, because the Key for each item is changed. Hence the dismissed state is not taken from the previously removed row’s state, as each row gets its own dismissed state.

3. Dismiss item referring to an old item instead of the updated one

With the fix above, my code looks like below

LazyColumn(
    modifier = Modifier.fillMaxHeight(),
    key = { todoItem -> todoItem.id }, 
    state = lazyListState
) {
    items(
        items = todoListState.value,
        itemContent = { item ->
            val dismissState = rememberDismissState(
                confirmStateChange = {
                    if (it == DismissValue.DismissedToStart || 
                        it == DismissValue.DismissedToEnd) {
                            viewModel.removeRecord(item)
                        true
                } else false
            }
            SwipeToDismiss(....)
        }
}

It works well when I load the first set of items.

However, if I load the second set of items, and perform a new swipe to remove another row, it crashes as shown in GIF Below.

This issue is also reported in this StackOverflow.

Explanation

If we look at the below, the item is captured within lambda of the confirmStateChange

itemContent = { item ->
    val dismissState = rememberDismissState(
        confirmStateChange = {
            if (it == DismissValue.DismissedToStart || 
                it == DismissValue.DismissedToEnd) {
                    viewModel.removeRecord(item)
                    true
            } else false
        }
    SwipeToDismiss(....)
}

Hence the item in the confirmStateChange is retained there even in the subsequent refresh of data

Therefore, when one performed a delete on the subsequent load, the (old) item cannot be found in the new list, and crash the app.

The Fix

We need to have a way to get the confirmStateChange lambda to retrieve the updated item. Fortunately, we have a function called rememberUpdatedState that can help to retrieve the latest item.

itemContent = { item ->
    val currentItem by rememberUpdatedState(item)
    val dismissState = rememberDismissState(
        confirmStateChange = {
            if (it == DismissValue.DismissedToStart || 
                it == DismissValue.DismissedToEnd) {
                    viewModel.removeRecord(currentItem)
                    true
            } else false
        }
    SwipeToDismiss(....)
}

4. The Optimization of SwipeToDismiss Background

I have the below code to draw the SwipeToDismiss background

@Composable
@OptIn(ExperimentalMaterialApi::class)
fun SwipeBackground(dismissState: DismissState) {
    Box(
        Modifier
            .fillMaxSize()
            .background(Color.Red)
            .padding(horizontal = 20.dp),
    ) {}
}

My view will look below.

Explanation

This is because the SwipeToDismiss background is rendered even when no Swipe to Dismiss is performed. This is a little waste of rendering resource

The Fix

We can add some background color to each item, to cover it up. But it is not helping reduce unnecessary rendering on SwipeToDismiss background.

To prevent SwipeToDismiss background from being rendered, we can simply put dismissState.dismissDirection ?: return, so no rendering when there’s no Swipe To Dismiss happening.

@Composable
@OptIn(ExperimentalMaterialApi::class)
fun SwipeBackground(dismissState: DismissState) {
    dismissState.dismissDirection ?: return
    
    Box(
        Modifier
            .fillMaxSize()
            .background(Color.Red)
            .padding(horizontal = 20.dp),
    ) {}
}

5. The Need to Have Item Background

Despite the above seems good, where we no longer see the SwipeToDismiss background, we still have another issue.

We notice the SwipeToDismiss background is fully seen through from the row Item itself.

Explanation

The reason is, for each Row Item, we didn’t put any background color on it. Hence it is transparent. This makes the SwipeToDismiss background visible when we are performing Swipe to Dismiss.

The Fix

The fix is simple. Put a background color on each Row Item. To make is more natural, we can have the color the same as the original background color.

To illustrate this clearly, I just put Yellow instead.

6. Remember to Scroll to the Top On New Retrieval of Data

This one causes a little unnecessary panic for me.

I have a Randomly Generate Todo button that will generate items starting from Item 0, Item 1

However, after I remove the first item, and generate a new list of items, the first one shown in Item 1. Where did the Item 0 goes?

Explanation

Apparently for LazyColumn, when we remove Item 0, it scrolls down to Item 1. Even after we generate a new set of items, the scroll position stays on Item 1.

It looks like Item 0 is not there, but it is. We just need to scroll up to it in the new list.

The Fix

There’s nothing really to fix from the technical point of view. But from the user's point of view, this is confusing.

To address that, I just need to programmatically scroll to the top when a new list is generated.

Button(onClick = {
    viewModel.generateRandomTodo()
    scope.launch {
        lazyListState.scrollToItem(0)
    }
}) {
    Text("Randomly Generate Todo")
}

Recap

Quite a bit of learning from there, not just limited to SwipeToDismiss, but also other aspects of Jetpack Compose. Let’s recap below

  1. Avoid adding logical operation code on the code that gets executed on every recomposition. It will generate weird behavior. Put into some remember lambda instead.
  2. To properly setup LazyColumn, it is better to provide it with the needed KEY to uniquely identify each item. This ensures if any item is swapped or removed, the KEY is kept the same for each item.
  3. Anything that gets into a remember and lambda is persisted until explicitly updated. In Jetpack Compose hoisting purpose, to get the newer item, we can use rememberUpdatedState to help.
  4. Some UI code can be optimized to not perform if it is not being visible. In the case of SwipeToDismiss, we can use dismissState.dismissDirection ?: return
  5. For design, it is good to explicitly set a UI component background color without relying on the screen background color. We might not know it can change at any time.
  6. Sometimes, it is not a technical logical issue. Some seemingly illogical views can possibly be just a pure user interface issue that needs some tweak.
Android App Development
Jetpack Compose
AndroidDev
Mobile App Development
App Development
Recommended from ReadMedium