Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

LazyColumn is not keeping the state of items when scrolling

Following the Pathway code labs from Google about Jetpack compose, I was trying out this code

import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.Surface
import androidx.compose.material.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.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.codelab.basics.ui.BasicsCodelabTheme

class MainActivity : AppCompatActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContent {
           MyApp {
               MyScreenContent()
           }
       }
   }
}

@Composable
fun MyApp(content: @Composable () -> Unit) {
   BasicsCodelabTheme {
       Surface(color = Color.Yellow) {
           content()
       }
   }
}

@Composable
fun MyScreenContent(names: List<String> = List(1000) { "Hello Android #$it" }) {
   val counterState = remember { mutableStateOf(0) }

   Column(modifier = Modifier.fillMaxHeight()) {
       NameList(names, Modifier.weight(1f))
       Counter(
           count = counterState.value,
           updateCount = { newCount ->
               counterState.value = newCount
           }
       )
   }
}

@Composable
fun NameList(names: List<String>, modifier: Modifier = Modifier) {
   LazyColumn(modifier = modifier) {
       items(items = names) { name ->
           Greeting(name = name)
           Divider(color = Color.Black)
       }
   }
}

@Composable
fun Greeting(name: String) {
   var isSelected by remember { mutableStateOf(false) }
   val backgroundColor by animateColorAsState(if (isSelected) Color.Red else Color.Transparent)

   Text(
       text = "Hello $name!",
       modifier = Modifier
           .padding(24.dp)
           .background(color = backgroundColor)
           .clickable(onClick = { isSelected = !isSelected })
   )
}

@Composable
fun Counter(count: Int, updateCount: (Int) -> Unit) {
   Button(
       onClick = { updateCount(count + 1) },
       colors = ButtonDefaults.buttonColors(
           backgroundColor = if (count > 5) Color.Green else Color.White
       )
   ) {
       Text("I've been clicked $count times")
   }
}

@Preview("MyScreen preview")
@Composable
fun DefaultPreview() {
   MyApp {
       MyScreenContent()
   }
}

I have noticed that LazyColumn will recompose the items whenever they become Visible on the screen (intended behaviour!) however, the Local state of Greeting widget is completely lost!

I believe this is a bug in Compose, Ideally the composer should consider the remember cached state. Is there an elegant way to fix this?

Thanks in advance!

like image 807
AouledIssa Avatar asked Mar 07 '21 12:03

AouledIssa


1 Answers

[Update]

Using rememberSaveable {...} gives the power to survive the android change of configuration as well as the scrollability

Docs

Remember the value produced by init.
It behaves similarly to remember, but the stored value will survive the activity or process recreation using the saved instance state mechanism (for example it happens when the screen is rotated in the Android application).

The code now is more elegant and shorter, we don't even need to hoist the state, it can be kept internal. The only thing I am not so sure of now with using rememberSaveable is if there will be any performance penalties when the list grows bigger and bigger, say 1000 items.

@Composable
fun Greeting(name: String) {
    val isSelected = rememberSaveable { mutableStateOf(false) }
    val backgroundColor by animateColorAsState(if (isSelected.value) Color.Red else Color.Transparent)

    Text(
        text = "Hello $name! selected: ${isSelected.value}",
        modifier = Modifier
            .padding(24.dp)
            .background(color = backgroundColor)
            .clickable(onClick = {
                isSelected.value = !isSelected.value
            })
    )
}

[Original Answer]

Based on @CommonWare's answer The LazyColumn will dispose the composables along with their states when they are off-screen this means when LazyColumn recomposes the Compsoables again it will have fresh start state. To fix this issue all that has to be done is to hoist the state to the consumer's scope, LazyColumn in this case.

Also we need to use mutableStateMapOf() instead of MutableMapOf inside the remember { ... } lambda or Compose-core engine will not be aware of this change.

So far here is the code:

@Composable
fun NameList(names: List<String>, modifier: Modifier = Modifier) {

    val selectedStates = remember {
        mutableStateMapOf<Int, Boolean>().apply {
            names.mapIndexed { index, _ ->
                index to false
            }.toMap().also {
                putAll(it)
            }
        }
    }

    LazyColumn(modifier = modifier) {
        itemsIndexed(items = names) { index, name ->
            Greeting(
                name = name,
                isSelected = selectedStates[index] == true,
                onSelected = {
                    selectedStates[index] = !it
                }
            )
            Divider(color = Color.Black)
        }
    }
}

Happy composing!

like image 73
AouledIssa Avatar answered Sep 22 '22 04:09

AouledIssa