One of the benefits of creating a user interface in JavaFX is its ability to run on multiple devices. But how do you make sure your app looks great on every device – even between screen sizes?

The simple answer is to use a responsive layout. That means multiple, often very different views for different screen-sizes and displays.

JavaFX doesn’t support responsive layouts natively. However, by using the layout panes already provided by JavaFX, and with a few simple lines of code, it’s possible to make JavaFX completely responsive.

All it takes is 200 lines of code – and you get them all for free (obviously).

The Options For Making A Responsive JavaFX

Any attempt to make JavaFX behave responsively must start by registering a listener to the widthProperty of the Stage object. Changes to the view are then be initiated using conditional logic based on the new width of the Stage.

We can actually initiate changes in a number of ways. The absolute most complicated way to do this is to create custom code that rearranges your scene.

Manually rearranging contents

In a simple implementation, responsive behaviour can be generated by manually re-building the view, either by reassigning the contents between multiple containers, or by loading an alternative view using FXML.

This can be effective if the view size is unlikely to change once the user has started. However, it’s really inefficient for use cases where the screen size will change a lot.

On top of that, it might work well on a simple use case, but when you try to roll it out to a crowded scene, the complexity suddenly sky-rockets. If your UI has more than 2 or 3 responsive components, you may spend more code shifting elements than you do defining useful behaviour.

Instead, I think the sensible approach is to define the behaviour first, independent of scene complexity.

Automated responsive behaviour

Overall, it’s more efficient to manage responsive behaviour by designing a layout pane that handles the logic of rearranging the components within it for you.

There are a few ways to do that. Possibly the cleanest (but not the easiest) is to create a completely custom layout pane. The Pane class is extensible, and you’re welcome to use the algorithms below to work through the layout logic yourself. However, there are a lot of tricky layout features like margins and padding to be aware of when creating a completely new layout pane.

A simpler approach (and the one I’ll use here) is to extend one of the existing layout panes, baking in the behaviour using its already well-coded layout logic. That way, you don’t have to worry about coding out CSS styling, or handling padding and insets.

Making JavaFX behave like a Bootstrap Grid

JavaFX can be easily modified to simulate the behaviour of Twitter Bootstrap by replicating the grid, row and column elements of the Bootstrap Grid system. The grid object should extend a JavaFX layout such as a GridPane, whilst additional classes should be created to handle row and column behaviours.

I’ll walk through how we’ll define each below, but this is the basic behaviour we’ll achieve. Each coloured block represents a column within the grid.

The behaviours we’ll replicate in Java code

The Bootstrap Grid system (now at version 5) is fairly mature, so it has a lot of custom behaviours like modifiable visibility and alignment.

What I want to replicate here are just the layout behaviours – how to get something to be a different width at varying screen or window sizes. The basic layout elements of the grid system, which we’ll be mimicking, implement the following behaviours:

  1. The system is defined by one Grid, which may have one or more rows.
  2. Rows are laid out vertically within the grid in the order they are added.
  3. Each row contains one or more columns,
  4. Columns have a width defined from 1 to 12, where 12 represents 100% of the width of the Bootstrap Grid.
  5. If a row has columns with a total width of greater than 12, the contents wrap.
  6. Columns are usually containers rather than components. Containers can be empty.
  7. The width of a column may be defined for multiple screen sizes (there are 5 screen sizes (XS, S, M, L, XL).
  8. If no behaviour is defined for one screen size, behaviour is searched for in order of decreasing size.

Creating Grid, Row and Column Objects in Java

Alright, we’re going to create 3 Java objects – I won’t repeat what they are because your eyes are probably bleeding from the repetition. But here are the steps:

  • The grid is going to lay out the rows from top to bottom
JavaFX can replicate behaviours of the bootstrap grid using simple layout commands
  • The row is going to lay out columns from left to right, wrapping where necessary
JavaFX can replicate behaviours of the bootstrap row using simple layout commands
  • The column is going to keep track of how wide it’s supposed to be at each breakpoint.
JavaFX can replicate behaviours of the bootstrap column using simple layout commands

Once that’s all sorted, we should be able to add objects, and have the system handle how to place them without any further input.

1. Grid

It’s almost too simple, but to replicate the behaviour of the Bootstrap Grid, I’m going to extend the GridPane layout pane. To make it work, we’ll need:

  1. Default top-center alignment
  2. A GridPane with 12 columns of equal width.
  3. A register on the width property that rearranges nodes when the breakpoint changes
public class BootstrapPane extends GridPane {
    private Breakpoint currentWindowSize = Breakpoint.XSMALL;
    public BootstrapPane() {
        super();
        setAlignment(Pos.TOP_CENTER);
        setColumnConstraints();
        setWidthEventHandlers();
    }
    private void setWidthEventHandlers() {
        this.widthProperty().addListener((observable, oldValue, newValue) -> {
            Breakpoint newBreakpoint = Breakpoint.XSMALL;
            if (newValue.doubleValue() > 576) newBreakpoint = Breakpoint.SMALL;
            if (newValue.doubleValue() > 768) newBreakpoint = Breakpoint.MEDIUM;
            if (newValue.doubleValue() > 992) newBreakpoint = Breakpoint.LARGE;
            if (newValue.doubleValue() > 1200) newBreakpoint = Breakpoint.XLARGE;
            if (newBreakpoint != currentWindowSize) {
                currentWindowSize = newBreakpoint;
                calculateNodePositions();
            }
        });
    }
    private void setColumnConstraints() {
        //Remove all current columns.
        getColumnConstraints().clear();
        //Create 12 equally sized columns for layout
        double width = 100.0 / 12.0;
        for (int i = 0; i < 12; i++) {
            ColumnConstraints columnConstraints = new ColumnConstraints();
            columnConstraints.setPercentWidth(width);
            getColumnConstraints().add(columnConstraints);
        }
    }
    private void calculateNodePositions() {
        int currentGridPaneRow = 0;
        for (BootstrapRow row : rows) {
            currentGridPaneRow += row.calculateRowPositions(currentGridPaneRow, currentWindowSize);
        }
    }
}

Here, the constructor sets the alignment as Pos.TOP_CENTER

Then, it calls setColumnConstraints(), which creates 12 columns of equal width. This is perfect for a bootstrap grid.

Finally, it registers an event handler on its own widthProperty to initiate changes to its layout structure when the width of the window passes a breakpoint.

Note that all calculateNodePositions() is going to do is invoke each BootstrapRow to layout its BootstrapColumn objects. It will be triggered every time the BootstrapPane detects the current window size (known as a breakpoint) has changed.

We’ll also need methods to add and remove BootstrapRow objects as they’re added and removed.

    private final List<BootstrapRow> rows = new ArrayList<>();
    public void addRow(BootstrapRow row) {
        if (rows.contains(row)) return; //prevent duplicate children error
        rows.add(row);
        calculateNodePositions();
        for (BootstrapColumn column : row.getColumns()) {
            getChildren().add(column.getContent());
            GridPane.setFillWidth(column.getContent(), true);
            GridPane.setFillHeight(column.getContent(), true);
        }
    }
    public void removeRow(BootstrapRow row) {
        rows.remove(row);
        calculateNodePositions();
        for (BootstrapColumn column : row.getColumns()) {
            getChildren().remove(column.getContent());
        }
    }

2. Row

The key behaviour in this implementation is to take the incoming GridPane row from the row before it, layout all of its contents and return the new GridPane row position – essentially keeping track of where we are.

public class BootstrapRow {
    public int calculateRowPositions(int lastGridPaneRow, Breakpoint currentWindowSize) {
        int inputRow = lastGridPaneRow;
        if (this.getColumns().isEmpty()) return 0;
        int currentGridPaneColumn = 0; //start in the first column
        for (BootstrapColumn column : this.getColumns()) {
            int contentWidth = column.getColumnWidth(currentWindowSize);
            if (currentGridPaneColumn + contentWidth > 12) {
                lastGridPaneRow++;
                currentGridPaneColumn = 0;
            }
            GridPane.setConstraints(
                    column.getContent(),
                    currentGridPaneColumn,
                    lastGridPaneRow,
                    contentWidth,
                    1
            );
            currentGridPaneColumn += contentWidth;
        }
        return lastGridPaneRow - inputRow + 1;
    }
}

Now, the BootstrapRow is really just a middle manager – I want the BootstrapPane to be able to actually display the columns. So we need to give the BootstrapPane access to the columns.

Then, when a row is added, it can add all the columns as children to allow JavaFX to display them in the scene.

    public List<BootstrapColumn> getColumns(){
        return Collections.unmodifiableList(columns);
    }

I don’t want users to be able to modify the list directly, but I’m also wildly allergic to package-private statements unless they’re completely necessary. To get around that, I’ve used the Collections.unmodifiableList wrapper. There are other ways to do it, but I’ve chosen that one.

Now, from a user’s perspective, the row will need methods to allow users to add and remove BootstrapColumn objects.

    private final List<BootstrapColumn> columns = new ArrayList<>();
    public void addColumn(BootstrapColumn column){
        if(column == null) return;
        columns.add(column);
    }
    public void removeColumn(BootstrapColumn column){
        columns.remove(column);
    }
    public void clear(){
        columns.clear();  //remove all columns
    }

3. Column

Finally, we need a column that keeps track of how wide it’s meant to be, and feed that information back to it’s associated BootstrapRow when prompted.

Again, I’m going to use a pretty simple implementation – we’ll store the column widths in an array, and set/unset the values (with -1 indicating an unset value) as needed. For simplicity, default column width is 1.

public class BootstrapColumn {
    private final Node content;
    int[] columnWidths = new int[]{
            1,  //XS (default)
            -1, //Sm
            -1, //Md
            -1, //Lg
            -1  //XL
    };
    public BootstrapColumn(Node content) {
        this.content = content;
    }
    public void setBreakpointColumnWidth(Breakpoint breakPoint, int width) {
        columnWidths[breakPoint.getValue()] = MathUtils.clamp(width, 1, 12);
    }
    public void unsetBreakPoint(Breakpoint breakPoint) {
        columnWidths[breakPoint.getValue()] = -1;
    }
    public void unsetAllBreakPoints() {
        this.columnWidths = new int[]{
                1,  //XS (default)
                -1, //Sm
                -1, //Md
                -1, //Lg
                -1  //XL
        };
    }
    public int getColumnWidth(Breakpoint breakPoint) {
        //Iterate through breakpoints, beginning at the specified bp, travelling down. Return first valid bp value.
        for (int i = breakPoint.getValue(); i >= 0; i--) {
            if (isValid(columnWidths[i])) return columnWidths[i];
        }
        //If none are valid, return 1
        return 1;
    }
    public Node getContent() {
        return content;
    }
    private boolean isValid(int value) {
        return value > 0 && value <= 12;
    }
}

4. Breakpoints

I know, I know, I said we were going to create three objects, but to create a simple, type-safe way to link a breakpoint with its position in the BootstrapColumn‘s columnWidths array.

The following enum allows for five named breakpoints, with their corresponding list position.

public enum Breakpoint {
    XSMALL(0),
    SMALL(1),
    MEDIUM(2),
    LARGE(3),
    XLARGE(4);
    private int value;
    Breakpoint(int value) {
        this.value = value;
    }
    public int getValue() {
        return value;
    }
}

The result:

The resulting logic allows us to create and define columns, setting their widths by invoking setBreakpointColumnWidth(), and then load them into rows and grids that will then automatically adjust their position and size based on a clear set of pre-defined rules.

The following quick build is a really simple demonstration of the type of behaviour you can achieve using really small amounts of code.

The full code can be found in my responsive-javafx github repo, including the style sheets used to create the dark-style design.

Conclusions

Creating a responsive JavaFX feel is much simpler than it seems. In 4 classes and just over 200 lines of code, it’s possible to completely reproduce the simple layout behaviours demonstrated by Twitter Bootstrap.

With a little more code, the visibility and alignment features could be added for a complete Flex-box replica within JavaFX.

Hopefully this gave you some inspiration to think about ways you could stretch JavaFX across multiple devices. If you have any ideas for how to improve the features in the code I’ve written, please reach out using the contact form in the right hand menu, I’d love to hear from you.