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:
[jfx]
ListView {
items: [1..40]
cellFactory: function() {
def cell:ListCell = ListCell {
node: Label {
text: bind if (cell.empty) then "" else "{cell.item}"
}
}
return cell;
}
}
[/jfx]
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.
[jfx]
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}" };
[/jfx]
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.
[jfx]
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;
}
}
[/jfx]
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:
[jfx]
def h = bind (cell.item as ExpandableRow).height on replace {
cell.requestLayout();
}
[/jfx]
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!
[jfx]
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;
}
}
[/jfx]
Interesting!
But I do take exception to your antiquated KeyFrame syntax. 🙂
I prefer the more concise:
Timeline {
keyFrames: at(250ms) { h => 150; expanding => false; expanded => true }
}.play();
Fair enough 🙂 I actually did try to use that syntax bug couldn’t remember how to separate the different key values. I always try to use a , not a ; doh!
That gets me every time, too. I always try to use a comma the first time I write it.
Richard, your sample animated ListCell is great! Today I’m going to merge your concept with one I fleshed out yesterday, which I’ll go ahead and share here as a 1st iteration draft. Figuring out the proper technique for the mandatory binding is quite tricky given the current API docs. I love it, though! A powerful new feature you all created in this cell factory:-)
def cell: ListCell = ListCell {
layoutInfo: LayoutInfo{height:16}
node: bind if (cell.empty) {
Label {text: “”}
} else {
HBox {
nodeVPos:VPos.CENTER
spacing:3
content: [
Rectangle {width:15 height:8
fill: bind {colorObjSequence[cell.index]}},
Label {text:bind “{cell.item}”}]
}
}
}
Note: the parent list for this factory needs to be provided a sequence of presentable color names in the same order as the colorObjSequence.
Great example! Its really a beautiful implementation that you guys provided with cellfactory.
But there is an issue with this (I couldn’t find the reason). If I expand one of the rows and scroll down using the scroll button of mouse, there are several other nodes which are in expanded state.
Initially it used to follow a pattern where only multiples were showing up but after multiple runs I found it to be random.
Can you please explain why this would happen?
Thanks,
Which version of the code, first or second? I might have a bug in my example (I didn’t notice this issue but can see if I can reproduce it)
the second one.
I didn’t change anything except for moving ‘ExpandablePane’ into its class and calling the ListView part from main.
the second one.
I didn’t change anything except for moving ‘ExpandablePane’ into its class and calling the ListView part from main.
Yes, I experience the same issue : scrolling the list or changing/updating its content tend to move the expanded cell around.
Probably a side effect of the cell API architecture and re-use of the same cells.
An alternative solution / quick fix might be to store the expanded state in the cell.item itself?
Hi, wonderful example.
In your example the cell expand but the size of the label is always the same. It would be interesting to implement a ListView made with TextBox which can expands or contracts (i.e. showing a different number of lines).
Would that be possible?
hello, i’m a student in holland
we have to make the game 4 in a row within javafx
could anyone help me?
thnx