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 ArrayIndexOutOfBoundsException
s or NoSuchElementException
s 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.
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.