TilePane with automatically stretching tiles in JavaFX
Asked Answered
T

2

6

Is there any way in JavaFX to take best from both TilePane or FlowPane and GridPane?
Here's what I'd like to achieve:

First, I like the idea of GridPane where I can set up a M×N grid which automatically resizes within its parent container to divide the space equally into M columns and N rows. Then I can put some child elements which fill each cell completely, and they will stretch along with the grid. This is cool.
But there's one drawback: I need to explicitly specify where should each control go, by setting its row & column number.

Then, there are layout containers such as FlowPane or TilePane which automatically reflow their child elements when the parent container changes its size. When I add another child element, it is automatically attached at the end of the list, and the list of elements automatically wraps after reaching the edge of the container when there's too few space to fit another element in there.
But here's a drawback as well: the child elements can only have rigid, pre-defined sizes. They won't stretch with its parent element.

And here's what I need:
I need the best from both of these containers, that is, I want a M by N grid (let's say 4×4) where each cell is ParentWidth/M by ParentHeight/N and stretches along with the window, so that it is always 4×4 cells, but their sizes (along with the sizes of their contents) stretches accordingly. But I don't want to tell the container explicitly in which row & column to put every new child I add there. Instead, I want to just add it and let the container figure out the first empty cell to put it in, filling the cells from left to right, then top to bottom if there's no empty cell left in the current row.

Is there some magical setup for any of these predefined containers' attributes which would allow me to achieve this? Or do I need to write such a container myself?

Tsunami answered 25/4, 2016 at 3:51 Comment(1)
Perhaps review this implementation of a grid based view of a color chooser and see if some of the logic there helps you achieve your goals. Try running it and resizing the color chooser to see its behavior as size changes.Boot
T
0

OK here's my own attempt at the solution:

Since the GridPane works almost the way I need, just doesn't automatically flow its contents, I decided to subclass GridPane and add custom code for automatic flowing its child controls. (Thanks to @jns for the hint that JavaFX library control classes can be subclassed.)

So I subclassed the GridPane and added a listener for its internal list of children, so that whenever a child is added or removed from that list, or the list changes in some way (e.g. we reorder the children), a private method is called to reflow the children in the grid by assigning them to correct columns and rows by their position in the list. I also added two private helper functions to convert the position in the list into row & column numbers and back.

I know that this isn't perfect nor elegant, but hey, it works, and it works well enough for my needs :P so I publish the code here in case someone else had the same problem:

package flowgrid;

import javafx.collections.ObservableList;
import javafx.collections.ListChangeListener;

import javafx.scene.Node;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.ColumnConstraints;
import javafx.scene.layout.RowConstraints;
import javafx.scene.layout.Priority;
import javafx.geometry.HPos;
import javafx.geometry.VPos;

import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.NamedArg;

/**
 * This class subclasses the GridPane layout class.
 * It manages its child nodes by arranging them in rows of equal number of tiles.
 * Their order in the grid corresponds to their indexes in the list of children
 * in the following fashion (similarly to how FlowPane works):
 * 
 *      +---+---+---+---+
 *      | 0 | 1 | 2 | 3 |
 *      +---+---+---+---+
 *      | 4 | 5 | 6 | 7 |
 *      +---+---+---+---+
 *      | 8 | 9 | … |   |
 *      +---+---+---+---+
 *  
 * It observes its internal list of children and it automatically reflows them
 * if the number of columns changes or if you add/remove some children from the list.
 * All the tiles of the grid are of the same size and stretch accordingly with the control.
 */
public class FlowGridPane extends GridPane
{
   // Properties for managing the number of rows & columns.
   private IntegerProperty rowsCount;
   private IntegerProperty colsCount;

   public final IntegerProperty colsCountProperty() { return colsCount; }
   public final Integer getColsCount() { return colsCountProperty().get(); }
   public final void setColsCount(final Integer cols) {
      // Recreate column constraints so that they will resize properly.
      ObservableList<ColumnConstraints> constraints = getColumnConstraints();
      constraints.clear();
      for (int i=0; i < cols; ++i) {
         ColumnConstraints c = new ColumnConstraints();
         c.setHalignment(HPos.CENTER);
         c.setHgrow(Priority.ALWAYS);
         c.setMinWidth(60);
         constraints.add(c);
      }
      colsCountProperty().set(cols);
      reflowAll();
   }

   public final IntegerProperty rowsCountProperty() { return rowsCount; }
   public final Integer getRowsCount() { return rowsCountProperty().get(); }
   public final void setRowsCount(final Integer rows) {
      // Recreate column constraints so that they will resize properly.
      ObservableList<RowConstraints> constraints = getRowConstraints();
      constraints.clear();
      for (int i=0; i < rows; ++i) {
         RowConstraints r = new RowConstraints();
         r.setValignment(VPos.CENTER);
         r.setVgrow(Priority.ALWAYS);
         r.setMinHeight(20);
         constraints.add(r);
      }
      rowsCountProperty().set(rows);
      reflowAll();
   }

   /// Constructor. Takes the number of columns and rows of the grid (can be changed later).
   public FlowGridPane(@NamedArg("cols")int cols, @NamedArg("rows")int rows) {
      super();
      colsCount = new SimpleIntegerProperty();  setColsCount(cols);
      rowsCount = new SimpleIntegerProperty();  setRowsCount(rows);
      getChildren().addListener(new ListChangeListener<Node>() {
         public void onChanged(ListChangeListener.Change<? extends Node> change) {
            reflowAll();
         }
      } );
   }

   // Helper functions for coordinate conversions.
   private int coordsToOffset(int col, int row) { return row*colsCount.get() + col; }
   private int offsetToCol(int offset) { return offset%colsCount.get(); }
   private int offsetToRow(int offset) { return offset/colsCount.get(); }

   private void reflowAll() {
      ObservableList<Node> children = getChildren();
      for (Node child : children ) {
         int offs = children.indexOf(child);
         GridPane.setConstraints(child, offsetToCol(offs), offsetToRow(offs) );
      }
   }
}

As you can see, I also used properties for the number of rows and columns, with getters and setters. When one uses a setter to change the number of rows or columns, the internal lists of column/row constraints inside GridPane are being recreated so that they resized properly, and the content is being reflown when the number of columns changes.

There are also @NamedArg annotations in the constructor, so that one could create this control with an initial number of rows & columns from the layout described in an FXML file.

Since it pretty much "solved" my problem in a way, I'm marking my answer as accepted. But if anyone will post a better solution, I'll gladly accept his answer then.

Also feel free to tell me if you have any suggestions about improvements of this code, or if you think something could be done more elegantly, since – as I said – it is hardly perfect yet.

Tsunami answered 4/5, 2016 at 15:51 Comment(0)
G
4

Exactly for the purpose you are describing, I created the ButtonGridPane . It's a first draft, and there is still room for improvement, but maybe it can give you a basic idea.

public class ButtonGrid extends Pane {

    private static final double DEFAULT_RATIO = 0.618033987;

    private int                 columnCount;
    private double              ratio;

    public ButtonGrid(int columnCount) {
        this(columnCount, DEFAULT_RATIO);
    }

    public ButtonGrid(int columnCount, double heightToWidthRatio) {
        getStyleClass().add("button-grid");
        this.columnCount = columnCount;
        ratio = heightToWidthRatio;
    }

    public void setColumnCount(int columnCount) {
        this.columnCount = columnCount;
    }

    public void setHeightToWidthRatio(double ratio) {
        this.ratio = ratio;
    }

    @Override
    public Orientation getContentBias() {
        return Orientation.HORIZONTAL;
    }

    @Override
    protected void layoutChildren() {
        double left = getInsets().getLeft();
        double top = getInsets().getTop();

        double tileWidth = calculateTileWidth(getWidth());
        double tileHeight = calculateTileHeight(getWidth());

        ObservableList<Node> children = getChildren();
        double currentX = left;
        double currentY = top;
        for (int idx = 0; idx < children.size(); idx++) {
            if (idx > 0 && idx % columnCount == 0) {
                currentX = left;
                currentY = currentY + tileHeight;
            }
            children.get(idx).resize(tileWidth, tileHeight);
            children.get(idx).relocate(currentX, currentY);
            currentX = currentX + tileWidth;
        }
    }

    @Override
    protected double computePrefWidth(double height) {
        double w = 0;
        for (int idx = 0; idx < columnCount; idx++) {
            Node node = getChildren().get(idx);
            w += node.prefWidth(-1);
        }
        return getInsets().getLeft() + w + getInsets().getRight();
    }

    @Override
    protected double computePrefHeight(double width) {
        double h = calculateTileHeight(width) * getRowCount();
        return getInsets().getTop() + h + getInsets().getBottom();
    }

    private double calculateTileHeight(double width) {
        return calculateTileWidth(width) * ratio;
    }

    private double calculateTileWidth(double width) {
        return (-getInsets().getLeft() + width - getInsets().getRight()) / columnCount;
    }

    private int getRowCount() {
        return getChildren().size() / columnCount;
    }
}
Genu answered 25/4, 2016 at 4:11 Comment(4)
Hmm so it's possible to just subclass a built-in JavaFX control class and add my own functionality? Because if that is the case, I guess the best way to go is to subclass the GridPane and just add some counters which would keep track of the first empty cell and update upon adding/removing child elements. But I'm afraid there might be more trouble when the user removes a control somewhere in the middle, because in that case, all subsequent controls need to be reflown :/Tsunami
Another way I can think of is to change the prefTileWidth and prefTileHeight accordingly every time the TilePane container changes its size. The problem is I cannot find any events regarding resizing in the TilePane class :/Tsunami
"The problem is I cannot find any events regarding resizing in the TilePane class" => see layoutChildren()Boot
Interesting. Could it be done only for subclassed controls? Or could this be captured as some event for a pre-existing control as well? Because what I really need is to just get informed when the parent container changes its sizes from whatever reasons. For my own subclassed controls, though, then sure, I'll check this method out, sounds interesting...Tsunami
T
0

OK here's my own attempt at the solution:

Since the GridPane works almost the way I need, just doesn't automatically flow its contents, I decided to subclass GridPane and add custom code for automatic flowing its child controls. (Thanks to @jns for the hint that JavaFX library control classes can be subclassed.)

So I subclassed the GridPane and added a listener for its internal list of children, so that whenever a child is added or removed from that list, or the list changes in some way (e.g. we reorder the children), a private method is called to reflow the children in the grid by assigning them to correct columns and rows by their position in the list. I also added two private helper functions to convert the position in the list into row & column numbers and back.

I know that this isn't perfect nor elegant, but hey, it works, and it works well enough for my needs :P so I publish the code here in case someone else had the same problem:

package flowgrid;

import javafx.collections.ObservableList;
import javafx.collections.ListChangeListener;

import javafx.scene.Node;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.ColumnConstraints;
import javafx.scene.layout.RowConstraints;
import javafx.scene.layout.Priority;
import javafx.geometry.HPos;
import javafx.geometry.VPos;

import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.NamedArg;

/**
 * This class subclasses the GridPane layout class.
 * It manages its child nodes by arranging them in rows of equal number of tiles.
 * Their order in the grid corresponds to their indexes in the list of children
 * in the following fashion (similarly to how FlowPane works):
 * 
 *      +---+---+---+---+
 *      | 0 | 1 | 2 | 3 |
 *      +---+---+---+---+
 *      | 4 | 5 | 6 | 7 |
 *      +---+---+---+---+
 *      | 8 | 9 | … |   |
 *      +---+---+---+---+
 *  
 * It observes its internal list of children and it automatically reflows them
 * if the number of columns changes or if you add/remove some children from the list.
 * All the tiles of the grid are of the same size and stretch accordingly with the control.
 */
public class FlowGridPane extends GridPane
{
   // Properties for managing the number of rows & columns.
   private IntegerProperty rowsCount;
   private IntegerProperty colsCount;

   public final IntegerProperty colsCountProperty() { return colsCount; }
   public final Integer getColsCount() { return colsCountProperty().get(); }
   public final void setColsCount(final Integer cols) {
      // Recreate column constraints so that they will resize properly.
      ObservableList<ColumnConstraints> constraints = getColumnConstraints();
      constraints.clear();
      for (int i=0; i < cols; ++i) {
         ColumnConstraints c = new ColumnConstraints();
         c.setHalignment(HPos.CENTER);
         c.setHgrow(Priority.ALWAYS);
         c.setMinWidth(60);
         constraints.add(c);
      }
      colsCountProperty().set(cols);
      reflowAll();
   }

   public final IntegerProperty rowsCountProperty() { return rowsCount; }
   public final Integer getRowsCount() { return rowsCountProperty().get(); }
   public final void setRowsCount(final Integer rows) {
      // Recreate column constraints so that they will resize properly.
      ObservableList<RowConstraints> constraints = getRowConstraints();
      constraints.clear();
      for (int i=0; i < rows; ++i) {
         RowConstraints r = new RowConstraints();
         r.setValignment(VPos.CENTER);
         r.setVgrow(Priority.ALWAYS);
         r.setMinHeight(20);
         constraints.add(r);
      }
      rowsCountProperty().set(rows);
      reflowAll();
   }

   /// Constructor. Takes the number of columns and rows of the grid (can be changed later).
   public FlowGridPane(@NamedArg("cols")int cols, @NamedArg("rows")int rows) {
      super();
      colsCount = new SimpleIntegerProperty();  setColsCount(cols);
      rowsCount = new SimpleIntegerProperty();  setRowsCount(rows);
      getChildren().addListener(new ListChangeListener<Node>() {
         public void onChanged(ListChangeListener.Change<? extends Node> change) {
            reflowAll();
         }
      } );
   }

   // Helper functions for coordinate conversions.
   private int coordsToOffset(int col, int row) { return row*colsCount.get() + col; }
   private int offsetToCol(int offset) { return offset%colsCount.get(); }
   private int offsetToRow(int offset) { return offset/colsCount.get(); }

   private void reflowAll() {
      ObservableList<Node> children = getChildren();
      for (Node child : children ) {
         int offs = children.indexOf(child);
         GridPane.setConstraints(child, offsetToCol(offs), offsetToRow(offs) );
      }
   }
}

As you can see, I also used properties for the number of rows and columns, with getters and setters. When one uses a setter to change the number of rows or columns, the internal lists of column/row constraints inside GridPane are being recreated so that they resized properly, and the content is being reflown when the number of columns changes.

There are also @NamedArg annotations in the constructor, so that one could create this control with an initial number of rows & columns from the layout described in an FXML file.

Since it pretty much "solved" my problem in a way, I'm marking my answer as accepted. But if anyone will post a better solution, I'll gladly accept his answer then.

Also feel free to tell me if you have any suggestions about improvements of this code, or if you think something could be done more elegantly, since – as I said – it is hardly perfect yet.

Tsunami answered 4/5, 2016 at 15:51 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.