One of the really neat things about the JavaFX ListView control is the Cell API, and the ability to have dynamically variable row heights, without sacrificing performance or scalability. To accomplish this, we’ll write a custom Cell factory which will create a Cell who’s size depends on some state.

To recap briefly, to remain scalable and fast, the ListView reuses Cells for rendering each row in the ListView. Because it reuses Cells, each Cell needs to be designed so that it does the right thing no matter what row it is asked to represent — even empty rows which are used only for filling out a ListView and not for actually holding valid data.

The first thing we need to do is create a ListView with some data and a cell factory. The simple boilerplate to do that is:

ListView {
    items: [1..40]
    cellFactory: function() {
        def cell:ListCell = ListCell {
            node: Label {
                text: bind if (cell.empty) then "" else "{cell.item}"
            }
        }
        return cell;
    }
}

Because our Cells want to animate between being opened and closed, somewhere we need to store whether a particular row is opened or closed, and we also need some way to know what sizes we should animate between. For this particular example, what I’m going to do is create a helper class that represents the row, and it will keep track of whether it is expanded or contracted. It will also record the height the row should be rendered at. This height will only be used if expanded is true. And, we’ll want to animate it when expanded is changed. To use this class in my ListView, I’m going to use a little syntactic sugar in JavaFX to construct my ExpandableRows in a for loop.

class ExpandableRow {
    public-init var data:Object;
    public-read var expanded = false;
    public-read var expanding = false;
    public-read var height:Number;
    var prefHeight:Number;

    public function expand(currentHeight:Number):Void {
        expanding = true;
        height = prefHeight = currentHeight;
        Timeline {
            keyFrames: KeyFrame {
                time: 250ms
                values: [
                    height => 150,
                    expanding => false,
                    expanded => true
                ]
            }
        }.play();
    }

    public function contract():Void {
        expanding = true;
        Timeline {
            keyFrames: KeyFrame {
                time: 250ms
                values: [
                    height => prefHeight,
                    expanding => false,
                    expanded => false
                ]
            }
        }.play();
    }
}

var items = for (i in [1..40000]) ExpandableRow { data: "Row {i}" };

I have a hunch this can be simplified further, suggestions in the comments section of this post most welcome.

For the sake of simplicity, lets assume that the preferred height of an expanded cell is going to be 150, otherwise we use the “real” preferred height of the cell. When cells are laid out in the ListView, the ListView asks each Cell for its preferred height (if the ListView is vertical), and stacks the Cells based on their preferred heights. So if you say the preferred height is “50″, you’ll get a cell 50px tall. If you say “100″, it’ll be 100px tall. So in our case, we’ll give a different answer depending on whether it is expanded or contracted. And, we’ll toggle the expanded / contracted state of a cell based on whether it is double-clicked.

ListView {
    items: bind items
    cellFactory: function() {
        def cell:ListCell = ListCell {
            node: Label {
                text: bind if (cell.empty) then "" else "{(cell.item as ExpandableRow).data}"
            }
            onMouseClicked: function(e) {
                if (e.clickCount == 2) {
                    def row = cell.item as ExpandableRow;
                    if (not row.expanding) {
                        if (row.expanded) {
                            row.contract();
                        } else {
                            row.expand(cell.getPrefHeight(-1));
                        }
                    }
                }
            }
            override function getPrefHeight(width) {
                def row = cell.item as ExpandableRow;
                if (row.expanded or row.expanding) {
                    return row.height;
                } else {
                    return super.getPrefHeight(width);
                }
            }
        }
        def h = bind (cell.item as ExpandableRow).height on replace {
            cell.requestLayout();
        }
        return cell;
    }
}

I added a onMouseClicked event handler to react to mouse events on the Cell. If the clickCount of the event is 2, then I either expand or contract the row. I also overrode the getPrefHeight function so that it will return the ExpandableRow height when expanding or expanded, but otherwise, returns its normal preferred height.

The last really tricky bit was this line:

        def h = bind (cell.item as ExpandableRow).height on replace {
            cell.requestLayout();
        }

This bit is critical as otherwise you won’t see anything repaint. Because the preferred height of the cell is changed whenever the height changes (well, technically whenever the height changes AND it is expanding or expanded) then we need to be sure to call requestLayout on the Cell. This causes the layout engine to know it needs to ask for the new preferred height and resize the cell.

Go ahead, give it a whirl. This sample generates 40,000 rows and all of them are individually resizable, and it doesn’t really matter which you expand and which you don’t, the performance is the same. Have at it, and happy hacking!

Update

Ok, it was bothering me, so I went ahead and cleaned up the sample code. This time, I factored it all out such that if you write a custom cell factory, you have very little work to do for your custom cell to take advantage of this trick. Maybe one of the guys at JFXtras will pick this up and clean it up and make it officially reusable :-)

The difference in this version is that instead of wrapping all the data items in some “ExpandableRow” class, I wrap the content of the Cell in an ExpandablePane. The ExpandablePane has all the height, pref height, expansion booleans, etc defined on it as a self contained unit. Instead of coordinating with a Cell, it simply handles all the mojo itself. The nice thing here is that you can reuse ExpandablePane outside of the Cell context and it will still work. That’s kind of nice.

The layout needs a play — maybe Stack isn’t the right guy to use here, didn’t put much thought into it. But hey, it works!

class ExpandablePane extends Stack {
    var expanded = false;
    var expanding = false;
    var prefHeight:Number;
    var h:Number on replace {
        if (expanding) requestLayout();
    }

    public function toggle() {
        if (not expanding) {
            if (expanded) {
                contract();
            } else {
                expand();
            }
        }
    }

    function expand():Void {
        expanding = true;
        h = prefHeight = height; // store off the old height
        Timeline {
            keyFrames: KeyFrame {
                time: 250ms
                values: [
                    h => 150,
                    expanding => false,
                    expanded => true
                ]
            }
        }.play();
    }

    function contract():Void {
        expanding = true;
        Timeline {
            keyFrames: KeyFrame {
                time: 250ms
                values: [
                    h => prefHeight,
                    expanding => false,
                    expanded => false
                ]
            }
        }.play();
    }

    override function getPrefHeight(width) {
        if (expanded or expanding) {
            return h;
        } else {
            return super.getPrefHeight(width);
        }
    }
}

ListView {
    items: [1..40000]
    cellFactory: function() {
        var ep:ExpandablePane;
        def cell:ListCell = ListCell {
            node: ep = ExpandablePane {
                content: Label {
                    text: bind if (cell.empty) then "" else "{cell.item}"
                }
            }
            onMouseClicked: function(e) {
                if (e.clickCount == 2) {
                    ep.toggle();
                }
            }
        }
        return cell;
    }
}