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);
    }
}

No comments:

Post a Comment