Search code examples
kotlinkotlin-multiplatformcompose-multiplatform

Modelling augmented filtered list Kotlin Compose Multiplatform


Background

This is a Kotlin Compose Multiplatform Desktop toy I'm playing around with. I'm new to Kotlin (and Compose).

Scenario

I have a library filled with books. I have a view where users can specify a filter criteria and I list a subset of the entire library for them based on the filter. I would like for users to be able to click on those books and alter a view-specific aspect of them (like the book is Open or Closed). Users may have multiple lists with different filters, and Openning/Closing a book in one list does not change that value in another view. However, if the user changes the name of a book, I want it reflected in all lists.

Implementation

Here's where I'm stuck.

So, I have a Library class that has a mutableStateMapOf<ID,Book>() which has the entire library.

I have MyLibraryView which takes a private val library: Library and has a mutableStateOf a filter predicate. With that in hand, I'm using derivedStateOf to have myBooks...

class MyLibraryView(private val library: Library) {
    private val filterPredicate =
        mutableStateOf<(Book) -> Boolean>({ true /* updated when user updates filter */ })

    val myBooks: State<List<Book>> = derivedStateOf {
        library.allBooks.values.filter(predicate = filterPredicate.value)
    }
}

I have a @Composable with a LazyColumn to list my books:

@Composable
fun MyLibraryList(
    myBooks: State<List<Book>>,
) {
    val listState = rememberLazyListState()

    LazyColumn(
        state = listState,
    ) {
        items(
            items = myBooks.value,
            key = { book -> book.id },
        ) { book ->
            BookRow(book)
        }
    }
}

This works as expected (I've understood State<List<Book>> is Stable, but List<Book> would not be?).

I'm not sure how to best add the display details (the open/closed, but also maybe if it's selected, etc).

Broken Solution #1

I could alter the data class Book to include DisplayDetails. This doesn't work since the Open/Closed becomes part of Book and then is shared across views.

Broken Solution #2

Make a BookWithDisplayDetails data class and have the derivedStateOf lambda for myBooks return that instead of Book. This doesn't work because (afaict), you can't reference myBooks from within the lambda (I get an error about it needing to be initialized first), so each time myBooks is recalculated, the existing DisplayDetails is lost (so if a Book changes title, the DisplayDetails would be wiped out).

Broken Solution #3

Have a separate Map<ID,DisplayDetails> in MyLibraryView, and a fun toggleOpenClosed(book: Book) which does as you'd expect.

This doesn't work directly, so it'd have to be a mutableStateMapOf. But also, if the filterPredicate changes, then the derivedStateOf myBooks will need to change, and it should remove any Entry from that map that is filtered away. This is a mess and causes a full recomposition of the entire list.

I'm missing something obvious and simple, it could be because I'm new to this.


Solution

  • If you only need the details to exist in the UI without any persistence I think the easiest solution is to just introduce the details as variables in BookRow, like this:

    var isOpen by remember { mutableStateOf(false) }
    

    Each BookRow, that is, each book in its filtered list, now has its own isOpen variable that is local to this specific BookRow. Setting this will only affect one book in one list.

    Changing the name should be done at the source where the Book objects come from so it will affect all Books.