June 2, 2008

Java GridLayout can't center extra space

Java's GridLayout design appears to lack some forethought. You can't center the elements that are laid out by Java's GridLayout class if the space cannot be evenly distributed among the number of columns (or rows). You can do left-to-right, right-to-left, top-to-bottom, and/or bottom-to-top, but you cannot center. This seems quite silly.

In the end, I wound up subclassing GridLayout and fixing the mistake. GridBagLayout was an inappropriate alternative, since GridBagLayout relies on the preferredWidth of its constituent elements to lay out a container and I just wanted a grid! A simple grid that centers elements in the container if it can't use up all the space can be found inline:

import java.awt.*;

/**
 * Centers the lopsided extra pixel distribution from GridLayout to within
 * one-half pixel margin of error.
 *
 * @author Chris Leary
 */
public class CenteredGridLayout extends GridLayout {

    public CenteredGridLayout() {}

    public CenteredGridLayout(int rows, int cols) {
        super(rows, cols);
    }

    public CenteredGridLayout(int rows, int cols, int hgap, int vgap) {
        super(rows, cols, hgap, vgap);
    }

    /**
     * @return  3-tuple (starts, perObj, realDims).
     *          starts are starting pixel deltas in relation to the
     *              parent container
     *          perObj = (pixelsPerObjX, pixelsPerObjY)
     *          realDims = (rowsX, colsY); automatically calculated if not
     *              set to 0 on the container
     */
    private int[][] lcHelper(Container parent, int componentCount) {
        /*
         * The available space for actual objects is:
         *  parent.width - leftInset - rightInset - hgap * (cols - 1);
         *
         * Note that the (cols - 1) is because the last item needs no hgap.
         *
         * If the available space isn't evenly divisible into the number of
         * columns, we have to distribute the remainder evenly across the
         * insets.
         *
         * If the remainder isn't evenly divisible into the two insets, the
         * right/bottom inset is given the extra pixel.
         */
        int rows = getRows();
        int cols = getColumns();
        Insets insets = parent.getInsets();
        /* Calculate dimensions if not explicitly given. */
        int realCols = (cols == 0)
            ? (int) Math.ceil(((double) componentCount) / rows)
            : cols;
        int realRows = (rows == 0)
            ? (int) Math.ceil(((double) componentCount) / cols)
            : rows;

        /* Helper values. */
        int hInset = insets.left + insets.right;
        int vInset = insets.bottom + insets.top;
        int parentHeight = parent.getHeight();
        int parentWidth = parent.getWidth();

        /* Distribution calculations. */
        int hGapTotal = (realCols - 1) * getHgap();
        int vGapTotal = (realRows - 1) * getVgap();
        int widthPerItem = (parentWidth - hInset - hGapTotal) / realCols;
        int heightPerItem = (parentHeight - vInset - vGapTotal) / realRows;
        int extraWidth = parentWidth
            - (widthPerItem * realCols + hGapTotal);
        int extraHeight = parentHeight
            - (heightPerItem * realRows + vGapTotal);

        /* Package values in containers for return. */
        int[] starts = { /* x, y */
            insets.left + extraWidth / 2,
            insets.top + extraHeight / 2};
        int[] perObj = { widthPerItem, heightPerItem };
        int[] realDims = { realCols, realRows };
        return new int[][] { starts, perObj, realDims };
    }

    /**
     * Set bounds for objects within parent.
     * @param parent    Container being layed out.
     */
    @Override
    public void layoutContainer(Container parent) {
        synchronized (parent.getTreeLock()) {
            int componentCount = parent.getComponentCount();
            if (componentCount == 0) {
                return; /* Nothing to lay out. */
            }
            /* Unpack data calculated by helper. */
            int[][] params = lcHelper(parent, componentCount);
            int[] starts = params[0];
            int[] perObj = params[1];
            int[] realDims = params[2];
            int realCols = realDims[0];
            int realRows = realDims[1];

            /* Move down the height per object plus vertical gap
             * per row. */
            for (
                int y = starts[1], row = 0;
                row < realRows;
                y += perObj[1] + getVgap(), row++
            ) {
                /* Move over the width per object plus horizontal gap per
                 * row. */
                for (
                    int x = starts[0], col = 0;
                    col < realCols;
                    x += perObj[0] + getHgap(), col++
                ) {
                    int arrayIndex = row * realCols + col;
                    parent.getComponent(arrayIndex)
                        .setBounds(x, y, perObj[0], perObj[1]);
                }
            }
        }
    }
}

Here's an illustration of the normal GridLayout failing to allocate my space evenly, using a left-to-right and top-to-bottom setting:

There are 8 pixels of extra space maximum, since 8 pixels isn't evenly divisible over the nine cells.