0

I'm displaying a list of items that I want to display in "distance" order. However, sometimes when an item's distance is updated causing it to move to a different place in the list, several items disappear from the bottom of the list. Another update or two later, they reappear again.

My strategy for sorting is to remove the item, linear search forwards or backwards in the list for its new location, and insert it, because I don't expect items to move more than one or two rows at a time.

I only have one thread updating the list. But are Composables thread safe? Or do I need to synchronize the list while it is being composed?

My ViewModel


class TodoViewModel : ViewModel() {
    private val _todos = emptyList<Todo>().toMutableStateList()
    val todos: List<Todo>
        get() = _todos

    fun changeTitle(id: Int, title: String) {
        var index = _todos.indexOfFirst { todo -> todo.id == id }
        if (index == -1)
            return
        _todos[index] = _todos[index].copy(title = title)
    }

    fun delete(index: Int) {
        _todos.removeAt(index)
    }

     fun changeDistance(id: Int, delta: Int) {
        var index = _todos.indexOfFirst { todo -> todo.id == id }
        if (index == -1)
            return
         val distance = _todos[index].distance + delta
         val oldDistance = _todos[index].distance
         Log.i("xx", "Update $id at $index from $oldDistance to $distance")
         val newTodo = _todos[index].copy(distance = distance)
         if ((index == 0 || distance >= _todos[index-1].distance) &&
             (index == _todos.size-1 || distance <= _todos[index+1].distance)) {
           _todos[index] = newTodo
           return
         }
         Log.i("xx", "Move $id from $index")
         delete(index)
         while (index > 0 && distance < todos[index-1].distance) {
             index--
         }
         while (index < todos.size && distance > todos[index].distance) {
             index++
         }
         Log.i("xx", "Move $id to $index")
         _todos.add(index, newTodo)
    }

    fun insert(todo: Todo) {
        val index = todos.indexOfFirst { t -> t.id == todo.id }
        if (index != -1) {
            _todos[index] = todo
            return
        }
        var newIndex = todos.binarySearch {t -> t.distance - todo.distance }
        if (newIndex < 0) newIndex = (-newIndex - 1)
        _todos.add(newIndex, todo)
    }
}

Main Activity


    private val todoViewModel = TodoViewModel()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MaterialTheme {
                TodoScreen(todoViewModel = todoViewModel)
            }
        }
        var nextId = 0
        val thread = Thread {
            for (i in 0..10) {
                todoViewModel.insert(Todo(nextId, "new $nextId", Random.nextInt(25, 50)))
                nextId++
            }
            for (i in 0..100) {
                Thread.sleep(500)
                val functionNum = Random.nextInt(0, 100)
                    val index = Random.nextInt(0, todoViewModel.todos.size)
                    val id = todoViewModel.todos[index].id
                    todoViewModel.changeDistance(id, Random.nextInt(-10, 10))
            }
        }
        thread.start()
    }
}

@Composable
fun TodoScreen(
    modifier: Modifier = Modifier,
    todoViewModel: TodoViewModel = viewModel()
) {
    Surface(color = MaterialTheme.colorScheme.background) {
        TodoList(
            modifier = modifier.fillMaxSize(),
            list = todoViewModel.todos
        )
    }
}

@Composable
fun TodoList(
    list: List<Todo>,
    modifier: Modifier = Modifier
) {
    LazyColumn(
        modifier = modifier,
        verticalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        items(items = list, key = { todo -> todo.id }) { todo ->
            TodoItem(todo = todo, modifier = Modifier.fillMaxWidth() )
        }
    }
}

@Composable
fun TodoItem(todo: Todo,
             modifier: Modifier
) {
    Card(
        modifier = modifier,
        elevation = CardDefaults.cardElevation(8.dp, 8.dp, 8.dp, 8.dp, 8.dp, 8.dp)
    ) {
        Row(modifier = Modifier.padding(8.dp)) {
            Text(text = todo.title)
            Spacer(modifier = Modifier.weight(1f))
            Text(text = todo.distance.toString())
        }
    }
}
user998303
  • 136
  • 7
  • Why are you handling the re-sorting of the list in such a low level way? Assuming there are not a very large amount (10s of thousands) of items in the List, I suggest you merely re-sort the list with something like `_todos.sortBy { it.distance }`. Also consider you can stably break ties when two list elements have the same `distance` – AndrewL Jul 09 '23 at 16:40
  • Hi Andrew, thanks for your comment. One of the things I want is to animate the movements. Also, it seems wasteful to sort the entire list (even though its short) when I know only one item is changing and that it won't move very far. Also, when I do a tie break, I want the moving item to move by the minimum distance. – user998303 Jul 09 '23 at 20:49
  • I thought that I had solved the problem, which is indeed a matter of synchronization between changing the list and displaying it. However, it seems that the actual displaying of items is done on a separate thread which expects the data to be unchanged. Because it's on a separate thread no amount of synchronization, either on the List or individual items, in the Composable and ViewModel fixes it completely. – user998303 Jul 16 '23 at 00:11
  • This background thread seems to expect the List to not change from I assume the state in the MutableStateList, so it throws IndexOutOfBounds exceptions if I add new items to the list or remove items from the list while it's displaying it. This seems to be a race condition -- it becomes visible when I throw a lot of changes quickly at the list. – user998303 Jul 16 '23 at 00:13
  • Is there perhaps a callback I can hook into, so that I can use my own code to (a) synchronize on the list to prevent my other tasks from updating it during a display, and/or (b) detect that a change has occurred so I can abandon the current display because presumably there's another one about to be queued up? I think I must have the wrong paradigm in my head. Surely it shouldn't be this difficult? Is there some other way to display a dynamic list which is updated by a background thread? – user998303 Jul 16 '23 at 00:13
  • I cannot help you any more since I am not using Kotlin for Android development. I suggest you open a new question focused to the community on the Proper way to change a data by a background thread and have the display thread be advised of it. – AndrewL Jul 16 '23 at 11:50
  • I copied the above into a project and have been running it for quite some time. I am not seeing anything wrong. However, as composition can occur during the update you might be seeing isolation issues where composition is seeing a partially updated list. As you note in your answer to can use synchronization to handle this. You can also use a mutable snapshot to change the list so composition only sees the complete change, not a partial change. – chuckj Jul 20 '23 at 22:18

1 Answers1

0

I have stumbled across what seems to be a solution... synchronizing on the list plus using itemsIndexed instead of Items in the Composable makes this work. I don't understand why ItemsIndexed would make any difference, but I have run this for many thousands of iterations and it hasn't crashed.

Also, removing items seems to cause IndexOutofBounds exceptions in the Composable. Pragmatically, I solved this by not deleting items. I mark them as deleted so the don't display, and then re-use them at the next insert. (Surprisingly, replacing a list item with one with a different key seems to be acceptable).

If someone has a better solution, I'd love to see it.

Main Activity TodoList function

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TodoList(
    viewModel: TodoViewModel,
    modifier: Modifier = Modifier
) {
    with(viewModel) {
        if (todos.isEmpty()) {
            Text("No data")
        } else
            LazyColumn(
                state = rememberLazyListState(),
                modifier = modifier,
            ) {
                synchronized(viewModel.todos) {
                    itemsIndexed(items = viewModel.todos) { _, item ->
                        if (!item.deleted)
                            TodoItem(
                                todo = item,
                                modifier = Modifier
                                    .fillMaxWidth()
                                    .animateItemPlacement()
                            )
                    }
                }
            }
    }
}

TodoViewModel


class TodoViewModel : ViewModel() {
    private val _todos = emptyList<Todo>().toMutableStateList()
    val todos: List<Todo>
        get() = _todos

    fun changeTitle(id: Int, title: String) {
        synchronized(todos) {
            val index = _todos.indexOfFirst { todo -> todo.id == id }
            if (index == -1)
                return
            _todos[index] = todos[index].copy(title = title)
        }
    }

    fun deleteAt(index: Int) {
        synchronized(todos) {
            _todos[index] = _todos[index].copy(deleted = true)
        }
    }

    fun changeDistance(id: Int, distance: Int) {
        synchronized(todos) {
            val from = _todos.indexOfFirst { todo -> todo.id == id }
            if (from == -1) {
                Log.e("xx", "Not found: $id")
                return
            }
            val newTodo = _todos[from].copy(distance = distance)
            val oldDistance = _todos[from].distance
            var to = from
            if (distance < oldDistance) {
                while (to > 0 && distance < _todos[to - 1].distance) {
                    to--
                }
            } else {
                while (to < _todos.size && distance > _todos[to].distance) {
                    to++
                }
            }
            if (to == from)
                _todos[to] = newTodo
            else {
                _todos.removeAt(from)
                _todos.add(if (to > from) to -1 else to, newTodo)
            }
//            Log.i("xx", "Move " + newTodo.id + " ($oldDistance -> $distance) from $from -> $to")
        }
    }

    fun replaceIndexed(from: Int, newTodo: Todo) {
            val oldDistance = _todos[from].distance
            var to = from
            if (newTodo.distance < oldDistance) {
                while (to > 0 && newTodo.distance < _todos[to - 1].distance) {
                    to--
                }
            } else {
                while (to < _todos.size && newTodo.distance > _todos[to].distance) {
                    to++
                }
            }
            if (to == from)
                _todos[to] = newTodo
            else {
                _todos.removeAt(from)
                _todos.add(if (to > from) to -1 else to, newTodo)
            }
//            Log.i("xx", "Move " + newTodo.id + " ($oldDistance -> $distance) from $from -> $to")
    }

    fun insert(todo: Todo) {
        synchronized(todos) {
            val index = _todos.indexOfFirst { t -> t.id == todo.id }
            if (index != -1) {
                _todos[index] = todo
                return
            }
            val delIndex = _todos.indexOfFirst { t -> t.deleted }
            if (delIndex != -1) {
                replaceIndexed(delIndex, todo)
                return
            }
            var newIndex = _todos.binarySearch { t -> t.distance - todo.distance }
            if (newIndex < 0) newIndex = (-newIndex - 1)
            Log.i("xx", "Adding " + todo.id + " (" + todo.distance + ")")
            _todos.add(newIndex, todo)
        }
    }
}
user998303
  • 136
  • 7