Monday, April 17, 2023

String.replace and String.replaceAll are totally different beasts - and named quite badly

We start out with some innocent code that will replace a variable with a certain value:

  public static void main(String[] args) {

  String quote = "$villain$ has an eye.";

        String villain = "Sauron";

  System.out.println(quote.replace("$villain$", villain));

  }

We get this output:

Sauron has an eye.

All is well until we remember that there was also someone called Saurons' Mouth, so we change our quote to:

        String quote = "$villain$ has an eye and $villain$ has a mouth.";

And since we now have multiple occurences of $villain$ we'll also use replaceAll instead of replace, right? Like this:

    public static void main(String[] args) {

        String quote = "$villain$ has an eye and $villain$ has a mouth.";

        String villain = "Sauron";

        System.out.println(quote.replaceAll("$villain$", villain));

    }

Surprisingly, we get this output:

$villain$ has an eye and $villain$ has a mouth.

This is because replaceAll expects a regularExpression as its first parameter and the dollar sign has special significance in regular expressions (denoting "end-of-line"). Even worse, though the presence of two methods named replace / replaceAll suggests that the former will only replace the first occurrence this is not the case, this is not the case. So the correct solution is to indeed still use replace:

    public static void main(String[] args) {
        String quote = "$villain$ has an eye and $villain$ has a mouth.";
        String villain = "Sauron";
        System.out.println(quote.replace("$villain$", villain));
    }

Takeaways:
  • This is a case of bad API design in Javas' String class. 
  • Always check, if parameters to String methods are plain strings or interpreted as regular expressions. 
    • There is also a possible performance penalty when using regular expressions

Thursday, October 22, 2015

Drag and Drop in a TreeTableView with scrolling

The following example shows a TreeTableView whose items can be rearranged by drag and drop.

Some remarks:
  • To enable scrolling, a row factory must be set which creates rows that handle the drag events.
  • When leaving the treetableview no scrolling will happen by JavaFX itself. Therefore we install listeners on the tree that start a timeline that will scroll up or down. This is a bit hacky since there is no official API to get the scrollbar from a TreeTableView.
Hope this helps ;-).

    import java.util.HashMap;
    import java.util.Map;
   
    import javafx.animation.KeyFrame;
    import javafx.animation.Timeline;
    import javafx.application.Application;
    import javafx.beans.property.ReadOnlyStringWrapper;
    import javafx.beans.value.ObservableValue;
    import javafx.geometry.Orientation;
    import javafx.scene.Node;
    import javafx.scene.Scene;
    import javafx.scene.control.ScrollBar;
    import javafx.scene.control.TreeItem;
    import javafx.scene.control.TreeTableColumn;
    import javafx.scene.control.TreeTableRow;
    import javafx.scene.control.TreeTableView;
    import javafx.scene.input.ClipboardContent;
    import javafx.scene.input.DataFormat;
    import javafx.scene.input.Dragboard;
    import javafx.scene.input.TransferMode;
    import javafx.scene.layout.VBox;
    import javafx.stage.Stage;
    import javafx.util.Duration;
       
    @SuppressWarnings("all")
    public class TreeTableViewDragAndDropDemo extends Application {
   
        private static final DataFormat SERIALIZED_MIME_TYPE = new DataFormat("application/x-java-serialized-object");
       
        private TreeItem<Map<String, Object>> root;
        private TreeTableView<Map<String, Object>> tree;
        private Timeline scrolltimeline = new Timeline();
        private double scrollDirection = 0;
   
        @Override
        public void start(Stage primaryStage) throws Exception {
            VBox outer = new VBox();
   
            root = new TreeItem<>();
            tree = new TreeTableView<>(root);
            tree.setShowRoot(false);
            tree.setRowFactory(this::rowFactory);
            addColumn("Region", "region");
            addColumn("Type", "type");
            addColumn("Pop.", "population");
            setupData();
            setupScrolling();
   
            outer.getChildren().addAll(tree);
            Scene scene = new Scene(outer, 640, 480);
            primaryStage.setScene(scene);
            primaryStage.show();
        }
   
        private void setupScrolling() {
            scrolltimeline.setCycleCount(Timeline.INDEFINITE);
            scrolltimeline.getKeyFrames().add(new KeyFrame(Duration.millis(20), "Scoll", (ActionEvent) -> { dragScroll();}));
            tree.setOnDragExited(event -> {
                if (event.getY() > 0) {
                    scrollDirection = 1.0 / tree.getExpandedItemCount();
                }
                else {
                    scrollDirection = -1.0 / tree.getExpandedItemCount();
                }
                scrolltimeline.play();
            });
            tree.setOnDragEntered(event -> {
                scrolltimeline.stop();
            });
            tree.setOnDragDone(event -> {
                scrolltimeline.stop();
            });
           
        }
       
        private void dragScroll() {
            ScrollBar sb = getVerticalScrollbar();
            if (sb != null) {
                double newValue = sb.getValue() + scrollDirection;
                newValue = Math.min(newValue, 1.0);
                newValue = Math.max(newValue, 0.0);
                sb.setValue(newValue);
            }
        }
       
        private ScrollBar getVerticalScrollbar() {
            ScrollBar result = null;
            for (Node n : tree.lookupAll(".scroll-bar")) {
                if (n instanceof ScrollBar) {
                    ScrollBar bar = (ScrollBar) n;
                    if (bar.getOrientation().equals(Orientation.VERTICAL)) {
                        result = bar;
                    }
                }
            }       
            return result;
        }
       
   
        private TreeTableRow<Map<String, Object>> rowFactory(TreeTableView<Map<String, Object>> view) {
            TreeTableRow<Map<String, Object>> row = new TreeTableRow<>();
            row.setOnDragDetected(event -> {
                if (!row.isEmpty()) {
                    Dragboard db = row.startDragAndDrop(TransferMode.MOVE);
                    db.setDragView(row.snapshot(null, null));
                    ClipboardContent cc = new ClipboardContent();
                    cc.put(SERIALIZED_MIME_TYPE, row.getIndex());
                    db.setContent(cc);
                    event.consume();
                }
            });
           
            row.setOnDragOver(event -> {
                Dragboard db = event.getDragboard();
                if (acceptable(db, row)) {
                    event.acceptTransferModes(TransferMode.COPY_OR_MOVE);
                    event.consume();
                }
            });
   
            row.setOnDragDropped(event -> {
                Dragboard db = event.getDragboard();
                if (acceptable(db, row)) {
                    int index = (Integer) db.getContent(SERIALIZED_MIME_TYPE);
                    TreeItem item = tree.getTreeItem(index);
                    item.getParent().getChildren().remove(item);
                    getTarget(row).getChildren().add(item);
                    event.setDropCompleted(true);
                    tree.getSelectionModel().select(item);
                    event.consume();
                }           
            });
           
            return row;
        }
       
        private boolean acceptable(Dragboard db, TreeTableRow<Map<String, Object>> row) {
            boolean result = false;
            if (db.hasContent(SERIALIZED_MIME_TYPE)) {
                int index = (Integer) db.getContent(SERIALIZED_MIME_TYPE);
                if (row.getIndex() != index) {
                    TreeItem target = getTarget(row);
                    TreeItem item = tree.getTreeItem(index);
                    result = !isParent(item, target);
                }
            }
            return result;
        }
   
        private TreeItem getTarget(TreeTableRow<Map<String, Object>> row) {
            TreeItem target = tree.getRoot();
            if (!row.isEmpty()) {
                target = row.getTreeItem();
            }
            return target;
        }
       
        // prevent loops in the tree
        private boolean isParent(TreeItem parent, TreeItem child) {
            boolean result = false;
            while (!result && child != null) {
                result = child.getParent() == parent;
                child = child.getParent();
            }
            return result;
        }
   
        private void setupData() {
            TreeItem<Map<String, Object>> europe = createItem(root, "Europe", "continent", 742500000L);
            TreeItem<Map<String, Object>> austria = createItem(europe, "Austria", "country", 847400L);
            createItem(austria, "Tyrol", "state", 728537L);       
            createItem(europe, "Russia ", "country", 144031000);
            createItem(europe, "Germany ", "country", 81276000);
            createItem(europe, "Turkey ", "country", 78214000);
            createItem(europe, "France ", "country", 67063000);
            createItem(europe, "Italy ", "country", 60963000);
            createItem(europe, "Spain ", "country", 46335000);
            createItem(europe, "Ukraine ", "country", 42850000);
            createItem(europe, "Poland ", "country", 38494000);
            createItem(europe, "Romania ", "country", 19822000);
            createItem(europe, "Kazakhstan ", "country", 17543000);
            createItem(europe, "Netherlands ", "country", 16933000);
            createItem(europe, "Belgium ", "country", 11259000);
            createItem(europe, "Greece ", "country", 10769000);
            createItem(europe, "Portugal ", "country", 10311000);
            createItem(europe, "Hungary ", "country", 9835000);
            createItem(europe, "Sweden ", "country", 9794000);
            createItem(europe, "Azerbaijan ", "country", 9651000);
            createItem(europe, "Belarus ", "country", 9481000);
            createItem(europe, "Switzerland ", "country", 8265000);
            createItem(europe, "Bulgaria ", "country", 7185000);
            createItem(europe, "Serbia ", "country", 7103000);
            createItem(europe, "Denmark ", "country", 5673000);
            createItem(europe, "Finland ", "country", 5475000);
            createItem(europe, "Slovakia ", "country", 5426000);
            createItem(europe, "Norway ", "country", 5194000);
            createItem(europe, "Ireland ", "country", 4630000);
            createItem(europe, "Croatia ", "country", 4230000);
            createItem(europe, "Bosnia and Georgia ", "country", 3707000);
            createItem(europe, "Moldova ", "country", 3564000);
            createItem(europe, "Armenia ", "country", 3010000);
            createItem(europe, "Lithuania ", "country", 2906000);
            createItem(europe, "Albania ", "country", 2887000);
            createItem(europe, "Macedonia ", "country", 2071000);
            createItem(europe, "Slovenia ", "country", 2065000);
            createItem(europe, "Latvia ", "country", 1979000);
            createItem(europe, "Kosovo ", "country", 1867000);
            createItem(europe, "Estonia ", "country", 1315000);
            createItem(europe, "Cyprus ", "country", 876000);
            createItem(europe, "Montenegro ", "country", 620000);
            createItem(europe, "Luxembourg ", "country", 570000);
            createItem(europe, "Transnistria ", "country", 505153);
            createItem(europe, "Malta ", "country", 425000);
            createItem(europe, "Iceland ", "country", 331000);
            createItem(europe, "Jersey (UK) ", "country", 103000);
            createItem(europe, "Andorra ", "country", 78000);
            createItem(europe, "Guernsey (UK) ", "country", 66000);
            createItem(europe, "Liechtenstein ", "country", 37000);
            createItem(europe, "Monaco ", "country", 37000);
            TreeItem<Map<String, Object>> america = createItem(root, "America", "continent", 953700000L);
            createItem(america, "USA", "country", 318900000L);
            createItem(america, "Mexico", "country", 122300000L);       
        }
       
        private TreeItem<Map<String, Object>> createItem(TreeItem<Map<String, Object>> parent, String region, String type, long population) {
            TreeItem<Map<String, Object>> item = new TreeItem<>();
            Map<String, Object> value = new HashMap<>();
            value.put("region",  region);
            value.put("type", type);
            value.put("population", population);
            item.setValue(value);
            parent.getChildren().add(item);
            item.setExpanded(true);
            return item;
        }
       
        protected void addColumn(String label, String dataIndex) {
            TreeTableColumn<Map<String, Object>, String> column = new TreeTableColumn<>(label);
            column.setPrefWidth(150);
            column.setCellValueFactory(
                (TreeTableColumn.CellDataFeatures<Map<String, Object>, String> param) -> {
                    ObservableValue<String> result = new ReadOnlyStringWrapper("");
                    if (param.getValue().getValue() != null) {
                        result = new ReadOnlyStringWrapper("" + param.getValue().getValue().get(dataIndex));
                    }
                    return result;
                }
            );       
            tree.getColumns().add(column);
        }
       
   
        public static void main(String[] args) {
            launch(args);
        }
   
    }

Monday, October 19, 2015

Filtering a TableTreeView in JavaFX

The following example demonstrates filtering in a TableTreeView in JavaFX:
    import java.util.HashMap;
    import java.util.Map;
   
    import javafx.animation.KeyFrame;
    import javafx.application.Application;
    import javafx.beans.InvalidationListener;
    import javafx.beans.Observable;
    import javafx.beans.binding.ListBinding;
    import javafx.beans.property.BooleanProperty;
    import javafx.beans.property.ReadOnlyStringWrapper;
    import javafx.beans.property.SimpleBooleanProperty;
    import javafx.beans.property.SimpleStringProperty;
    import javafx.beans.property.StringProperty;
    import javafx.beans.value.ChangeListener;
    import javafx.beans.value.ObservableValue;
    import javafx.collections.FXCollections;
    import javafx.collections.ListChangeListener;
    import javafx.collections.ObservableList;
    import javafx.event.ActionEvent;
    import javafx.event.EventHandler;
    import javafx.geometry.Insets;
    import javafx.geometry.Pos;
    import javafx.scene.Scene;
    import javafx.scene.control.Button;
    import javafx.scene.control.ButtonBuilder;
    import javafx.scene.control.CheckBox;
    import javafx.scene.control.CheckBoxBuilder;
    import javafx.scene.control.SelectionMode;
    import javafx.scene.control.TableCell;
    import javafx.scene.control.TableColumn;
    import javafx.scene.control.TableView;
    import javafx.scene.control.TextField;
    import javafx.scene.control.TreeItem;
    import javafx.scene.control.TreeTableColumn;
    import javafx.scene.control.TreeTableView;
    import javafx.scene.control.cell.PropertyValueFactory;
    import javafx.scene.layout.HBox;
    import javafx.scene.layout.StackPane;
    import javafx.scene.layout.VBox;
    import javafx.scene.layout.VBoxBuilder;
    import javafx.stage.Stage;
    import javafx.util.Callback;
    import javafx.util.Duration;
   
    @SuppressWarnings("all")
    public class TreeTableViewFilterDemo extends Application {
   
        private TreeItem<Map<String, Object>> root;
        private TreeTableView<Map<String, Object>> tree;
   
   
        @Override
        public void start(Stage primaryStage) throws Exception {
            VBox outer = new VBox();
   
            TextField filter = new TextField();
            filter.textProperty().addListener((observable, oldValue, newValue) -> filterChanged(newValue));
           
            root = new TreeItem<>();
            tree = new TreeTableView<>(root);
            addColumn("Region", "region");
            addColumn("Type", "type");
            addColumn("Pop.", "population");
            setup();
            tree.setShowRoot(false);
   
            outer.getChildren().addAll(filter, tree);
            Scene scene = new Scene(outer, 640, 480);
            primaryStage.setScene(scene);
            primaryStage.show();
        }
       
        private void filterChanged(String filter) {
            if (filter.isEmpty()) {
                tree.setRoot(root);           
            }
            else {
                TreeItem<Map<String, Object>> filteredRoot = new TreeItem<>();
                filter(root, filter, filteredRoot);
                tree.setRoot(filteredRoot);
            }
        }
   
   
        private void filter(TreeItem<Map<String, Object>> root, String filter, TreeItem<Map<String, Object>> filteredRoot) {
            for (TreeItem<Map<String, Object>> child : root.getChildren()) {           
                TreeItem<Map<String, Object>> filteredChild = new TreeItem<>();
                filteredChild.setValue(child.getValue());
                filteredChild.setExpanded(true);
                filter(child, filter, filteredChild );
                if (!filteredChild.getChildren().isEmpty() || isMatch(filteredChild.getValue(), filter)) {
                    System.out.println(filteredChild.getValue() + " matches.");
                    filteredRoot.getChildren().add(filteredChild);
                }
            }
        }
   
        private boolean isMatch(Map<String, Object> value, String filter) {
            return value.values().stream().anyMatch((Object o) -> o.toString().contains(filter));
        }
   
        private void setup() {
            TreeItem<Map<String, Object>> europe = createItem(root, "Europe", "continent", 742500000L);
            createItem(europe, "Germany", "country", 80620000L);
            TreeItem<Map<String, Object>> austria = createItem(europe, "Austria", "country", 847400L);
            createItem(austria, "Tyrol", "state", 728537L);       
            TreeItem<Map<String, Object>> america = createItem(root, "America", "continent", 953700000L);
            createItem(america, "USA", "country", 318900000L);
            createItem(america, "Mexico", "country", 122300000L);       
        }
       
        private TreeItem<Map<String, Object>> createItem(TreeItem<Map<String, Object>> parent, String region, String type, long population) {
            TreeItem<Map<String, Object>> item = new TreeItem<>();
            Map<String, Object> value = new HashMap<>();
            value.put("region",  region);
            value.put("type", type);
            value.put("population", population);
            item.setValue(value);
            parent.getChildren().add(item);
            item.setExpanded(true);
            return item;
        }
       
        protected void addColumn(String label, String dataIndex) {
            TreeTableColumn<Map<String, Object>, String> column = new TreeTableColumn<>(label);
            column.setPrefWidth(150);
            column.setCellValueFactory(
                (TreeTableColumn.CellDataFeatures<Map<String, Object>, String> param) -> {
                    ObservableValue<String> result = new ReadOnlyStringWrapper("");
                    if (param.getValue().getValue() != null) {
                        result = new ReadOnlyStringWrapper("" + param.getValue().getValue().get(dataIndex));
                    }
                    return result;
                }
            );       
            tree.getColumns().add(column);
        }
       
   
        public static void main(String[] args) {
            launch(args);
        }
   
    }

Wednesday, November 19, 2014

Metaprogramming Trap with Interfaces in Java 8

Consider the following little example:
package prv.rli.codetest;

import java.lang.reflect.Method;

public class BreakingInterfaces  {
    interface Base {
        BaseFoo foo();
        interface BaseFoo {           
        }
    }
   
    interface Derived extends Base {
        DerivedFoo foo();
        interface DerivedFoo extends BaseFoo {
           
        }
    }
   
    public static void main(String[] args) {       
        dumpDeclaredMethods(Derived.class);
    }

    private static void dumpDeclaredMethods(Class<?> class1) {
        System.out.println("---" + class1.getSimpleName() + "---");
        Method[] methods = class1.getDeclaredMethods();
        for (Method method : methods) {
            System.out.println(method);
        }
        System.out.println("----------");
    }
}
What would you expect to be the output? We have a derived Interface which overrides the only method of its base interface in a covariant way. So one method only.

If you compile the above examplt with jdk1.7.0.55 the output is indeed:
 ---Derived---
public abstract BreakingInterfaces$Derived$DerivedFoo BreakingInterfaces$Derived.foo()
----------
But when using jdk1.8.0.25 the output is:
---Derived---
public abstract prv.rli.codetest.BreakingInterfaces$Derived$DerivedFoo prv.rli.codetest.BreakingInterfaces$Derived.foo()
public default prv.rli.codetest.BreakingInterfaces$Base$BaseFoo prv.rli.codetest.BreakingInterfaces$Derived.foo()
----------
 For reasons unknown to me, the base class method resurfaces here which may lead to unwanted behaviour when using reflection.

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