kotlinjavafxdata-bindingobservablelist

JavaFX: Changing filtered list predicate when the underlying list is modified


I have a JavaFX application which I'm writing in Kotlin. The application shows a list of bills which all have a date:

data class Bill(
    val date: LocalDate
    // ...
)

The bills are stored in an observable list, wrapped by a filtered list.

I want the user to be able to set a range of dates for which the bills will be filtered. In addition to that, I want that range to automatically change when the underlying list of bills is modified. The lower and upper bounds of that date range are saved as properties.

I already tried two approaches:

Here is a simplified example of what is going on:

import javafx.application.Application
import javafx.beans.binding.Bindings
import javafx.beans.binding.ObjectBinding
import javafx.beans.property.SimpleObjectProperty
import javafx.collections.FXCollections
import javafx.collections.ObservableList
import javafx.collections.transformation.FilteredList
import javafx.stage.Stage
import java.time.LocalDate
import java.util.function.Predicate

data class Bill(
    val date: LocalDate
)

class Example : Application() {
    private val bills: ObservableList<Bill> = FXCollections.observableArrayList()
    private val filteredBills: FilteredList<Bill> = FilteredList(bills)

    val latestBillDateBinding: ObjectBinding<LocalDate?> =
        Bindings.createObjectBinding({
            bills.maxOfOrNull { it.date }
        }, bills)

    // In the original code, the UI is bidirectionally bound to this
    val endingDateProperty = SimpleObjectProperty(LocalDate.now())
    var endingDate: LocalDate?
        get() = endingDateProperty.value
        set(value) {
            endingDateProperty.value = value
        }

    init {
        latestBillDateBinding.addListener { _, oldValue, newValue ->
            if (endingDate == oldValue)
                endingDate = newValue
        }

        // First approach - does not refilter
        filteredBills.predicate = Predicate {
            it.date == endingDate
        }

        // Second approach - throws exceptions
        /*
        filteredBills.predicateProperty().bind(Bindings.createObjectBinding({
            // This is just an example.
            // The actual predicate checks whether the date is in a valid range.
            Predicate { it.date == endingDate }
        }, endingDateProperty))
        */

        bills += Bill(LocalDate.now())
    }

    fun alterData() {
        println("Altering data")
        bills += Bill(bills.last().date.plusDays(1))
    }

    fun accessData() {
        println("Accessing data")
        println(filteredBills)
    }

    fun changeEndingDate() {
        println("Changing filter")
        endingDate = endingDate?.plusDays(1)
    }

    override fun start(primaryStage: Stage) {
        accessData()
        alterData()
        accessData()
        changeEndingDate()
        accessData()
    }
}

Output of the first approach:

Accessing data
[Bill(date=2021-07-20)]
Altering data
Accessing data
[Bill(date=2021-07-20), Bill(date=2021-07-21)]
Changing filter
Accessing data
[Bill(date=2021-07-20), Bill(date=2021-07-21)]

Output of the second approach:

Accessing data
[Bill(date=2021-07-20)]
Altering data
Accessing data
Exception in Application start method
Exception in thread "main" java.lang.reflect.InvocationTargetException
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.base/java.lang.reflect.Method.invoke(Method.java:566)
    at java.base/sun.launcher.LauncherHelper$FXHelper.main(LauncherHelper.java:1051)
Caused by: java.lang.RuntimeException: Exception in Application start method
    at javafx.graphics/com.sun.javafx.application.LauncherImpl.launchApplication1(LauncherImpl.java:900)
    at javafx.graphics/com.sun.javafx.application.LauncherImpl.lambda$launchApplication$2(LauncherImpl.java:195)
    at java.base/java.lang.Thread.run(Thread.java:834)
Caused by: java.util.NoSuchElementException
    at java.base/java.util.AbstractList$Itr.next(AbstractList.java:377)
    at java.base/java.util.AbstractCollection.toString(AbstractCollection.java:472)
    at java.base/java.lang.String.valueOf(String.java:2951)
    at java.base/java.io.PrintStream.println(PrintStream.java:897)
    at org.example.App.accessData(App.kt:63)
    at org.example.App.start(App.kt:74)
    at javafx.graphics/com.sun.javafx.application.LauncherImpl.lambda$launchApplication1$9(LauncherImpl.java:846)
    at javafx.graphics/com.sun.javafx.application.PlatformImpl.lambda$runAndWait$12(PlatformImpl.java:474)
    at javafx.graphics/com.sun.javafx.application.PlatformImpl.lambda$runLater$10(PlatformImpl.java:447)
    at java.base/java.security.AccessController.doPrivileged(Native Method)
    at javafx.graphics/com.sun.javafx.application.PlatformImpl.lambda$runLater$11(PlatformImpl.java:446)
    at javafx.graphics/com.sun.glass.ui.InvokeLaterDispatcher$Future.run(InvokeLaterDispatcher.java:96)
    at javafx.graphics/com.sun.glass.ui.win.WinApplication._runLoop(Native Method)
    at javafx.graphics/com.sun.glass.ui.win.WinApplication.lambda$runLoop$3(WinApplication.java:174)
    ... 1 more

I would prefer the first approach if there exists a way of refiltering the list. I would also like to know what is happening that's causing the problems I'm getting with the second approach and how I can get around it.

Note: Code examples written in Java are also welcome.


Solution

  • As kleopatra pointed out, there seems to be a problem with the order of updates to the contents of the filtered list and its predicate.

    Instead of adding a listener to latestBillDateBinding which was supposed to alter the predicate based on the source list, a similar listener can be added to the source list itself:

    bills.addListener(object : ListChangeListener<Bill> {
    
        var latestBillDate: LocalDate? = null
    
        override fun onChanged(c: ListChangeListener.Change<out Bill>) {
            val newMax = bills.maxOfOrNull { it.date }
    
            if (endingDate == latestBillDate){
                endingDate = newMax
            }
    
            latestBillDate = newMax
        }
    })
    

    The difference here is that the latest bill date is no longer calculated by a binding, but is stored as a regular variable and calculated in the new listener.

    I believe the reason why this works is due to the listener on the source list being called after the filtered list has received the change event, enabling it to refilter properly when the predicate is finally changed.