JFX Custom Nodes

September 13, 2008

Vikings and Wizards in JavaFX

Stockholm-09-Horizontal

I have three objectives for today's article:

  1. Continue teaching you how to create UI custom controls in JavaFX.  This lesson is a new addition to the JFX Custom Nodes category, and it provides an infrastructure on which you can easily create "wizards".

  2. Show my appreciation to the JavaZone committee for inviting me to speak on JavaFX at JavaZone 2008 in Oslo, Norway, which occurred on September 17 & 18.

  3. Show my appreciation to the Jfokus committee for inviting me to speak on JavaFX at Jfokus in Stockholm, Sweden which will occur on February 27 & 28, 2009.  Also, as noted in the banner above, I will be teaching a two day public JavaFX course entitled "Rich Internet Application Development with JavaFX"

JfokusLogo

To accomplish these objectives, I've created a "wizard" (in the spirit of fun) in which you can discover whether you are a Viking or not.  Having visited Norway in the past, and having talked to the Jfokus leadership , I've found that Norwegians and Swedes tend to be very proud of their Viking heritage.  The program in this post was created out of respect for that sentiment, and I consulted one of the Norwegian JavaZone organizers for fun ideas to include in this wizard.  As with the rest of the series, the designers at Malden Labs created the graphical mock-ups and assets.  Here are a couple of screenshots of the wizard that I call "Are You a Viking?"

VikingWizard-Shaving   

As shown in the previous screen shot, this wizard asks the user questions, The user's response to a given question determine what wizard page will be shown next. If you answer the questions as a true Viking would, then the wizard will congratulate you with the page shown in the screenshot below:

VikingWizard-Congrats
 

Go ahead and try out the program.  You'll need JRE 6, and please note that Java SE 6 Update 10 will give you a faster deployment experience.

Creating a Wizard

In addition to using some of the classes from the JFX Custom Nodes category of the blog (namely the MenuNode, ButtonNode and DeckNode classes), this example introduces three more classes: WizardNode, WizardPoint and OptionsNode.  Before showing you the code for these new classes, I’d like you to see the main program in this "Are You a Viking?" example, which is in a file named WizardNodeExampleMain.fx:

/*
 *  WizardNodeExampleMain.fx - 
 *  An example of using the WizardNode custom node.  It also demonstrates
 *  the OptionsNode, MenuNode and ButtonNode custom nodes, as well as the
 *  WizardPoint class.
 *
 *  Developed 2008 by James L. Weaver (jim.weaver at lat-inc.com)
 *  to demonstrate how to create custom nodes in JavaFX
 */
package com.javafxpert.wizard_node_example.ui;

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

var wizardRef:WizardNode;

Frame {
  var stageRef:Stage;
  var menuRef:MenuNode;
  var pageFont =
    Font {
      name: "Sans serif"
      size: 28
    };
  title: "WizardNode Example"
  width: 505
  height: 400
  visible: true
  stage:
    stageRef = Stage {
      fill: Color.BLACK
      content: [
        wizardRef = WizardNode {
          flowPath: 
            WizardPoint {
              nodeID: "Shaving"  // The id attribute of the Node to be displayed
              nextPoints: [
                WizardPoint {
                  advanceState: "ElectricRazor"
                  nodeID: "NotViking"
                },
                WizardPoint {
                  advanceState: "SafetyRazor"
                  nodeID: "NotViking"
                },
                WizardPoint {
                  advanceState: "StraightRazor"
                  nodeID: "NotViking"
                },
                WizardPoint {
                  advanceState: "ShaveWithSword"
                  nodeID: "DiscoveredAmerica"
                  nextPoints: [
                    WizardPoint {
                      advanceState: "ChristopherColumbus"
                      nodeID: "NotViking"
                    },
                    WizardPoint {
                      advanceState: "LeifEricson"
                      nodeID: "WearHelmetHowOften"
                      nextPoints: [
                        WizardPoint {
                          advanceState: "AlwaysWearsHelmet"
                          nodeID: "OwnWoodenBoat"
                          nextPoints: [
                            WizardPoint {
                              advanceState: "FiberglassBoat"
                              nodeID: "NotViking"
                            },
                            WizardPoint {
                              advanceState: "WoodenBoat"
                              nodeID: "IsViking"
                            },
                            WizardPoint {
                              advanceState: "NoBoat"
                              nodeID: "NotViking"
                            },
                          ]
                        },
                        WizardPoint {
                          advanceState: "WearsHelmetOnOccasions"
                          nodeID: "NotViking"
                        },
                        WizardPoint {
                          advanceState: "WearsHelmetOncePerWeek"
                          nodeID: "NotViking"
                        },
                        WizardPoint {
                          advanceState: "NeverWearsHelmet"
                          nodeID: "NotViking"
                        },
                      ]
                    },
                    WizardPoint {
                      advanceState: "TheBeatles"
                      nodeID: "NotViking"
                    },
                  ]
                },
              ]
            }
          fadeInDur: 700ms
          canCancel: true
          canFinish: false
          backgroundNode:
            Group {
              content: [
                ImageView {
                  image: 
                    Image {
                      url: "{__DIR__}images/viking_jprep_background.png"
                    } 
                },
                ImageView {
                  image: 
                    Image {
                      url: "{__DIR__}images/viking_jprep_helmet.png"
                    } 
                },
              ]
            }
          content: [
            Group {
              var optionsNode:OptionsNode;
              transform: Translate.translate(50, 130);
              id: "Shaving"
              content: [
                optionsNode = OptionsNode {
                  heading: "How Do You Shave?"
                  options: [
                    "I shave with an electric razor",
                    "I shave with a safety razor",
                    "I shave with a straight razor",
                    "I shave with my sword",
                  ]
                  optionAdvanceStates: [
                    "ElectricRazor",
                    "SafetyRazor",
                    "StraightRazor",
                    "ShaveWithSword"
                  ]
                  action:
                    function():Void {
                      wizardRef.candidateAdvanceState = optionsNode.optionAdvanceState;  
                    }
                }
              ]
            },
            Group {
              var optionsNode:OptionsNode;
              transform: Translate.translate(50, 130);
              id: "DiscoveredAmerica"
              content: [
                optionsNode = OptionsNode {
                  heading: "Who Discovered America?"
                  options: [
                    "Christopher Columbus",
                    "Leif Ericson",
                    "The Beatles",
                  ]
                  optionAdvanceStates: [
                    "ChristopherColumbus",
                    "LeifEricson",
                    "TheBeatles",
                  ]
                  action:
                    function():Void {
                      wizardRef.candidateAdvanceState = optionsNode.optionAdvanceState;  
                    }
                }
              ]
            },
            Group {
              var optionsNode:OptionsNode;
              transform: Translate.translate(50, 130);
              id: "WearHelmetHowOften"
              content: [
                optionsNode = OptionsNode {
                  heading: "How Often Do You Wear Your Helmet?"
                  options: [
                    "I always wear it, even in bed",
                    "Only on special occasions",
                    "Once a week",
                    "Never - it gives me Helmet Hair"
                  ]
                  optionAdvanceStates: [
                    "AlwaysWearsHelmet",
                    "WearsHelmetOnOccasions",
                    "WearsHelmetOncePerWeek",
                    "NeverWearsHelmet",
                  ]
                  action:
                    function():Void {
                      wizardRef.candidateAdvanceState = optionsNode.optionAdvanceState;  
                    }
                }
              ]
            },
            Group {
              var optionsNode:OptionsNode;
              transform: Translate.translate(50, 130);
              id: "OwnWoodenBoat"
              content: [
                optionsNode = OptionsNode {
                  heading: "Do You Own a Wooden Boat?"
                  options: [
                    "No, mine is fiberglass",
                    "Of course it's made out of wood!",
                    "I don't own a boat",
                  ]
                  optionAdvanceStates: [
                    "FiberglassBoat",
                    "WoodenBoat",
                    "NoBoat",
                  ]
                  action:
                    function():Void {
                      wizardRef.candidateAdvanceState = optionsNode.optionAdvanceState;  
                    }
                }
              ]
            },
            HBox {
              id: "IsViking"
              transform: Translate.translate(50, 110);
              content: [
                ImageView {
                  image: 
                    Image {
                      url: "{__DIR__}images/viking_yes.png"
                    } 
                },
                VBox {
                  transform: Translate.translate(0, 20);
                  spacing: 40
                  content: [
                    Text {
                      textOrigin: TextOrigin.TOP
                      content: "Congratulations!"
                      fill: Color.WHITE
                      font: pageFont
                    },
                    Text {
                      textOrigin: TextOrigin.TOP
                      content: "You are a Viking!"
                      fill: Color.WHITE
                      font: pageFont
                    },
                  ]
                }
              ]
            },
            HBox {
              id: "NotViking"
              transform: Translate.translate(50, 110);
              content: [
                ImageView {
                  image: 
                    Image {
                      url: "{__DIR__}images/viking_no.png"
                    } 
                },
                VBox {
                  transform: Translate.translate(0, 20);
                  spacing: 40
                  content: [
                    Text {
                      textOrigin: TextOrigin.TOP
                      content: "Sorry! You are most"
                      fill: Color.WHITE
                      font: pageFont
                    },
                    Text {
                      textOrigin: TextOrigin.TOP
                      content: "likely not a Viking!"
                      fill: Color.WHITE
                      font: pageFont
                    },
                  ]
                }
              ]
            },
          ]
        },
        menuRef = MenuNode {
          translateX: bind stageRef.width / 2 - menuRef.getWidth() / 2
          translateY: bind stageRef.height - menuRef.getHeight() * 0.75
          buttons: [
            ButtonNode {
              imageURL: "{__DIR__}images/viking_jprep_btn_previous.png"
              enabled: bind wizardRef.canGoBack
              scale: 1.0
              action:
                function():Void {
                  wizardRef.goBack();
                }
            },
            ButtonNode {
              imageURL: "{__DIR__}images/viking_jprep_btn_next.png"
              enabled: bind wizardRef.canAdvance
              scale: 1.0
              action:
                function():Void {
                  wizardRef.advance();
                }
              },
            ButtonNode {
              imageURL: "{__DIR__}images/viking_jprep_btn_cancel.png"
              enabled: bind wizardRef.canCancel
              scale: 1.0
              action:
                function():Void {
                  wizardRef.reset();
                }
            },
            ButtonNode {
              imageURL: "{__DIR__}images/viking_jprep_btn_finish.png"
              enabled: bind wizardRef.atEndpoint
              scale: 1.0
              action:
                function():Void {
                  wizardRef.reset();
                }
            },
          ]
        }
      ]
    }
  closeAction:
    function():Void {
      System.exit(0);
    }
}

As shown above, one of the important features of the wizard is what I call its flow path. The flowPath attribute of the WizardNode class enables you to articulate all of the points in the wizard’s flow, as well as to articulate the navigation paths between points. Each point in a wizard’s flow path is represented by a WizardPoint instamce, and each navigation path between points is represented by an element in the nextPoints sequence attribute. The nextPoints attribute contains a sequence of WizardPoint instances, each of which holds an advanceState string attribute that enables navigation to that point. As you can see from the listing above, each WizardPoint instance also contains a nodeID attribute that matches the id attribute of a Node (held within the content sequence attribute of the WizardNode). Looking at that attribute in the listing above, you’ll notice that all of the wizard pages (Node instances) are held in that sequence. As you’ll soon see, it is the responsibility of the WizardNode class to cause the associated Node instance to show after navigating to a given wizard point.

Another important attribute in the WizardNode class is backgroundNode. This attribute enables you to create a background that will show behind each page of the wizard. Take a look at the WizardNode.fx listing below, and notice that it extends the DeckNode class, which contains the functionality of switching between Node instances by their id attributes.


/*
 *  WizardNode.fx - 
 *  A custom node that functions as a "wizard", progressively displaying
 *  UI nodes according to a supplied "flow".  Each node, typically as a result
 *  of user interaction, may set a state that determines which node of the flow
 *  will be displayed. 
 *
 *  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.lang.*;
import java.lang.System;

/*
 *  A custom node that functions as a "wizard", progressively displaying
 *  UI nodes according to a supplied "flow".  Each node, typically as a result
 *  of user interaction, may set a state that determines which node of the flow
 *  will be displayed. 
 */
public class WizardNode extends DeckNode { 

  /**
   * The flow of the wizard, expressed as a series of related
   * WizardPoint instances
   */
  public attribute flowPath:WizardPoint;
  
  /**
   * The current path that the user is taking through the wizard, expressed as
   * a sequence of WizardPoint instances
   */
  public attribute currentPath:WizardPoint[];

  /**
   * Determines whether the user can advance to the next wizard page
   */
  public attribute canAdvance:Boolean = false;

  /**
   * Determines whether the user can go back to the previous wizard page
   */
  public attribute canGoBack:Boolean = bind sizeof currentPath > 1;

  /**
   * Determines whether the user can cancel out of this wizard
   */
  public attribute canCancel:Boolean;

  /**
   * Determines whether the user can finish this wizard
   */
  public attribute canFinish:Boolean;

  /**
   * Determines whether the user is at and end point in the wizard,
   * which is defined by not having any nextPoints
   */
  public attribute atEndpoint:Boolean = bind
    (sizeof currentPoint.nextPoints) == 0;

  /**
   * A reference to the WizardPoint instance that the user is currently on
   */
  private attribute currentPoint:WizardPoint on replace {
    if (currentPoint != null) {
      visibleNodeId = currentPoint.nodeID;
      canAdvance = false;
    }
  };

  postinit {
    reset();
  }

  /**
   * Resets the wizard to its initial state
   */
  public function reset():Void {
    currentPoint = flowPath;
    currentPath = [];
    insert currentPoint into currentPath;
  }

  /**
   * The requested advanceState to be navigated to from this point.  If this is
   * a valid advanceState, the canAdvance attribute is set to true, otherwise
   * it is set to false
   */
  public attribute candidateAdvanceState:String on replace {
    if (candidateAdvanceState != "") {
      var newPoint = currentPoint.getPointByAdvanceState(candidateAdvanceState);
      canAdvance = newPoint != null;
    }
  }

  /**
   * Request that the current WizardPoint be changed to the candidateAdvanceState
   * TODO: Consider throwing an exception if unsuccessful
   */
  public function advance():Void {
    var newPoint = currentPoint.getPointByAdvanceState(candidateAdvanceState);
    if (newPoint != null) {
      currentPoint = newPoint;
      
      // Put this WizardPoint on the current path through the flow path
      insert currentPoint into currentPath;
    }
  }

  /**
   * Request that the current WizardPoint be changed to the one that precedes
   * it in the current path that the user has taken through the flow.  In other
   * words, go back.
   * TODO: Consider throwing an exception if unsuccessful
   */
  public function goBack():Void {
    var idx = sizeof currentPath - 1;
    if (idx > 0) { 
      delete currentPath[idx];    
      // Put this WizardPoint on the current path through the flow path
      currentPoint = currentPath[idx - 1];
    }
  }
}  


Here is the listing for the WizardPoint class mention previously:

/*
 *  WizardPoint.fx - 
 *  A point in the flow of a "wizard", that is related to a given wizard page
 *  (graphical Node).  Wizard point instances are related to each other in
 *  a network of points that determines the potential flows within the wizard.
 *
 *  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 java.lang.System;

/**
 *  A point in the flow of a "wizard", that is related to a given wizard page
 *  (graphical Node).  Wizard point instances are related to each other in
 *  a network of points that determines the potential flows within the wizard.
 */
public class WizardPoint { 

  /**
   * The id attribute of the Node (wizard page) to be displayed
   */
  public attribute nodeID:String;

  /**
   * A String that controls the condition by which this WizardPoint may be
   * landed upon. If advanceState is an empty string, then this point may be 
   * landed upon by default
   */
  public attribute advanceState:String;

  /**
   * The WizardPoint instances that may be navigated to from this point.
   */
  public attribute nextPoints:WizardPoint[]; //TODO make sure that this creates an empty sequence, not a null reference

  /**
   * Returns the WizardPoint instance that may be navigated to from this point
   * for a given advanceState
   * TODO: Decide whether this is needed
   */
  public function getPointByAdvanceState(advanceState:String):WizardPoint {
    var points = 
      for (point in nextPoints where point.advanceState == advanceState) point;
    if (sizeof points > 0) points[0]
    else null;
  }
}  


Notice also in the WizardNodeExampleMain.fx listing shown previously that I'm using an OptionsNode custom node in some of the wizard pages. This class makes it quick and easy to articulate a set of mutually exclusive options as well as a heading for the list of options. It was designed for use with this wizard functionality, as it is capable of holding an advanceState for each of the options and storing the advanceState for an option that the user selected. Take a look at the listing for the OptionsNode.fx class below:

/*
 *  OptionsNode.fx - 
 *  A custom node that enables the user to choose 
 *  from a list of options.
 *
 *  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.ext.swing.*;
import javafx.scene.*;
import javafx.scene.paint.*;
import java.lang.System;

public class OptionsNode extends CustomNode { 

  /**
   * A header for the options
   */
  public attribute heading:String;
    
  /**
   * A sequence containing the choice of options
   */
  public attribute options:String[];
  
  /**
   * A sequence containing the WizardNode advanceState associated with the
   * options.
   */
  public attribute optionAdvanceStates:String[];
  
  /**
   * The font to use for the heading
   */
  public attribute headingFont = 
    Font {
      name: "Sans serif"
      size: 24
    };
  
  /**
   * The font to use for the options
   */
  public attribute optionsFont = 
    Font {
      name: "Sans serif"
      size: 22
    };
  
  /**
   * The color to use for the text
   */
  public attribute textColor = Color.rgb(253, 253, 253);
    
  /**
   * The advanceState associated with a selected option
   */
  public attribute optionAdvanceState:String;
    
  /**
   * The action function attribute that is executed when the
   * a selection is made
   */
  public attribute action:function():Void;
   
  /**
   * Reset the selected radio buttons
   */
  public function reset():Void {
    toggleGroup.clearSelection(); 
  }

  /**
   * Called when this page is shown in the wizard.  Reset the radio button
   * selections
   */
  public function onShow():Void {
    reset(); 
  }

  /**
   * A reference to the ToggleGroup associated with the RadioButton instances
   */ 
  private attribute toggleGroup:ToggleGroup = ToggleGroup{}; 
   
  /**
   * Create the Node
   */
  public function create():Node {
    ComponentView {
      component:
        GridPanel {
          background: 
            Color.rgb(255, 255, 255, 0.0) // Transparent
          rows: bind sizeof options + 1
          columns: 1
          content: bind [
            Label {
              text: heading
              font: headingFont
              foreground: textColor
            },
            for (option in options)
              RadioButton {
                var idx:Integer = indexof option;
                toggleGroup: toggleGroup
                text: option
                font: optionsFont
                foreground: textColor
                action:
                  function():Void {
                    optionAdvanceState = optionAdvanceStates[idx];
                    action();  
                  }
              }
          ]
        }
    }
  }
}  


Room for Improvement

There is at least one area of functionality in which I'd like to make an improvement:  Currently, a wizard page is a subclass of Node, but it may be good to define a custom node (perhaps named WizardPageNode) that has life-cycle functions and some helper functionality.  I welcome your thoughts on this, as well as any other input or questions that you have.  In either case, please post a comment.

Also, if you're at JFocus, please introduce yourself as I'd like to meet you!

Thanks,
Jim Weaver
JavaFXpert.com

September 04, 2008

JavaFX Applets Meet Google Chrome

In the JFX Custom Nodes category of this blog, graphics designer Mark Dingman of Malden Labs and I have been collaborating on an imaginary Sound Beans application. This category contains a growing series of posts in which we are demonstrating how to create JavaFX UI custom controls.  This series also provide a case study in how a graphics designer and an application developer can work together effectively in developing JavaFX applications.  Today I'd like to highlight the recent Google Chrome browser announcement by showing you how to create and run a JavaFX applet in Chrome.  Here's a screenshot of the TableNode example from an earlier post running as a JavaFX applet in Chrome:


TableNodeExampleApplet

To try this out, first obtain Google Chrome and install it.  Then obtain Java SE 6 Update 10 and install it as well.  By the way, installing Java SE 6 update 10 will enable this JavaFX applet to run on Firefox 3 and Internet Explorer as well.  Go ahead and run this example, being sure to scroll the custom TableNode control and to click on its rows.  Also, select the Burn icon and move the slider to demonstrate the custom ProgressNode control.


Looking at the Code

In addition to the ButtonNode.fx, MenuNode.fx, DeckNode.fx, ProgressNode.fx and TableNode.fx files from previous posts in this series, you'll need the following files:

TableNodeExampleApplet.fx:

/*
 *  TableNodeExampleApplet.fx -
 *  An example of using the TableNode custom node in an Applet.  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 and applets 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.DeckNode;
import com.javafxpert.custom_node.TableNode;
import com.javafxpert.custom_node.ProgressNode;
import com.javafxpert.custom_node.ButtonNode;
import com.javafxpert.custom_node.MenuNode;
import com.javafxpert.table_node_example.model.TableNodeExampleModel;

var deckRef:DeckNode;

Application {
  var model = TableNodeExampleModel.getInstance();
  var stageRef:Stage;
  var menuRef:MenuNode;
  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.#1c1c1c
                  selectedRowFill: Color.#2d2d2d
                  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.#0b0b0b
                      },
                      Stop {
                        offset: 1.0
                        color: Color.#343434
                      }
                    ]
                  }
                  vertScrollbarThumbFill: Color.#efefef
                  content: bind
                    for (obj in model.playlistObjects) {
                      if (obj instanceof String)
                        Text {
                          textOrigin: TextOrigin.TOP
                          fill: Color.#b7b7b7
                          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.#d3d3d3
                    },
                    ProgressNode {
                      width: 430
                      height: 15
                      progressPercentColor: Color.#bfdfef
                      progressTextColor: Color.#0c1515
                      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.#00c0ff
                            },
                            Stop {
                              offset: 0.20
                              color: Color.#00acea
                            },
                            Stop {
                              offset: 1.0
                              color: Color.#0070ae
                            },
                          ]
                        }
                      barFill:
                        LinearGradient {
                          startX: 0.0
                          startY: 0.0
                          endX: 0.0
                          endY: 1.0
                          stops: [
                            Stop {
                              offset: 0.0
                              color: Color.#707070
                            },
                            Stop {
                              offset: 1.0
                              color: Color.#585858
                            },
                          ]
                        }
                      progress: bind model.burnProgressPercent / 100.0
                    },
                    ComponentView {
                      component:
                        FlowPanel {
                          background: Color.BLACK
                          content: [
                            Label {
                              text: "Slide to simulate burn progress:"
                              foreground: Color.#d3d3d3
                            },
                            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";
                }
            },
          ]
        }
      ]
    }
}


Note that the Application class has a stage attribute just as the Frame had in previous examples.  Here's the TableNodeExamplePage.html file that you'll open in your browser.  The draggable param, by the way, enables that neat "pull the applet out of the browser" trick that I'll show you in a bit:

<html>
  <body bgcolor="Black">
    <center>
      <applet code="javafx.application.Applet" width=500 height=400
          archive="javafxrt.jar, Scenario.jar, javafxgui.jar, javafx-swing.jar, TableNodeExample.jar">
        <param name="ApplicationClass" value="com.javafxpert.table_node_example.ui.TableNodeExampleApplet"/>
        <param name="jnlp_href" value="TableNodeExampleApplet.jnlp"/>
        <param name="draggable" value="true">
      </applet>
    </center>
  </body>
</html>


Finally, here's the Java Web Start TableNodeExampleApplet.jnlp file that is used by the HTML file above:

<?xml version="1.0" encoding="UTF-8"?>
<jnlp spec="1.0+" href="TableNodeExampleApplet.jnlp">
  <information>
    <title>TableNodeExampleApplet</title>
    <vendor>JMentor</vendor>
    <description>TableNodeExampleApplet</description>
    <description kind="short">TableNodeExampleApplet</description>
    <homepage href="http://jmentor.com"/>
    <offline-allowed />
  </information>
  <security>
    <all-permissions/>
  </security>
  <resources>
    <property name="jnlp.packEnabled" value="true"/>
    <j2se version="1.6+" href="http://java.sun.com/products/autodl/j2se" java-vm-args="-Xmx800m" />
    <!--j2se version="1.6+" java-vm-args="-Xmx800m" /-->
    <jar href="TableNodeExample.jar" main="true" download="eager"/>
    <jar href="Scenario.jar"/>
    <jar href="javafxrt.jar"/>
    <jar href="javafxgui.jar"/>
    <jar href="javafx-swing.jar"/>
  </resources>
  <applet-desc
    name="TableNodeExampleApplet"
    main-class="javafx.application.Applet"
    width="500"
    height="400">
  </applet-desc>
</jnlp>


Dragging the Applet out of the Browser and onto the Desktop

As shown in the following screenshot, one of the cool features of Java SE 6 update 10 is that you can drag a Java or JavaFX applet out of the browser and onto the desktop.  By default, you press the Alt key while dragging the applet:

TableNodeExampleApplet-Drag

Here is our JavaFX Applet living happily on the desktop after the browser has been closed, and the user has selected the Burn page:

TableNodeExampleApplet-Dragged

Google Chrome will be a Driving Force for RIA

According to Google, Java SE 6 Update 10 is the version that must be used in order to run Java in the Chrome browser.  As I've mentioned previously, one of the objectives of Java SE 6 Update 10 is to solve the JRE and Java/JavaFX deployment issues.  Because Google Chrome is destined to be a great, cross-platform browser, and because it requires the version of Java that makes rich-client Java/JavaFX programs feasible, this will increase the adoption rate of JavaFX applets and applications.

Thanks Google!

Jim Weaver
JavaFXpert.com

August 17, 2008

TableNode: Creating a Custom Scrollable Table in JavaFX

Stockholm-09-Horizontal

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.#1c1c1c
                  selectedRowFill: Color.#2d2d2d
                  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.#0b0b0b
                      },
                      Stop {
                        offset: 1.0
                        color: Color.#343434
                      }
                    ]
                  }
                  vertScrollbarThumbFill: Color.#efefef
                  content: bind
                    for (obj in model.playlistObjects) {
                      if (obj instanceof String)
                        Text {
                          textOrigin: TextOrigin.TOP
                          fill: Color.#b7b7b7
                          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.#d3d3d3
                    },
                    ProgressNode {
                      width: 430
                      height: 15
                      progressPercentColor: Color.#bfdfef
                      progressTextColor: Color.#0c1515
                      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.#00c0ff
                            },
                            Stop {
                              offset: 0.20
                              color: Color.#00acea
                            },
                            Stop {
                              offset: 1.0
                              color: Color.#0070ae
                            },
                          ]
                        }
                      barFill:
                        LinearGradient {
                          startX: 0.0
                          startY: 0.0
                          endX: 0.0
                          endY: 1.0
                          stops: [
                            Stop {
                              offset: 0.0
                              color: Color.#707070
                            },
                            Stop {
                              offset: 1.0
                              color: Color.#585858
                            },
                          ]
                        }
                      progress: bind model.burnProgressPercent / 100.0
                    },
                    ComponentView {
                      component:
                        FlowPanel {
                          background: Color.BLACK
                          content: [
                            Label {
                              text: "Slide to simulate burn progress:"
                              foreground: Color.#d3d3d3
                            },
                            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

July 30, 2008

Progress Indicator: Creating a JavaFX Custom Node and Binding to a Model

In the Rolling Your Own JavaFX "Custom Nodes": A Graphical Menu Example post, I began showing 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.

Then, in the Getting Decked: Another JavaFX Custom Node post, we defined a DeckNode  that stores a set of Node instances and displays one of these nodes at a time.  It is used, for example, to show the node that pertains to a given menu button.

The posts mentioned above are part of a series in the JFX Custom Nodes category 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 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.

In today's post, we're going to do two things:

  1. Define a ProgressNode control that may be use to show the progress of an operation.
  2. Introduce a model class into the Sound Beans application.  As I've said before, the "way of JavaFX" is to bind the UI to a model, and this Sound Beans application has gone long enough without one.

Here's the mock-up that Mark gave me for the Burn CD page:

Burning_2

Based upon this image, I decided to create a "progress bar" control that consists of JavaFX graphical nodes (e.g. Rectangle, Text).  For this page, there are no image assets needed from Mark.

I'll show you the code in a bit, but first take a look at a screenshot of the Sound Beans application after the Burn button has been clicked:

Progressnodeexample_3

As you can see, I added a slider control for the purpose of simulating the progress of the burn.  Give it a whirl 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 ProgressNode custom node, in a file named ProgressNode.fx:

/*
*  ProgressNode.fx -
*  A custom node that functions as a progress bar
*  TODO: Add the ability to have an "infinite progress" look as well
*
*  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.scene.*;
import javafx.scene.geometry.*;
import javafx.scene.paint.*;
import javafx.scene.text.*;

public class ProgressNode extends CustomNode {

  /*
   * A number from 0.0 to 1.0 that indicates the amount of progress
   */
  public attribute progress:Number;
   
  /*
   * The fill of the progress part of the progress bar.  Because
   * this is of type Paint, a Color or gradient may be used.
   */
  public attribute progressFill:Paint = Color.BLUE;
   
  /*
   * The fill of the bar part of the progress bar. Because
   * this is of type Paint, a Color or gradient may be used.
   */
  public attribute barFill:Paint = Color.GREY;
   
  /*
   * The color of the progress percent text on the progress bar
   */
  public attribute progressPercentColor:Color = Color.WHITE;
   
  /*
   * The color of the progress text on the right side of the progress bar
   */
  public attribute progressTextColor:Color = Color.WHITE;
   
  /*
   * The progress text string on the right side of the progress bar
   */
  public attribute progressText:String;
   
  /*
   * Determines the width, in pixels, of the progress bar
   */
  public attribute width:Integer = 200;
   
  /*
   * Determines the height, in pixels, of the progress bar
   */
  public attribute height:Integer = 20;
   
  /**
   * Create the Node
   */
  public function create():Node {
    Group {
      var textRef:Text;
      var progTextRef:Text;
      var progBarFont =
        Font {
          name: "Sans serif"
          style: FontStyle.BOLD
          size: 12
        };
      content: [
        // The entire progress bar
        Rectangle {
          width: bind width
          height: bind height
          fill: bind barFill
        },
        // The progress part of the progress bar
        Polygon {
          points: bind [
            0.0, 0.0,
            0.0, height as Number,
            width * progress + height / 2.0, height as Number,
            width * progress - height / 2.0, 0.0
          ]
          fill: bind progressFill
          clip:
            Rectangle {
              width: bind width
              height: bind height
            }
        },
        // The percent complete displayed on the progress bar
        textRef = Text {
          translateX: width / 3
          translateY: 3
          textOrigin: TextOrigin.TOP
          font: progBarFont
          fill: bind progressPercentColor
          content: bind "{progress * 100 as Integer}%"
        },
        // The progress text displayed on the right side of the progress bar
        progTextRef = Text {
          translateX: bind width - progTextRef.getWidth() - 5
          translateY: 3
          textOrigin: TextOrigin.TOP
          font: progBarFont
          fill: bind progressTextColor
          content: bind progressText
        }
      ]
    }   
  }

Most of the concepts used here were discussed in the posts referenced above.  One thing that I'd like to point out is the use of binding, for example, to display the current value of the progress attribute as a percentage in the last line of the listing.  Now take a look at the main program, in a file named ProgressNodeExampleMain.fx:

/*
*  ProgressNodeExampleMain.fx -
*  An example of using the ProgressNode custom node.  It also demonstrates
*  the 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.progress_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.System;
import com.javafxpert.custom_node.*;
import com.javafxpert.progress_node_example.model.*;

var deckRef:DeckNode;

Frame {
  var model = ProgressNodeExampleModel.getInstance();
  var stageRef:Stage;
  var menuRef:MenuNode;
  title: "ProgressNode 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
            Group {
              id: "Play"
              content: [
                ImageView {
                  image:
                    Image {
                      url: "{__DIR__}images/playlist.png"
                    }
                }
              ]
            },
            // 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";

Using the ProgressNode control

You'll notice that the main program above is almost identical to the main program in the Getting Decked post, except that instead of showing the mock-up graphic that Mark Dingman supplied, we're using JavaFX code to display something similar, including our new ProgressNode control.  Examine the Group block right after the //The "Play" page comment in the listing to see this additional code.  One thing that deserves repeating from previous posts is that JavaFX is moving to a node-centric approach, so 2D graphics as well as components will all be graphical nodes.  Because of this, I'm using the ComponentView class (which is a subclass of Node), to contain the Slider, which is a component.  The JavaFX team is rapidly developing a set of controls (e.g. Button) that are subclasses of Node, so very soon the ComponentView class won't be necessary.

Note that as the complexity of the individual pages grow, I'll tend to put them in their own files, subclassing CustomNode just like we're doing with this UI controls.


Introducing a Model into this Program

If you've followed this blog, you know that JavaFX inherently supports the model-view-controller pattern through constructs such as declarative programming syntax, binding, and triggers.  In this program, our model has an attribute named burnProgressPercent, for example, that holds the completion percent of the CD burn, as shown in the ProgressNodeExampleModel.fx listing below.  Notice that in the ProgressNodeExampleMain.fx listing above that the value attribute of the Slider is bound bi-directionally to this variable, and that the progress attribute of the ProgressNode is bound to it as well.  This is what causes the progress bar to be updated as you move the slider.  Here's the ProgressNodeExampleModel.fx listing:

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

/**
* The model behind the ProgressNode example
*/
public class ProgressNodeExampleModel {
 
  /**
   * 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;

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

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

Take a look at the burnProgressPercent attribute and you'll notice a couple of things: 

  • It has an on replace trigger that gets executed whenever the value of burnProgressPercent changes.  In the on replace block we're altering the value of the remainingBurnTime attribute, which you may have noticed is being bound to by the progressText attribute of the ProgressNode.  See the ProgressNodeExampleMain.fx listing above to see this bind. 
  • Another item of interest in the on replace block is the use of a format string to pad the seconds with a leading zero.  The set of format strings available can be found in the java.util.Formatter class API documentation.

One last observation about this model class is that I'm using a singleton pattern to get a reference to it.  As our program grows, and more classes need a reference to the model class, this is an alternative to supplying a model reference via public attributes to every class in the program that needs a reference.

By the way, the other classes used by this example (ButtonNode, MenuNode and DeckNode) are located in the Rolling Your Own, and Getting Decked posts referred to above.  As always, please post a comment if you have any questions!

Regards,
Jim Weaver

July 28, 2008

Getting Decked: Another JavaFX Custom Node

In the Rolling Your Own JavaFX "Custom Nodes": A Graphical Menu Example post, I began showing you how to create your own UI controls in JavaFX.  I also said that the JFX Custom Nodes category has been started in this blog in which custom nodes that you and I create will be featured.

Today's example features a simple custom node, named DeckNode, that I developed as a way to store a set of Node instances and display one of these nodes at a time.  It is being used to augment the graphical menu example by showing the Node that pertains to a given menu button.  Note that the concept is very similar to the Java CardLayout, so I wouldn't be surprised to see a similar class appear in the javafx.scene.layout package at some point.  Just for grins, I provided the ability to specify a fade-in duration for the node being displayed.  Here's a screenshot of the example application, with one of the nodes in the "deck" being displayed as the splash page: 

Decknodeexample_5  

One of the goals of JavaFX is for graphics designers and developers to be able to work together effectively in creating great looking applications.  To demonstrate this, Mark Dingman (the User eXperience director at Malden Labs) created some graphical mock-ups for the fictitious CD application shown above.  Future blog posts will turn these mock-ups into functioning pages using a combination of UI controls from the JavaFX SDK and custom nodes that we create in this blog.  Here's an example mocked-up page in the form of a graphic that is being displayed as a result of pressing the Play button:

Decknodeexample_play

By the way, Malden Labs, under CTO Thom Theriault's technical leadership, has made a strong commitment to using JavaFX for some pretty exciting systems in the works.  Anyway, if you would like to try today's example out, click on this Java Web Start link, keeping in mind that you'll need at least JRE 5.  Also, installing Java SE 6 update 10 will give you faster deployment time.

Webstartsmall2

Here's the code for the DeckNode class, contained in a file named DeckNode.fx:

/*
 *  DeckNode.fx - 
 *  A node that shows a deck of nodes one at a time.
 *
 *  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.animation.*;
import javafx.lang.*;
import javafx.scene.*;

/**
 *  A node that shows a deck of nodes one at a time.  When the
 *  visibleNodeId attribute is assigned a value, the Node whose
 *  id is the same as the name becomes visible.  Note that the
 *  the id attribute of each Node must be assigned a unique name.
 *  This class also has an attribute in which a fade-in duration
 *  may be specified.
 */
public class DeckNode extends CustomNode { 
  /**
   * A sequence that contains the Node instances in this "deck"
   */
  public attribute content:Node[];
  
  /**
   * An optional node that will appear in the background of all nodes
   */
  public attribute backgroundNode:Node;

  /**
   * The id of the node that is to be visible
   */
  public attribute visibleNodeId:String on replace {
    var nodes = for (node in content where node.id == visibleNodeId) node;
    visibleNodeRef = if (sizeof nodes > 0) nodes[0] else null;
    fadeTimeline.start(); 
  }
   
  /**
   * The amount of time to fade-in the new Node 
   */
  public attribute fadeInDur:Duration = 1ms;

  /**
   * This attribute is interpolated by a Timeline, and the opacity
   * attribute of this DeckNode class is bound to it.  This helps
   * enable the fade-in effect.
   */
  private attribute opa:Number;

  /**
   * Override the opacity attribute so that it can be bound to the
   * opa attribute that is interpolated by a Timeline
   */
  override attribute opacity = bind opa;

  /**
   * A Timeline to control the fade-in behavior
   */
  private attribute fadeTimeline =
    Timeline {
      keyFrames: [
        KeyFrame {
          time: bind fadeInDur
          values: [
            opa => 1.0 tween Interpolator.LINEAR,
          ]
        }
      ]
    };

  /**
   * A reference to the Node in the Node instances that is visible
   */
  protected attribute visibleNodeRef:Node;
   
  /**
   * Create the Node
   */
  public function create():Node {
    Group {
      content: bind [
        backgroundNode,
        visibleNodeRef
      ]
    };
  }
}  
 

Shown below is the main script for this program, in a file named DeckNodeExampleMain.fx:

/*
*  DeckNodeExampleMain.fx -
*  An example of using the DeckNode custom node.  It also demostrates
*  the 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.deck_node_example.ui;

import javafx.application.*;
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.System;
import com.javafxpert.custom_node.*;

var deckRef:DeckNode;

Frame {
  var stageRef:Stage;
  var menuRef:MenuNode;
  title: "DeckNode Example"
  width: 500
  height: 400
  visible: true
  stage:
    stageRef = Stage {
      fill: Color.BLACK
      content: [
        deckRef = DeckNode {
          fadeInDur: 700ms
          content: [
            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
                    },
                  ]
                }
              ]
            },
            Group {
              id: "Play"
              content: [
                ImageView {
                  image:
                    Image {
                      url: "{__DIR__}images/playlist.png"
                    }
                }
              ]
            },
            Group {
              id: "Burn"
              content: [
                ImageView {
                  image:
                    Image {
                      url: "{__DIR__}images/burning.png"
                    }
                }
              ]
            },
            Group {
              id: "Config"
              content: [
                ImageView {
                  image:
                    Image {
                      url: "{__DIR__}images/config.png"
                    }
                }
              ]
            },
            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";

Please notice the last line of the listing above is what causes the node that we named "Splash" to be displayed when the application starts. 

The code for the other custom nodes used in this program (ButtonNode.fx and MenuNode.fx) are in the Rolling Your Own JavaFX "Custom Nodes": A Graphical Menu Example post, so you may want to visit that post.  At some point I'll put these custom nodes in a JAR file if developers find them useful.  As always, please leave a comment if you have any questions of comments!

Thanks,
Jim Weaver

July 23, 2008

Rolling Your Own JavaFX "Custom Nodes": A Graphical Menu Example

Now that the JavaFX SDK Technology Preview has been released, I'd like to get you up to speed on how to create your own "custom nodes".  This is JavaFX-speak for widgets, gadgets, UI components, whatever, but the purpose is the same: to be able to create a potentially reusable UI thingy for JavaFX programs.  Today's example demonstrates how to create a custom node (in fact, two), and here's a screenshot:

Menunodeexample

By the way, a big thanks goes to Edgar Merino for pointing out some simplifications to the code that have now been implemented in this example.  If you would like to try it out, click 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

As I mentioned in the JavaFX SDK Packages are Taking Shape post, JavaFX is adopting a graphical "node-centric" approach to UI development, so nearly everything in a JavaFX user interface is a Node.  When you want to create your own custom node, you'll extend the CustomNode class, giving it your desired attributes and behavior.  Shown below is the code for the custom node in the example that displays an image and responds to mouse events (e.g. becoming more translucent and showing the text when rolling the mouse over). 

Note: You may be wondering why I don't just use the Button class that is located in the javafx.ext.swing package.  The reason is that the Button class is a Component, not a Node, and I think that it is best to follow the stated direction of moving to a node-centric approach.  At some point there will be a button that subclasses Node, at which point the ButtonNode class in this example may not be needed anymore.

ButtonNode.fx

/*
*  ButtonNode.fx -
*  A node that functions as an image button
*
*  Developed 2008 by James L. Weaver (jim.weaver at lat-inc.com)
*  and Edgar Merino (http://devpower.blogsite.org/) to demonstrate how
*  to create custom nodes in JavaFX
*/

package com.javafxpert.custom_node;

import javafx.animation.*;
import javafx.input.*;
import javafx.scene.*;
import javafx.scene.effect.*;
import javafx.scene.geometry.*;
import javafx.scene.image.*;
import javafx.scene.paint.*;
import javafx.scene.text.*;
import javafx.scene.transform.*;

public class ButtonNode extends CustomNode {
  /**
   * The title for this button
   */
  public attribute title:String;

  /**
   * The Image for this button
   */
  private attribute btnImage:Image;

  /**
   * The URL of the image on the button
   */
  public attribute imageURL:String on replace {
    btnImage =
      Image {
        url: imageURL
      };
  }
   
  /**
   * The percent of the original image size to show when mouse isn't
   * rolling over it. 
   * Note: The image will be its original size when it's being
   * rolled over.
   */
  public attribute scale:Number = 0.9;

  /**
   * The opacity of the button when not in a rollover state
   */
  public attribute opacityValue:Number = 0.8;

  /**
   * The opacity of the text when not in a rollover state
   */
  public attribute textOpacityValue:Number = 0.0;

  /**
   * A Timeline to control fading behavior when mouse enters or exits a button
   */
  private attribute fadeTimeline =
    Timeline {
      toggle: true
      keyFrames: [
        KeyFrame {
          time: 600ms
          values: [
            scale => 1.0 tween Interpolator.LINEAR,
            opacityValue => 1.0 tween Interpolator.LINEAR,
            textOpacityValue => 1.0 tween Interpolator.LINEAR
          ]
        }
      ]
    };

  /**
   * This attribute is interpolated by a Timeline, and various
   * attributes are bound to it for fade-in behaviors
   */
  private attribute fade:Number = 1.0;
 
  /**
   * This attribute represents the state of whether the mouse is inside
   * or outside the button, and is used to help compute opacity values
   * for fade-in and fade-out behavior.
   */
  private attribute mouseInside:Boolean;

  /**
   * The action function attribute that is executed when the
   * the button is pressed
   */
  public attribute action:function():Void;
   
  /**
   * Create the Node
   */
  public function create():Node {
    Group {
      var textRef:Text;
      content: [
        Rectangle {
          width: bind btnImage.width
          height: bind btnImage.height
          opacity: 0.0
        },
        ImageView {
          image: btnImage
          opacity: bind opacityValue;
          scaleX: bind scale;
          scaleY: bind scale;
          translateX: bind btnImage.width / 2 - btnImage.width * scale / 2
          translateY: bind btnImage.height - btnImage.height * scale
          onMouseEntered:
            function(me:MouseEvent):Void {
              mouseInside = true;
              fadeTimeline.start();
            }
          onMouseExited:
            function(me:MouseEvent):Void {
              mouseInside = false;
              fadeTimeline.start();
              me.node.effect = null
            }
          onMousePressed:
            function(me:MouseEvent):Void {
              me.node.effect = Glow {
                level: 0.9
              };
            }
          onMouseReleased:
            function(me:MouseEvent):Void {
              me.node.effect = null;
            }
          onMouseClicked:
            function(me:MouseEvent):Void {
              action();
            }
        },
        textRef = Text {
          translateX: bind btnImage.width / 2 - textRef.getWidth() / 2
          translateY: bind btnImage.height - textRef.getHeight()
          textOrigin: TextOrigin.TOP
          content: title
          fill: Color.WHITE
          opacity: bind textOpacityValue
          font:
            Font {
              name: "Sans serif"
              size: 16
              style: FontStyle.BOLD
            }
        },
      ]
    };
  }

Some things to note in the ButtonNode.fx code listing above are:

  • Our ButtonNode class extends CustomNode
  • This new class introduces attributes for storing the image and text that will appear on the custom node.
  • The create() function returns the declarative expression of our custom node's UI appearance and behavior.
  • The Glow effect in the javafx.scene.effect package is used to brighten the image when clicked.
  • The opacity of the image, the size of the image, and the title of the custom node, are transitioned as the mouse enters and exits the button.  A Timeline is employed to make these transitions gradual.
  • After adjusting opacity and applying a glow effect, the onMouseClicked function calls the action() function attribute defined earlier in the listing.  This make our custom node behave like the familiar Button.

Arranging the ButtonNode instances into a "menu"

As shown in the Setting the "Stage" for the JavaFX SDK post, the HBox class is located in the javafx.scene.layout package, and is a node that arranges other nodes within it.  The MenuNode custom node shown below arranges the ButtonNode instances horizontally, and it uses the Reflection class in the javafx.scene.effects package to add a nice reflection effect below the buttons.  Here's the code:

MenuNode.fx

/*
*  MenuNode.fx -
*  A custom node that functions as a menu
*
*  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.scene.*;
import javafx.scene.effect.*;
import javafx.scene.layout.*;

public class MenuNode extends CustomNode {

  /*
   * A sequence containing the ButtonNode instances
   */
  public attribute buttons:ButtonNode[];
   
  /**
   * Create the Node
   */
  public function create():Node {
    HBox {
      spacing: 10
      content: buttons
      effect:
        Reflection {
          fraction: 0.50
          topOpacity: 0.8
        }
    }   
  }

Using our custom nodes

Now that the custom nodes have been defined, I'd like to show you how to use them in a simple program.  If you've followed this blog, you know that "the way of JavaFX is to bind the UI to a model".  In this simple example, since I really want to focus on teaching you how to create custom nodes, I'm not going to complicate things by creating a model and binding the UI to it.  Rather, I'm simply printing a string to the console whenever a ButtonNode instance is clicked.  Here's the code for the main program in this example:

MenuNodeExampleMain.fx

/*
*  MenuNodeExampleMain.fx -
*  An example of using the MenuNode custom 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.menu_node_example.ui;

import javafx.application.*;
import javafx.scene.paint.*;
import javafx.scene.transform.*;
import java.lang.System;
import com.javafxpert.custom_node.*;

Frame {
  var stageRef:Stage;
  var menuRef:MenuNode;
  title: "MenuNode Example"
  width: 500
  height: 400
  visible: true
  stage:
    stageRef = Stage {
      fill: Color.BLACK
      content: [
        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 {
                  System.out.println("Play button clicked");
                }
            },
            ButtonNode {
              title: "Burn"
              imageURL: "{__DIR__}icons/burn.png"
              action:
                function():Void {
                  System.out.println("Burn button clicked");
                }
            },
            ButtonNode {
              title: "Config"
              imageURL: "{__DIR__}icons/config.png"
              action:
                function():Void {
                  System.out.println("Config button clicked");
                }
            },
            ButtonNode {
              title: "Help"
              imageURL: "{__DIR__}icons/help.png"
              action:
                function():Void {
                  System.out.println("Help button clicked");
                }
            },
          ]
        }
      ]
    }
}

Notice that the action attributes are assigned functions that are called whenever the user clicks the mouse on the corresponding ButtonNode, as pointed out earlier.  Also notice the the __DIR__  expression evaluates to the directory in which the CLASS file resides.  In this case, the graphical images are located in a com/javafxpert/menu_node_example/ui/icons directory.

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.

It is my intent to build up a library of useful custom nodes for the JavaFX SDK Technology Preview and post them in the JFX Custom Nodes category of this blog.  If you have ideas for custom nodes, or would like to share ones that you've developed, please drop me a line at jim.weaver at lat-inc.com

By the way, after this post ran, Weiqi Gao reported some cool news in his Java WebStart Works On Debian GNU/Linux 4.0 AMD64 post.  I'm partial to Weiqi (pronounced way-chee), of course, because he did a great job on the technical review of our JavaFX Script book ;-)

Thanks,
Jim Weaver
JavaFX Script: Dynamic Java Scripting for Rich Internet/Client-side Applications

Immediate eBook (PDF) download available at the book's Apress site

My Photo

Upcoming Speaking Engagements:


  • Stephen Chin and Jim Weaver speaking about JavaFX Platform

  • Speaking on JavaFX and Java at Øredev in Malmö, Sweden on 2-6 November, 2009

Upcoming JavaFX Training:


  • Developing Secure, Rich Internet Applications Hosted on a Variety of Clients Using JavaFX Technology

Enter your email address:

Delivered by FeedBurner

Available now as early access eBook


  • Click book image above to obtain eBook

Twitter Updates

    follow me on Twitter

    Affiliations:

    DZone Links:


    July 2009

    Sun Mon Tue Wed Thu Fri Sat
          1 2 3 4
    5 6 7 8 9 10 11
    12 13 14 15 16 17 18
    19 20 21 22 23 24 25
    26 27 28 29 30 31  

    Disclaimer:

    • By reading this site, you are agreeing that under no circumstances will Veriana Networks, Inc. or its affiliates be responsible for (1) any information contained on or omitted from the site, (2) any person's reliance on any such information, whether or not the information is correct, current or complete, (3) the consequences of any action you or any other person takes or fails to take, whether or not based on information provided by or as a result of the use of the sites.