Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

Tags:

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:

  • Assigning a single predicate which filters dates by reading current property values. This doesn't refilter the list when the date range changes. It would be great if there was a way to force the filtered list to refilter when that happens.

  • Binding the predicate property so it updates when the range properties are changed. This results in ArrayIndexOutOfBoundsExceptions or NoSuchElementExceptions being thrown from the filtered list when bills are modified or accessed. I don't quite understand why that happens and what exactly is going on under the hood with all those bindings.

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.

like image 287
Altline Avatar asked Jul 20 '21 17:07

Altline


1 Answers

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.

like image 115
Altline Avatar answered Oct 12 '22 23:10

Altline