Issue
I'm writing a small program for a project for Uni and it's basically a library program to manage books anr read/write to JSON file instead of using a database cause it'd be simpler since it's my first proper Java application.
I'm utilizing a TextField to filter a ListView with all the books' titles, and it works, it shows the correct book in the list and updates the corresponding informations on screen when that book is selected, the issue is that even if the program works as intended, it throws an error everytime I update the search field I get an ArrayIndexOutOfBoundsException
. The full stack is as follows:
Exception in thread "JavaFX Application Thread" java.lang.ArrayIndexOutOfBoundsException: Index -1 out of bounds for length 7
at javafx.base@19-ea/javafx.collections.transformation.FilteredList.get(FilteredList.java:172)
at com.libraryproject.javalibrary/com.libraryproject.javalibrary.MainViewController.populateDetails(MainViewController.java:200)
at com.libraryproject.javalibrary/com.libraryproject.javalibrary.MainViewController.lambda$initialize$3(MainViewController.java:127)
at javafx.graphics@19-ea/com.sun.javafx.application.PlatformImpl.lambda$runLater$10(PlatformImpl.java:457)
at java.base/java.security.AccessController.doPrivileged(AccessController.java:399)
at javafx.graphics@19-ea/com.sun.javafx.application.PlatformImpl.lambda$runLater$11(PlatformImpl.java:456)
at javafx.graphics@19-ea/com.sun.glass.ui.InvokeLaterDispatcher$Future.run$$$capture(InvokeLaterDispatcher.java:96)
at javafx.graphics@19-ea/com.sun.glass.ui.InvokeLaterDispatcher$Future.run(InvokeLaterDispatcher.java)
at javafx.graphics@19-ea/com.sun.glass.ui.win.WinApplication._runLoop(Native Method)
at javafx.graphics@19-ea/com.sun.glass.ui.win.WinApplication.lambda$runLoop$3(WinApplication.java:184)
After some googling, some people suggested that when updating the GUI from user input, one should do it in the Application Thread, which to be honest I'm not absolutely sure what that means, but anyway I followed the advice and wrapped the functions that would then update the UI variables in a Platform.runLater(() -> {}
, but the issue still remains, and it's the stack above, at this point I have absolutely no idea what the problem could be, so, following the stack posted, let's see the code of the parts that are shown:
I'm using a FilteredList to, well, filter the listrView using the search, here's the code managing that and most of the initialize method:
private FilteredList<Book> filteredBooks;
...
...
// inside the initialize method
@Override
public void initialize(URL arg0, ResourceBundle arg1) {
// Populate the variable we use throughout the program with the data from the JSON file
filteredBooks = new FilteredList<Book>(handleJSON.getBooks());
// Then update the list view for the first time
populateView(filteredBooks);
...
...
// section of code responsible to check for search changes, when found, fires populateView once more, this time with the variable updated.
searchField.textProperty().addListener((obs, oldText, newText) -> {
filteredBooks.setPredicate(book -> {
if(newText == null || newText.isEmpty() || newText.isBlank()) {
return true;
}
String lowerCaseCompare = newText.toLowerCase();
if(book.getTitle().toLowerCase().contains(lowerCaseCompare)) {
return true;
}
return false;
});
Platform.runLater(() -> populateView(filteredBooks));
}); // Listener
...
...
...
// This one handles the selection of an item in the list, when selected, the fields on the other side of the windows will get populated with the respective data from the book based on the id from the list, since they essentialy share the same FilteredList
listView.getSelectionModel().selectedItemProperty().addListener((obs, oldSel, newSel) -> {
Platform.runLater(() -> {
populateDetails(listView.getSelectionModel().selectedIndexProperty().getValue(), filteredBooks);
editButton.setDisable(false);
});
As you can see I wrapped all of the function that will update the ListView and fields in the window with Platform.runLater
, but it doesn't seem to help.
Now for the populateView function that fires the first time the program is opened and everytime there's a change in the searchfiield:
public void populateView(FilteredList<Book> booksList) {
// clears the listview to avoid old elements stacking in the list.
listView.getSelectionModel().clearSelection();
listView.getItems().clear();
ObservableList<String> rawTitles = FXCollections.observableArrayList();
for(Book book: booksList) {
rawTitles.add(book.getTitle());
}
listView.setItems(rawTitles);
} // populateView()
And last but not least the populateDetails function that fills the fields about a book based on the list selection:
public void populateDetails(Integer selectedBookID, FilteredList<Book> books) {
Book currentBook = books.get(selectedBookID);
titleValue.setText(currentBook.getTitle());
authorValue.setText(currentBook.getAuthor());
languageValue.setText(currentBook.getLanguage());
genreValue.setText(currentBook.getGenre());
pagesValue.setText(currentBook.getPages().toString());
yearValue.setText(currentBook.getRelease().toString());
if (currentBook.getAvailable()) {
availableRadio.setSelected(true);
} else {
unavailableRadio.setSelected(true);
}
} // populateDetails
Thats basically I tried to use the runLater in different places just to be sure, I still get the same stack, any idea what could cause this?
Solution
The stack trace tells you exactly what the problem is. The ArrayIndexOutOfBoundsException
occurs when you call get(..)
on a FilteredList
with the value -1
, which you do on line 200 of MainViewController.java
, in the populateDetails(...)
method. Looking at your code, this line must be the line
Book currentBook = books.get(selectedBookID);
so selectedBookID
must be the culprit, having the value -1
.
selectedBookID
is a parameter passed to the method, and you call the method from line 127 of MainController.java
, in a lambda expression in the initialize()
method. (Again, this information is in the stack trace.) The value you pass is
listView.getSelectionModel().selectedIndexProperty().getValue()
The documentation tells you explicitly when this happens:
The selected index is either -1, to represent that there is no selection, or an integer value that is within the range of the underlying data model size.
So your populate details needs to handle the case where nothing is selected (probably by clearing the text fields). I think it's cleaner to listen to the selectedItemProperty()
instead of the selectedIndexProperty()
, as it directly gives you the selected Book
(or null
if nothing is selected), and you don't have to retrieve the Book
from the list:
public void populateDetails(Book currentBook) {
if (currentBook == null) {
titleValue.setText("");
authorValue.setText("");
languageValue.setText("");
genreValue.setText("");
pagesValue.setText("");
yearValue.setText("");
availableRadio.setSelected(false);
unavailableRadio.setSelected(false);
} else {
titleValue.setText(currentBook.getTitle());
authorValue.setText(currentBook.getAuthor());
languageValue.setText(currentBook.getLanguage());
genreValue.setText(currentBook.getGenre());
pagesValue.setText(currentBook.getPages().toString());
yearValue.setText(currentBook.getRelease().toString());
if (currentBook.getAvailable()) {
availableRadio.setSelected(true);
} else {
unavailableRadio.setSelected(true);
}
}
}
Your code is overkill; there is basically no need for the populateView()
method. The filtered list will update its contents when you change the predicate, and notify observers that its content has changed. So you should just set the list view's items list to the filtered list directly. Then your listener for the search field only has to update the predicate, and the list view will automatically update.
Delete the populateView()
method and update the initialize()
method as:
public void initialize(URL arg0, ResourceBundle arg1) {
// Populate the variable we use throughout the program with the data from the JSON file
filteredBooks = new FilteredList<Book>(handleJSON.getBooks());
listView.setItems(filteredBooks);
// ...
// ...
// section of code responsible to check for search changes, when found, fires populateView once more, this time with the variable updated.
searchField.textProperty().addListener((obs, oldText, newText) -> {
filteredBooks.setPredicate(book -> {
if(newText == null || newText.isEmpty() || newText.isBlank()) {
return true;
}
String lowerCaseCompare = newText.toLowerCase();
return book.getTitle().toLowerCase().contains(lowerCaseCompare)
});
}); // Listener
// ...
// This one handles the selection of an item in the list, when selected, the fields on the other side of the windows will get populated with the respective data from the book based on the id from the list, since they essentialy share the same FilteredList
listView.getSelectionModel().selectedItemProperty().addListener(
(obs, oldSel, newSel) -> populateDetails(newSel)
);
}
Answered By - James_D
Answer Checked By - Katrina (JavaFixing Volunteer)