Jim has posted 66 posts at DZone. You can read more from them at their website. View Full User Profile

TableNode: Creating a Custom Scrollable Table in JavaFX

08.18.2008
| 7219 views |
  • submit to reddit

In July 2008 I started the JFX Custom Nodes category that contains a growing series of posts in which a graphics designer (Mark Dingman of Malden Labs) and I are collaborating on an imaginary Sound Beans application.  The objectives of building this application are to demonstrate how to create JavaFX custom nodes, and to provide a case study in how a graphics designer and an application developer can work together effectively in developing JavaFX applications. 

The first post in this series, Rolling Your Own JavaFX "Custom Nodes": A Graphical Menu Example, shows you how to create your own UI controls in JavaFX.  In that post we defined the MenuNode and ButtonNode custom nodes so that you can easily create menus that consist of buttons that fade-in and expand when the mouse rolls over them.  Subsequent posts in this series have:

  • defined a DeckNode that stores a set of Node instances and displays one of these nodes at a time.
  • defined a ProgressNode control that may be use to show the progress of an operation.  That post also introduced a long overdue model class into the Sound Beans application.

In today's post, we're going to build a custom node name TableNode whose purpose is to provide a scrollable table whose rows can be viewed and selected.  Each cell in the table can hold a subclass of Node, so it is in line with the node-centric approach that the JavaFX SDK 1.0 will take.  By the way, I do expect that the JavaFX SDK 1.0 will have some sort of table UI control.  Anyway, here a screenshot of the TableNode being used in our imaginary Sound Beans program:

Tablenodeexample


This is based upon the playlist comp (mock-up) that Mark Dingman gave me, shown in the Getting Decked: Another JavaFX Custom Node post.  I subsequently asked him for a comp of a scrollbar that I could implement by drawing shapes (as opposed to using images).  Mark's comp included the rounded rectangle for the proportional scrollbar thumb shown above, with the scrollbar's track having a slight horizontal gradient.

In this iteration of our imaginary Sound Beans program, the number in the upper left hand corner of the UI will change as you click different rows in the table, demonstrating that you can bind to the selectedIndex attribute of the TableNode.  In future iterations we'll cause the album graphics, title, etc. to change, and we'll dispense of that number in the upper left corner.  Anyway, try it out by clicking on this Java Web Start link, keeping in mind that you'll need at least JRE 6.  Also, installing Java SE 6 update 10 will give you faster deployment time.

Webstartsmall2

Here's the code for the TableNode custom node, in a file named TableNode.fx:

/*
* TableNode.fx -
* A custom node that contains rows and columns, each cell
* containing a node.
*
* Developed 2008 by James L. Weaver (jim.weaver at lat-inc.com)
* to demonstrate how to create custom nodes in JavaFX
*/

package com.javafxpert.custom_node;

import javafx.input.*;
import javafx.scene.*;
import javafx.scene.geometry.*;
import javafx.scene.paint.*;
import javafx.scene.transform.*;
import java.lang.System;

/*
* A custom node that contains rows and columns, each cell
* containing a node. Column widths may be set individually,
* and the height of the rows can be set. In addition, several
* other attributes such as width and color of the scrollbar
* may be set. The scrollbar will show only when necessary,
* and overlays the right side of each row, so the rightmost
* column should be given plenty of room to display data and
* a scrollbar.
*/
public class TableNode extends CustomNode {

/*
* Contains the height of the table in pixels.
*/
public attribute height:Integer = 200;

/*
* Contains the height of each row in pixels.
*/
public attribute rowHeight:Integer;

/*
* A sequence containing the column widths in pixels. The
* number of elements in the sequence determines the number of
* columns in the table.
*/
public attribute columnWidths:Integer[];

/*
* A sequence containing the nodes in the cells. The nodes are
* placed from left to right, continuing to the next row when
* the current row is filled.
*/
public attribute content:Node[];

/*
* The selected row number (zero-based)
*/
public attribute selectedIndex:Integer;

/*
* The height (in pixels) of the space between rows of the table.
* This space will be filled with the tableFill color.
*/
public attribute rowSpacing:Integer = 1;

/*
* The background color of the table
*/
public attribute tableFill:Paint;

/*
* The background color of an unselected row
*/
public attribute rowFill:Paint;

/*
* The background color of a selected row
*/
public attribute selectedRowFill:Paint;

/*
* The color or gradient of the vertical scrollbar.
*/
public attribute vertScrollbarFill:Paint = Color.BLACK;

/*
* The color or gradient of the vertical scrollbar thumb.
*/
public attribute vertScrollbarThumbFill:Paint = Color.WHITE;

/*
* The width (in pixels) of the vertical scrollbar.
*/
public attribute vertScrollbarWidth:Integer = 20;

/*
* The number of pixels from the left of a cell to place the node
*/
private attribute cellHorizMargin:Integer = 10;

/*
* Contains the width of the table in pixels. This is currently a
* calculated value based upon the specified column widths
*/
private attribute width:Integer = bind
computePosition(columnWidths, sizeof columnWidths);

private function computePosition(sizes:Integer[], element:Integer) {
var position = 0;
if (sizeof sizes > 1) {
for (i in [0..element - 1]) {
position += sizes[i];
}
}
return position;
}

/**
* The onSelectionChange function attribute that is executed when the
* a row is selected
*/
public attribute onSelectionChange:function(row:Integer):Void;

/**
* Create the Node
*/
public function create():Node {
var numRows = sizeof content / sizeof columnWidths;
var tableContentsNode:Group;
var needScrollbar:Boolean = bind (rowHeight + rowSpacing) * numRows > height;
Group {
var thumbStartY = 0.0;
var thumbEndY = 0.0;
var thumb:Rectangle;
var track:Rectangle;
var rowRef:Group;
content: [
for (row in [0..numRows - 1], colWidth in columnWidths) {
Group {
transform: bind
Translate.translate(computePosition(columnWidths, indexof colWidth) +
cellHorizMargin,
((rowHeight + rowSpacing) * row) + (-1.0 * thumbEndY *
((rowHeight + rowSpacing) * numRows) / height))
content: bind [
Rectangle {
width: colWidth
height: rowHeight
fill: if (indexof row == selectedIndex)
selectedRowFill
else
rowFill
},
Line {
startX: 0
startY: 0
endX: colWidth
endY: 0
strokeWidth: rowSpacing
stroke: tableFill
},
rowRef = Group {
var node =
content[indexof row * (sizeof columnWidths) + indexof colWidth];
transform: bind Translate.translate(0, rowHeight / 2 -
node.getHeight() / 2)
content: node
}
]
onMouseClicked:
function (me:MouseEvent) {
selectedIndex = row;
onSelectionChange(row);
}
}
},
// Scrollbar
if (needScrollbar)
Group {
transform: bind Translate.translate(width - vertScrollbarWidth, 0)
content: [
track = Rectangle {
x: 0
y: 0
width: vertScrollbarWidth
height: bind height
fill: vertScrollbarFill
},
//Scrollbar thumb
thumb = Rectangle {
x: 0
y: bind thumbEndY
width: vertScrollbarWidth
height: bind 1.0 * height / ((rowHeight + rowSpacing) * numRows) * height
fill: vertScrollbarThumbFill
arcHeight: 10
arcWidth: 10
onMousePressed: function(e:MouseEvent):Void {
thumbStartY = e.getDragY() - thumbEndY;
}
onMouseDragged: function(e:MouseEvent):Void {
var tempY = e.getDragY() - thumbStartY;
// Keep the scroll thumb within the bounds of the scrollbar
if (tempY >=0 and tempY + thumb.getHeight() <= track.getHeight()) {
thumbEndY = tempY;
}
else if (tempY < 0) {
thumbEndY = 0;
}
else {
thumbEndY = track.getHeight() - thumb.getHeight();
}
}
onMouseDragged: function(e:MouseEvent):Void {
var tempY = e.getDragY() - thumbStartY;
// Keep the scroll thumb within the bounds of the scrollbar
if (tempY >=0 and tempY + thumb.getHeight() <= track.getHeight()) {
thumbEndY = tempY;
}
else if (tempY < 0) {
thumbEndY = 0;
}
else {
thumbEndY = track.getHeight() - thumb.getHeight();
}
}
}
]
}
else
null
]
clip:
Rectangle {
width: bind width
height: bind height
}
onMouseWheelMoved: function(e:MouseEvent):Void {
var tempY = thumbEndY + e.getWheelRotation() * 4;
// Keep the scroll thumb within the bounds of the scrollbar
if (tempY >=0 and tempY + thumb.getHeight() <= track.getHeight()) {
thumbEndY = tempY;
}
else if (tempY < 0) {
thumbEndY = 0;
}
else {
thumbEndY = track.getHeight() - thumb.getHeight();
}
}
}
}
}

 

As you can see by the public attributes, there are several TableNode attributes that may be configured by the developer, including the height of the table, the height of the rows, the width of each individual column, and the colors or gradients of various UI elements.  Notice that the code at the end of the listing provides mouse wheel support.  Now take a look at the main program, particularly the section denoted by The "Play" page comment where the TableNode instance is being created, in a file named TableNodeExampleMain.fx:

/*
* TableNodeExampleMain.fx -
* An example of using the TableNode custom node. It also demonstrates
* the ProgressNode, DeckNode, MenuNode and ButtonNode custom nodes
*
* Developed 2008 by James L. Weaver (jim.weaver at lat-inc.com)
* to demonstrate how to create custom nodes in JavaFX
*/
package com.javafxpert.table_node_example.ui;

import javafx.application.*;
import javafx.ext.swing.*;
import javafx.scene.*;
import javafx.scene.geometry.*;
import javafx.scene.image.*;
import javafx.scene.layout.*;
import javafx.scene.paint.*;
import javafx.scene.text.*;
import javafx.scene.transform.*;
import java.lang.Object;
import java.lang.System;
import com.javafxpert.custom_node.*;
import com.javafxpert.table_node_example.model.*;

var deckRef:DeckNode;

Frame {
var model = TableNodeExampleModel.getInstance();
var stageRef:Stage;
var menuRef:MenuNode;
title: "TableNode Example"
width: 500
height: 400
visible: true
stage:
stageRef = Stage {
fill: Color.BLACK
content: [
deckRef = DeckNode {
fadeInDur: 700ms
content: [
// The "Splash" page
Group {
var vboxRef:VBox;
var splashFont =
Font {
name: "Sans serif"
style: FontStyle.BOLD
size: 12
};
id: "Splash"
content: [
ImageView {
image:
Image {
url: "{__DIR__}images/splashpage.png"
}
},
vboxRef = VBox {
translateX: bind stageRef.width - vboxRef.getWidth() - 10
translateY: 215
spacing: 1
content: [
Text {
content: "A Fictitious Audio Application that Demonstrates"
fill: Color.WHITE
font: splashFont
},
Text {
content: "Creating JavaFX Custom Nodes"
fill: Color.WHITE
font: splashFont
},
Text {
content: "Application Developer: Jim Weaver"
fill: Color.WHITE
font: splashFont
},
Text {
content: "Graphics Designer: Mark Dingman"
fill: Color.WHITE
font: splashFont
},
]
}
]
},
// The "Play" page
VBox {
var tableNode:TableNode
id: "Play"
spacing: 4
content: [
Group {
content: [
ImageView {
image:
Image {
url: "{__DIR__}images/playing_currently.png"
}
},
Text {
textOrigin: TextOrigin.TOP
content: bind "{tableNode.selectedIndex}"
font: Font {
size: 24
}
}
]
},
tableNode = TableNode {
height: 135
rowHeight: 25
rowSpacing: 2
columnWidths: [150, 247, 25, 70]
tableFill: Color.BLACK
rowFill: Color.rgb(28, 28, 28)
selectedRowFill: Color.rgb(45, 45, 45)
selectedIndex: -1
vertScrollbarWidth: 20
vertScrollbarFill: LinearGradient {
startX: 0.0
startY: 0.0
endX: 1.0
endY: 0.0
stops: [
Stop {
offset: 0.0
color: Color.rgb(11, 11, 11)
},
Stop {
offset: 1.0
color: Color.rgb(52, 52, 52)
}
]
}
vertScrollbarThumbFill: Color.rgb(239, 239, 239)
content: bind
for (obj in model.playlistObjects) {
if (obj instanceof String)
Text {
textOrigin: TextOrigin.TOP
fill: Color.rgb(183, 183, 183)
content: obj as String
font:
Font {
size: 11
}
}
else if (obj instanceof Image)
ImageView {
image: obj as Image
}
else
null
}
onSelectionChange:
function(row:Integer):Void {
System.out.println("Table row #{row} selected");
}
}
]
},
// The "Burn" page
Group {
var vboxRef:VBox;
id: "Burn"
content: [
vboxRef = VBox {
translateX: bind stageRef.width / 2 - vboxRef.getWidth() / 2
translateY: bind stageRef.height / 2 - vboxRef.getHeight() / 2
spacing: 15
content: [
Text {
textOrigin: TextOrigin.TOP
content: "Burning custom playlist to CD..."
font:
Font {
name: "Sans serif"
style: FontStyle.PLAIN
size: 22
}
fill: Color.rgb(211, 211, 211)
},
ProgressNode {
width: 430
height: 15
progressPercentColor: Color.rgb(191, 223, 239)
progressTextColor: Color.rgb(12, 21, 21)
progressText: bind "{model.remainingBurnTime} Remaining"
progressFill:
LinearGradient {
startX: 0.0
startY: 0.0
endX: 0.0
endY: 1.0
stops: [
Stop {
offset: 0.0
color: Color.rgb(0, 192, 255)
},
Stop {
offset: 0.20
color: Color.rgb(0, 172, 234)
},
Stop {
offset: 1.0
color: Color.rgb(0, 112, 174)
},
]
}
barFill:
LinearGradient {
startX: 0.0
startY: 0.0
endX: 0.0
endY: 1.0
stops: [
Stop {
offset: 0.0
color: Color.rgb(112, 112, 112)
},
Stop {
offset: 1.0
color: Color.rgb(88, 88, 88)
},
]
}
progress: bind model.burnProgressPercent / 100.0
},
ComponentView {
component:
FlowPanel {
background: Color.BLACK
content: [
Label {
text: "Slide to simulate burn progress:"
foreground: Color.rgb(211, 211, 211)
},
Slider {
orientation: Orientation.HORIZONTAL
minimum: 0
maximum: 100
value: bind model.burnProgressPercent with inverse
preferredSize: [200, 20]
}
]
}
}
]
}
]
},
// The "Config" page
Group {
id: "Config"
content: [
ImageView {
image:
Image {
url: "{__DIR__}images/config.png"
}
}
]
},
// The "Help" page
Group {
id: "Help"
content: [
ImageView {
image:
Image {
url: "{__DIR__}images/help.png"
}
}
]
}
]
},
menuRef = MenuNode {
translateX: bind stageRef.width / 2 - menuRef.getWidth() / 2
translateY: bind stageRef.height - menuRef.getHeight()
buttons: [
ButtonNode {
title: "Play"
imageURL: "{__DIR__}icons/play.png"
action:
function():Void {
deckRef.visibleNodeId = "Play";
}
},
ButtonNode {
title: "Burn"
imageURL: "{__DIR__}icons/burn.png"
action:
function():Void {
deckRef.visibleNodeId = "Burn";
}
},
ButtonNode {
title: "Config"
imageURL: "{__DIR__}icons/config.png"
action:
function():Void {
deckRef.visibleNodeId = "Config";
}
},
ButtonNode {
title: "Help"
imageURL: "{__DIR__}icons/help.png"
action:
function():Void {
deckRef.visibleNodeId = "Help";
}
},
]
}
]
}
}

deckRef.visibleNodeId = "Splash";

The Model Behind the UI

Since the "way of JavaFX" is to bind UI attributes to a model, the content attribute of the TableNode is bound to the model as shown above.  Shown below is the model for the Sound Beans program so far, in a file named TableNodeExampleModel.fx.  Notice that the playlistObjects sequence can contain any kind of object, and that we're specifically avoiding putting Node instances in the model (as those belong in the UI).  To populate the TableNode, therefore, I'm taking the approach of having the model contain strings such as album titles and the URL of an image.  During the bind to the content attribute of the TableModel shown above, the Node subclasses (e.g. Text and ImageView) are created.

/*
* TableNodeExampleModel.fx -
* The model behind the TableNode example
*
* Developed 2008 by James L. Weaver (jim.weaver at lat-inc.com)
*/
package com.javafxpert.table_node_example.model;

import java.lang.Object;
import javafx.scene.*;
import javafx.scene.image.*;
import javafx.scene.text.*;

/**
* The model behind the TableNode example
*/
public class TableNodeExampleModel {

/**
* The total estimated number of seconds for the burn.
* For this example program, we'll set it to 10 minutes
*/
public attribute estimatedBurnTime:Integer = 600;

/**
* The percent progress of the CD burn, represented by a number
* between 0 and 100 inclusive.
*/
public attribute burnProgressPercent:Integer on replace {
var remainingSeconds = estimatedBurnTime * (burnProgressPercent / 100.0) as Integer;
remainingBurnTime = "{remainingSeconds / 60}:{%02d (remainingSeconds mod 60)}";
};

/**
* The time remaining on the CD burn, expressed as a String in mm:ss
*/
public attribute remainingBurnTime:String;

/**
* An image of a play button to be displayed in each row of the table
*/
private attribute playBtnImage = Image {url: "{__DIR__}images/play-btn.png"};

/**
* The song information in the playlist
*/
public attribute playlistObjects:Object[] =
["Who'll Stop the Rain", "Three Sides Now", playBtnImage, "2:43",
"Jackie Blue", "Ozark Mountain Devils", playBtnImage, "2:15",
"Come and Get Your Love", "Redbone", playBtnImage, "3:22",
"Love Machine", "Miracles", playBtnImage, "2:56",
"25 or 6 to 4", "Chicago", playBtnImage, "3:02",
"Free Bird", "Lynard Skynard", playBtnImage, "5:00",
"Riding the Storm Out", "REO Speedwagon", playBtnImage, "3:00",
"Lay it on the Line", "Triumph", playBtnImage, "2:00",
"Secret World", "Peter Gabriel", playBtnImage, "4:00"];



//-----------------Use Singleton pattern to get model instance -----------------------
private static attribute instance:TableNodeExampleModel;

public static function getInstance():TableNodeExampleModel {
if (instance == null) {
instance = TableNodeExampleModel {};
}
else {
instance;
}
}
}

As always, if you have any questions or input, please leave a comment.  By the way, the images for this article can be downloaded so that you can build and run this example with the graphics. This is a zip file that you can expand in the project's classpath. You will need the ButtonNode, MenuNode, DeckNode and ProgressNode code from previous posts in this JFX Custom Nodes series.


Got JavaFX Questions?

Well, you're in luck!  There will be an Ask the Experts: JavaFX Preview, on August 18-22, 2008.
You can post your questions during this session and get answers from key members of Sun's JavaFX engineering team.

Regards,
Jim Weaver
JavaFXpert.com

 

Published at DZone with permission of its author, Jim Weaver.

(Note: Opinions expressed in this article and its replies are the opinions of their respective authors and not those of DZone, Inc.)