1

I am using Jetpack Compose and Room as database solution. Have a room table with Tasks and retrieve all tasks using a flow to display them in a lazy list, like so:

@Entity(tableName="task")
data class Task(
  @PrimaryKey(autoGenerate = true) val id: Int = 0,
  val text: String,
  val done: Boolean,
)

@Dao
interface TaskDao {
  // ...other methods
  @Query("SELECT * FROM task") fun getAllTasks(): Flow<List<Task>>
  @Query("UPDATE todo SET done = :done WHERE id = :id")
  suspend fun setDone(id: Int, done: Boolean)
}

class TaskRepository(private val taskDao: TaskDao) {
  // ...other methods
  fun getAllTasks() = taskDao.getAllTasks()
  suspend fun setDone(id: Int, done: Boolean) = taskDao.setDone(id, done)
}

I provide the list of tasks via my ViewModel:

class MainScreenViewModel(
  private val taskRepository: DatabaseTaskRepository,
) : ViewModel() {
  val taskList: StateFlow<List<Task>> =
    taskRepository
      .getAllTasks()
      .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = emptyList()
      )

  fun setDone(taskId: Int, done: Boolean) {
    viewModelScope.launch { taskRepository.setDone(taskId, done) }
  }

In my MainScreen I now display a list of the tasks:

@Composable
fun MainScreen(viewModel: MainScreenViewModel) {
  val tasks by viewModel.taskList.collectAsState()
  TaskList(
      tasks = tasks,
      onDoneChanged = viewModel::setDone,
  )
}

@Composable
fun TaskList(
  tasks: List<Task>,
  onDoneChanged: (id: Int, done: Boolean) -> Unit,
) {
  LazyColumn {
    items(items = tasks, key = { it.id }) {
      TaskListItem(
        text = it.text,
        done = it.done,
        onDoneChanged = { done -> onDoneChanged(it.id, done) },
      )
    }
  }
}

@Composable
fun TaskListItem(
  text: String,
  done: Boolean,
  onDoneChanged: (Boolean) -> Unit,
) {
  Row(/* ... */) {
    Checkbox(checked = done, onCheckedChange = onDoneChanged)
    Text(text)
  }
}

This works, but every time I check a task, all items get recomposed. I suppose this is because the taskList flow emits a new List and compose doesn't know that actually only one item changed. But how can I make this clear to compose and achieve that only the checkbox gets recomposed? I thought providing LazyColumn with a key function would solve the issue as this is implied in the compose best practices, but it didn't. I'm sure there must be a way? I know using a SnapshotStateList works from this post, but as I need to get (and update) the data from the DB I guess this isn't an option?

Robin
  • 333
  • 1
  • 12
  • hey can you provide me the source code ? if you have the project on github?, so I can help, im just lazy to write a dummy code. – Mado May 01 '23 at 19:12
  • How are you converting `List` to `List`? – Atick Faisal May 01 '23 at 22:00
  • In my real code I have a `TaskUiState` but I removed it for simplicity here and apparently missed it at one spot. I edited the Question. – Robin May 02 '23 at 06:35
  • @Mado sadly no, the repo is private currently and I think I would like to keep it that way atm. However, I am interested in a more general solution anyway. – Robin May 02 '23 at 07:02

1 Answers1

1

the issue is not in the database nor the composable, the issue is in the viewmodel there is nothing wrong but it's just not managed well, to avoid the recompositin when an item chech changes update your viewModel:

viewmodel

class MainScreenViewModel(
    private val taskRepository: TaskRepository,
) : ViewModel() {
    private val _taskList = MutableStateFlow<List<Task>>(emptyList())
    val taskList: StateFlow<List<Task>> get() = _taskList

    init {
        viewModelScope.launch {
            taskRepository.getAllTasks().collect { tasks ->
                _taskList.value = tasks
            }
        }
    }

    fun setDone(taskId: Int, done: Boolean) {
        viewModelScope.launch {
            taskRepository.setDone(taskId, done)
            val updatedTaskList = _taskList.value.map { task ->
                if (task.id == taskId) {
                    task.copy(done = done)
                } else {
                    task
                }
            }
            _taskList.value = updatedTaskList
        }
    }
}

Task composable

also add rememberUpdatedState in task composable:

@Composable
fun Task(
    text: String,
    done: Boolean,
    onDoneChanged: (Boolean) -> Unit,
) {
    val updatedOnDoneChanged by rememberUpdatedState(onDoneChanged)

    Row(/* ... */) {
        Checkbox(checked = done, onCheckedChange = updatedOnDoneChanged)
        Text(text)
    }
}

here i found this on the official Android Developer documentation rememberupdatestateOfficialdoc

TIP

side tip : don't use viewmodel.taskList.collectAsState() use collectAsStateWithLifecycle() that collect flow in a manner way here is a further explanation -> collectAsStateWithLifeCycle

Mado
  • 329
  • 3
  • 16
  • `rememberUpdatedState` sounds like a great thing! I thought about initializing a MutableStateFlow from the DB and updating both separately too, but it felt error prone because of multiple sources of truth. What is the benefit over using the flow from the DB directly? – Robin May 02 '23 at 20:58
  • @Robin yes that's true managing a another MutableStateFlow while using the flow from the DB can lead to error that's going to be pain, and you going to need to think about a way to sync the 2 separate MutableState and that's unnecessary . the benefit from using the flow directly from the Db is just provides a single source of truth, and makes sure that UI is consistent with receiving the flows from the database preventing any conflicting error, as the name implies single source of truth, you just get the data from one place and you don't need to manage separate state flow – Mado May 02 '23 at 21:50
  • in simple words just use one MutableStateFlow, it's just easy to maintain and the composable is up to date with latest data from the database without any conflicts – Mado May 02 '23 at 21:58
  • So you are basically saying I should leave everything as-is? – Robin May 08 '23 at 08:54
  • yes @Robin , are you still having an issue ? – Mado May 08 '23 at 11:22
  • I stored the id of each task in the `items` callback so that I don't reference the task object in the `onDoneChecked` callback and that seems to have solved the problem (composition is now skipped for unchanged tasks). I still am not 100% sure why though. – Robin May 08 '23 at 17:47
  • that's because you are providing the Lazy Column a stable key, when the recompostion happens compose will know about key in the list and it will only change the modified one only,if you reference the whole object as you did before compose will not determine if a task has changed or no so it will recompose the whole list that's the scenario, hope that makes sense. @Robin – Mado May 08 '23 at 21:27