Thursday, October 16, 2014

Per-row mouse cursor in JavaFX TableViews

Today I want to show how to set the mouse cursor in a TableView on a per-row basis.

In a real-world scenario this can be used to indicate what will happen when you click on the row (will it be selected? will it work like a link? is it unselectable?, etc.). To that end it will often be necessary to access the data displayed in the row.

In our example we will set a crosshair cursor for Persons with the letter 'U' in their first name.

First we setup a row factory for the table:

    private void setupPerRowCursor(TableView<Person> table) {
        table.setRowFactory(this::createTableRow);
    }


That row factory will create the row and listen for changes to its indexProperty:

    private TableRow<Person> createTableRow(TableView<Person> table) {
        TableRow<Person> row =  new TableRow<Person>();
        row.indexProperty().addListener((ObservableValue<? extends Number> observable, Number oldValue, Number newValue) -> {
            rowChanged(row, newValue.intValue());
        });
        return row;
    }


Remember that rows (just like cells) will be reused in the table view so even if you have millions of data rows, there will only be a couple of TableRow instances. Therefore we must change react to a row being reused:

    private void rowChanged(TableRow<Person> row, int idx) {
        items = row.getTableView().getItems();
        if (idx >= 0 && idx < items.size()) {
            Person p = items.get(idx);
            if (p.getFirstName().contains("U")) {
                row.setCursor(Cursor.CROSSHAIR);
            }
            else {
                row.setCursor(Cursor.DEFAULT);
            }
        }
    }


We get the (newly associated) data by examining the items of the table. The new index the row points to can be out of bounds, e.g. for TableRows that are displayed but do not have a data row associated, therefore we must check idx to be within limits.

Here's a screenshot with the mouse cursor over a person with the (strange) first name HBZZU:


If you go down with the mouse a bit, the cursor will turn into a normal arrow again.

Here's full source code of the example:
import javafx.application.Application;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.Cursor;
import javafx.scene.Scene;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableRow;
import javafx.scene.control.TableView;
import javafx.scene.control.cell.PropertyValueFactory;
import javafx.scene.layout.GridPane;
import javafx.stage.Stage;

import org.apache.commons.lang.RandomStringUtils;

public class TableViewMouseCursorPerRow extends Application {

    ObservableList<Person> items = FXCollections.observableArrayList();
    TableView<Person> table = new TableView<Person>();

    public TableViewMouseCursorPerRow() {
    }

    @Override
    public void start(Stage primaryStage) throws Exception {
        GridPane grid = new GridPane();
        table.setItems(items);
        TableColumn<Person, String> firstNameCol = new TableColumn<Person, String>("First Name");
        firstNameCol.setCellValueFactory(new PropertyValueFactory<Person, String>("firstName"));
        TableColumn<Person, String> lastNameCol = new TableColumn<Person, String>("Last Name");
        lastNameCol.setCellValueFactory(new PropertyValueFactory<Person, String>("lastName"));
        table.getColumns().setAll(firstNameCol, lastNameCol);
        setupPerRowCursor(table);
        addPersons();
        grid.add(table, 0, 1, 3, 1);
       
        Scene scene = new Scene(grid, 400, 400);
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    private void setupPerRowCursor(TableView<Person> table) {
        table.setRowFactory(this::createTableRow);
    }
   
    private TableRow<Person> createTableRow(TableView<Person> table) {
        TableRow<Person> row =  new TableRow<Person>();
        row.indexProperty().addListener((ObservableValue<? extends Number> observable, Number oldValue, Number newValue) -> {
            rowChanged(row, newValue.intValue());
        });
        return row;
    }

    private void rowChanged(TableRow<Person> row, int idx) {
        items = row.getTableView().getItems();
        if (idx >= 0 && idx < items.size()) {
            Person p = items.get(idx);
            if (p.getFirstName().contains("U")) {
                row.setCursor(Cursor.CROSSHAIR);
            }
            else {
                row.setCursor(Cursor.DEFAULT);
            }
        }
    }

    private void addPersons() {
        for (int i = 0; i < 50; i++) {
            Person p = new Person();
            p.setFirstName(items.size() + " " + RandomStringUtils.random(5, "ABEGHILOUZ"));
            p.setLastName(RandomStringUtils.random(10, "QWRTOUPL"));
            items.add(p);
        }
    }

    public static void main(String[] args) {
        launch(args);
    }

    public class Person {
        private StringProperty firstName;

        public void setFirstName(String value) {
            firstNameProperty().set(value);
        }

        public String getFirstName() {
            return firstNameProperty().get();
        }

        public StringProperty firstNameProperty() {
            if (firstName == null)
                firstName = new SimpleStringProperty(this, "firstName");
            return firstName;
        }

        private StringProperty lastName;

        public void setLastName(String value) {
            lastNameProperty().set(value);
        }

        public String getLastName() {
            return lastNameProperty().get();
        }

        public StringProperty lastNameProperty() {
            if (lastName == null)
                lastName = new SimpleStringProperty(this, "lastName");
            return lastName;
        }
    }
}

Tuesday, October 7, 2014

Setting the preferred width of a ListView to fit its items in JavaFX

Let's assume, we want to create a popup listview, that is toggled by a click on a button. Like this:

import java.util.Random;

import javafx.application.Application;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.event.ActionEvent;
import javafx.geometry.Point2D;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.ListView;
import javafx.scene.layout.GridPane;
import javafx.stage.Popup;
import javafx.stage.Stage;

import org.apache.commons.lang.RandomStringUtils;

public class ListViewWidth extends Application {
   
    Popup popup = new Popup();   
    ListView<String> listView = new ListView<>();
    Button button = new Button("Popup");
    Random rnd = new Random();

    public ListViewWidth() {
    }

    @Override
    public void start(Stage primaryStage) throws Exception {
        GridPane grid = new GridPane();
        grid.add(button, 0, 0);
        button.setOnAction(this::toggle);
        popup.getContent().add(listView);
        Scene scene = new Scene(grid, 640, 480);
        primaryStage.setScene(scene);       
        primaryStage.show();
    }
   
    private void toggle(ActionEvent e) {
        if (popup.isShowing()) {
            popup.hide();
        }
        else {
            ObservableList<String> items = createItems();
            listView.setItems(items);
            Point2D pos = button.localToScreen(0, 0);
            popup.show(button, pos.getX(), pos.getY() + button.getHeight());
        }       
    }
   
    private ObservableList<String> createItems() {
        ObservableList<String> result = FXCollections.observableArrayList();
        for (int i = 0; i < 14; i++) {
            result.add(RandomStringUtils.random(rnd.nextInt(100) + 1, true, true));
        }
        return result;
    }

    public static void main(String[] args) {
        launch(args);
    }   
}


Whenever the button is clicked, we hide the popup, if it is already displayed. If it is not yet displayed we create new strings of random length to act as items for the listview and then we show the popup right underneath the button. The screenshot below shows how the popup will look like.


As you can see, we now have an ugly horizontal scrollbar. To get rid of it, we need to set the preferred width of the list view to the width of the widest element in the list, which is a bit tricky (JavaFX own implementation of the ComboBox is doing it with the aid of a few package private methods which we cannot use here).

The trick is to install your own cell factory
         listView.setCellFactory(this::cellFactory);
and make the cells report the sizes to the listview
    private ListCell<String> cellFactory(ListView<String> view) {
        return new ListCell<String>() {
            @Override
            protected void updateItem(String item, boolean empty) {
                super.updateItem(item, empty);
                setText(item);
                widthProperty().addListener(this::widthChanged);
            }

        };
    }


Whenever a cell reports a changed width, we compare it to the current preferred width of the list view and set the preferred width of the list view to the maximum of both values
private void widthChanged(ObservableValue<? extends Number> observable, Number oldValue, Number newValue) {
    double width = getWidth() + listView.getInsets().getLeft() + listView.getInsets().getRight();
    listView.setPrefWidth(Math.max(listView.getPrefWidth(), width));
}          
 

Note that you have to add the insets of the listview to the required width in order to get the correct size. Then we have a perfect popup listview beneath our button:


This looks good -- at first sight. Problems arise, if the number of list view entries grow to the point that they are no longer displayable together.
In that case, cells will not be created for the invisible rows and thus the maximum size of all the rows is no longer correct. If you scroll down, the listview will suddenly grow wider, if a cell is encountered whose width is bigger than the current maximum.

To make the problem more pronounced, I've changed createItems() to create more items and increase the length of the items that are further down:
    private ObservableList<String> createItems() {
        ObservableList<String> result = FXCollections.observableArrayList();
        for (int i = 0; i < 140; i++) {
            result.add(RandomStringUtils.random(rnd.nextInt(i+5) + 1, true, true));
        }
        return result;
    }



What we really want is to calculate the width of all the rows in the list. This is exactly what the Method prefWidth(...) does for a cell, so we can write something like this:
    private void setPreferredWidth(ObservableList<String> items) {
        ListCell<String> cell = new MyListCell();
        double width = 0.0;
        for (String item : items) {
            cell.setText(item);
            width = Math.max(width, cell.prefWidth(-1));
        }
        listView.setPrefWidth(width + listView.getInsets().getLeft() + listView.getInsets().getRight());
    }


We're using only one cell here to measure the widths of all the rows, calculating the maximum width as we go. Since prefWidth() will only work, if the cell has a skin, we have to play a little trick in the constructor of MyListCell:
    private class MyListCell extends ListCell<String> {
        public MyListCell() {
            super();
            updateListView(listView);
            setSkin(createDefaultSkin());
        }
    }


Now everything works the way we want it:

Well almost. If we scroll all the way down, the largest of the rows will require space that is occupied by the vertical scrollbar and thus a horizontal scrollbar is required:

Also the cells we render have an inset that is not yet taken into account. Unfortunately both the scrollbar as well as the (rendered) cell inset will only be available after rendering, so a slight flicker might be visible with the following solution:

import java.util.Random;

import javafx.application.Application;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.event.ActionEvent;
import javafx.geometry.Insets;
import javafx.geometry.Orientation;
import javafx.geometry.Point2D;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.control.ScrollBar;
import javafx.scene.layout.GridPane;
import javafx.stage.Popup;
import javafx.stage.Stage;

import org.apache.commons.lang.RandomStringUtils;

public class ListViewWidth extends Application {

    Popup popup = new Popup();
    ListView<String> listView = new ListView<>();
    Button button = new Button("Popup");
    Random rnd = new Random();
    double cellInset = 0.0;

    public ListViewWidth() {
    }

    @Override
    public void start(Stage primaryStage) throws Exception {
        GridPane grid = new GridPane();
        grid.add(button, 0, 0);
        button.setOnAction(this::toggle);
        popup.getContent().add(listView);
        Scene scene = new Scene(grid, 640, 480);
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    private void toggle(ActionEvent e) {
        if (popup.isShowing()) {
            popup.hide();
        } else {
            ObservableList<String> items = createItems();
            listView.setItems(items);
            listView.setCellFactory(this::cellFactory);
            Point2D pos = button.localToScreen(0, 0);
            popup.show(button, pos.getX(), pos.getY() + button.getHeight());
            double width = getMaxItemWidth(items) + listView.getInsets().getLeft() + listView.getInsets().getRight() + cellInset;
            ScrollBar bar = getVerticalScrollbar();
            width += bar.getWidth();
            listView.setPrefWidth(width);
        }
    }

    private double getMaxItemWidth(ObservableList<String> items) {
        ListCell<String> cell = new MyListCell();
        double width = 0.0;
        for (String item : items) {
            cell.setText(item);
            width = Math.max(width, cell.prefWidth(-1));
        }
        return width;
    }

    private class MyListCell extends ListCell<String> {
        public MyListCell() {
            super();
            updateListView(listView);
            setSkin(createDefaultSkin());
            insetsProperty().addListener(this::insetsChanged);
        }

        @Override
        protected void updateItem(String item, boolean empty) {
            super.updateItem(item, empty);
            setText(item);
        }
       
        private void insetsChanged(ObservableValue<? extends Insets> observable, Insets oldValue, Insets newValue) {
            cellInset = newValue.getLeft() + newValue.getRight();
        }                   
    }

    private ScrollBar getVerticalScrollbar() {
        ScrollBar result = null;
        for (Node n : listView.lookupAll(".scroll-bar")) {
            if (n instanceof ScrollBar) {
                ScrollBar bar = (ScrollBar) n;
                if (bar.getOrientation().equals(Orientation.VERTICAL)) {
                    result = bar;
                }
            }
        }
        return result;
    }

    private ListCell<String> cellFactory(ListView<String> view) {
        return new MyListCell();
    }

    private ObservableList<String> createItems() {
        ObservableList<String> result = FXCollections.observableArrayList();
        for (int i = 0; i < 140; i++) {
            result.add(RandomStringUtils.random(rnd.nextInt(i + 5) + 1, true, true));
        }
        return result;
    }

    public static void main(String[] args) {
        launch(args);
    }
}

Wednesday, October 1, 2014

Infinite scrolling with JavaFX TableView

Today I want to show how to realise infinite scrolling with JavaFX TableView.

The idea behind infinite scrolling is to retrieve more data once the user has scrolled to the bottom of the currently loaded data.

To realise this, we need to get the vertical ScrollBar of the TableView like this:
    private ScrollBar getVerticalScrollbar(TableView<?> table) {
        ScrollBar result = null;
        for (Node n : table.lookupAll(".scroll-bar")) {
            if (n instanceof ScrollBar) {
                ScrollBar bar = (ScrollBar) n;
                if (bar.getOrientation().equals(Orientation.VERTICAL)) {
                    result = bar;
                }
            }
        }       
        return result;
    }


Note that this method can only be executed after the table view has been rendered.


With this method we can add a listener to the scrollbars value property:
        ScrollBar bar = getVerticalScrollbar(table);
        bar.valueProperty().addListener(this::scrolled);


The listener can now check whether we are at the bottom and add new data (Persons in our case) to the item list of the table view:
    void scrolled(ObservableValue<? extends Number> observable, Number oldValue, Number newValue) {
        double value = newValue.doubleValue();
        System.out.println("Scrolled to " + value);
        ScrollBar bar = getVerticalScrollbar(table);
        if (value == bar.getMax()) {
            System.out.println("Adding new persons.");
            double targetValue = value * items.size();
            addPersons();
            bar.setValue(targetValue / items.size());
        }
    }

Note the call to bar.setValue() after data has been added. This is neccessary to keep the view at the elements viewed when reaching the bottom.

Here is the complete example:
import javafx.application.Application;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Orientation;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.ScrollBar;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.cell.PropertyValueFactory;
import javafx.stage.Stage;

import org.apache.commons.lang.RandomStringUtils;

public class TableViewInfiniteScrolling extends Application {
   
    ObservableList<Person> items = FXCollections.observableArrayList();
    TableView<Person> table = new TableView<Person>();

    public TableViewInfiniteScrolling() {
        addPersons();
    }

    private void addPersons() {
        for (int i = 0; i < 50; i++) {
            Person p = new Person();
            p.setFirstName(items.size() + " " + RandomStringUtils.random(5, "ABEGHILOUZ"));
            p.setLastName(RandomStringUtils.random(10, "QWRTOUPL"));
            items.add(p);
        }
    }

    @Override
    public void start(Stage primaryStage) throws Exception {
        table.setItems(items);
        TableColumn<Person, String> firstNameCol = new TableColumn<Person, String>("First Name");
        firstNameCol.setCellValueFactory(new PropertyValueFactory<Person, String>("firstName"));
        TableColumn<Person, String> lastNameCol = new TableColumn<Person, String>("Last Name");
        lastNameCol.setCellValueFactory(new PropertyValueFactory<Person, String>("lastName"));       
        table.getColumns().setAll(firstNameCol, lastNameCol);       
       
        Scene scene = new Scene(table, 400, 400);
        primaryStage.setScene(scene);       
        primaryStage.show();
        ScrollBar bar = getVerticalScrollbar(table);
        bar.valueProperty().addListener(this::scrolled);
    }
   
    private ScrollBar getVerticalScrollbar(TableView<?> table) {
        ScrollBar result = null;
        for (Node n : table.lookupAll(".scroll-bar")) {
            if (n instanceof ScrollBar) {
                ScrollBar bar = (ScrollBar) n;
                if (bar.getOrientation().equals(Orientation.VERTICAL)) {
                    result = bar;
                }
            }
        }       
        return result;
    }
       
    void scrolled(ObservableValue<? extends Number> observable, Number oldValue, Number newValue) {
        double value = newValue.doubleValue();
        System.out.println("Scrolled to " + value);
        ScrollBar bar = getVerticalScrollbar(table);
        if (value == bar.getMax()) {
            System.out.println("Adding new persons.");
            double targetValue = value * items.size();
            addPersons();
            bar.setValue(targetValue / items.size());
        }
    }
   
    public static void main(String[] args) {
        launch(args);
    }
   
    public class Person {
        private StringProperty firstName;
        public void setFirstName(String value) { firstNameProperty().set(value); }
        public String getFirstName() { return firstNameProperty().get(); }
        public StringProperty firstNameProperty() {
            if (firstName == null) firstName = new SimpleStringProperty(this, "firstName");
            return firstName;
        }
   
        private StringProperty lastName;
        public void setLastName(String value) { lastNameProperty().set(value); }
        public String getLastName() { return lastNameProperty().get(); }
        public StringProperty lastNameProperty() {
            if (lastName == null) lastName = new SimpleStringProperty(this, "lastName");
            return lastName;
        }
    }   
   
}