CS 124, Spring 2021
Lab 9: GUI Tutorial

This lab is a tutorial to introduce you to some basic JavaFX concepts. You will construct a program that lets the user do some basic freehand sketching. Most of the program can be copy-and-pasted from this web page, but you should do more than copy-and-paste; you should pay attention to the concepts. Note that there are some exercises at the end that you will need to complete to have a finished program.

This program is due by midnight next Monday, April 19, and will not be accepted late. You should actually be able to complete the program and get it turned in fairly quickly. For this program, you do not need to add comments. This lab will count for 15 points. To get full credit, you just need to turn in a complete, working program, with correct indentation. You will turn in a program named Sketcher.java.

About the Final Project

After finishing this lab, your task for the rest of the semester is to do one final programming project. I will provide fairly detailed instructions for a GUI Hangman game, but you should consider doing something else. I will show programs from some old labs to give you some ideas. More details will be available later in the week, but you should start thinking about ideas for a project. You can work on the final project, and get help on it, during the final two lab periods on April 20 and April 27. Note that you can choose, optionally, to work on the final project with a partner.

The Start: A Subclass of Application

You should work on this lab in an Eclipse project that supports JavaFX. Start by creating a new class named Sketcher. You want the class to be a subclass of Application, the class that represents JavaFX programs, so modify the definition to read:

public class Sketcher extends Application {

This will be marked as an error becasue class Application has to be imported. You can add the line to the top of the file, before the class definition:

import javafx.application.Application;

There will still be an error because Application is an abstract class, and you have to provide a definition for an abstract method that is defined in the abstract class. Add the following method definition to the class:

public void start(Stage stage) {
}

Again, there will be an error because class Stage needs to be imported. To do that, you can add the following line to the top of the file:

import javafx.stage.Stage;

You should now have a short program with no errors.

Adding import can get tricky and tiresome. Eclipse can do it for you. If you are typing the name of a class, then pressing Control-Space will bring up a list of classes with a matching name. Use the arrow keys to select the one you want, and press return. If the class has not already been imported, an import statement will be added to the file. When you paste code into the program that uses classes that have not been imported, they will be marked as errors. To import a missing class, you can hover your mouse over the class name, and you will get a list of error fixes including options to import the class; just click the import that you want to apply. BUT BE CAREFUL. For this lab, you want to make sure to import classes from package javafx. There are classes in other packages with similar names, that you don't want to import. If you accidently add one of those unneeded classes to your project, be sure to delete the import statement from your program and import the correct javafx class instead.

You don't yet have a working program that can be run. For that, you need a main() routine. For a JavaFX program, you can use the following main() routine, which you should add to your program now:

public static void main(String[] args) {
    launch();
}

At this point, however, you shouldn't run the program, since there is nothing for it to do.

Adding Content

The start() method in a JavaFX program sets up the GUI and opens a window for the program to run in. The parameter, stage, in the start method represents the window. To have a minimal working program, you need to add content to the stage and make it visible. To do that for this program, copy the following code into the start() method:

Canvas canvas = new Canvas(800,600);
g = canvas.getGraphicsContext2D();
BorderPane mainPane = new BorderPane(canvas);
Scene scene = new Scene(mainPane);
stage.setScene(scene);
stage.setResizable(false);
stage.setTitle("Simple Sketcher");
stage.show(); 

You will need to import the new classes that are used here, as discussed above. You will also need to define g, which has to be an instance variable of type GraphicsContext that will be used for drawing on the canvas. (If you want to let Eclipse write the declaration for you, the error fix that you need is listed as "Ceate field 'g'". Here, "field" is another name for instance variable.) You might as well make the instance variable private, so the declaration will look like

private GraphicsContext g;

This must be in the class, outside of any subroutine definition. Usually, instance variables are defined at the top of the class.

At this point, you should then have a program that you can run. An empty window will appear on the screen. Closing the window will end the program.

You shouldn't simply copy code blindly into your program. You should try to understand it. Here is what is going on...

This program will use a canvas for drawing, which is something that you have done many times already. The first line creates a Canvas object. The constructor has two parameters, representing the width and height of the canvas. In this program, the size of the canvas will determine the size of the window.

You can't just add a canvas to a stage. A Stage contains a Scene, and the Scene holds the content of the window. As a programmer, you need to lay out the components of the program's interface in the scene. Here a "component" means a visible element of the GUI, and "lay out" means to arrange the compontents in the window. Layout is usually done with "panes". A pane is a "container" component that holds other components and implements some policy about how they are laid out inside the pane.

This program uses a BorderPane for the main layout of the window. A BorderPane has a component in the center, and can have up to four addtional components arranged around the sides of that center component. The constructor for BorderPane that is used here takes one parameter that represents the central component. In this case, the center component is the canvas. Soon, we will add some other components. So, mainPane, which is of type BorderPane, holds the canvas. Next, we need a Scene to hold the pane. The scene is created with a constructor that takes the main component as its parameter. (No, I don't know why we have to have a scene instead of just adding the main component directly to the stage.)

The last four lines of the start method configure the stage: stage.setScene(scene) sets the scene the will be shown in the window; stage.setResizable(false) makes it impossible for the user to resize the window (managing resizable windows makes things significantly more complicated); stage.setTitle("Simple Sketcher") specifies the string that appears in the titlebar of the window; and stage.show() makes the window visible on the screen.

Mouse Events

In addition to components and layout, the other important aspect of GUI programming is event handling, that is, programming the response to various events that can occur while the program is running. You have seen previously that event handlers in JavaFX are often given by lambda expressions. Events can be generated by the user by using the mouse or keyboard. A program can respond directly to mouse and keyboard events. There are also "higher-level" events that are generated by components. For example, a button generates an "action event" when the user clicks on it.

The Sketcher program will respond to mouse presses and mouse drags on the canvas. The event handlers for these events are "wired" to the canvas using the canvas's setOnMousePressed and setOnMouseDragged methods. There is also setOnMouseReleased, but we will not use it in this lab. The idea is that a mouse gesture on the canvas can consists of pressing a mouse button while the mouse is over the canvas, moving the mouse while holding down the button, and then releasing the mouse button. This gesture generates a mouse pressed event, a stream of mouse dragged events as the mouse is moved, and finally a mouse released event. To respond to a mouse-drag gesture on the canvas, we can program responses to any or all of the three kinds of event.

The parameter to these event-handler methods can be a lambda expression, which could be a very complicated piece of code. However, I like to use a simple lambda expression that just calls an event-handling method that is defined elsewhere in the program.

To handle the mouse events on the canvas, add the following lines to the start() method. (I put them at the end of the start() method, but they can come anywhere after the canvas object is created.) While you are at it, add some code to fill the canvas with white. This will introduce some errors that you will need to fix:

 
canvas.setOnMousePressed( e -> doMouseDown(e.getX(), e.getY()) );
canvas.setOnMouseDragged( e -> doMouseDrag(e.getX(), e.getY()) );
g.setFill(Color.WHITE);
g.fillRect(0, 0, 800, 600);

Here, e will be an object representing the mouse event that is being handled. We only need the x and y coordinates of the mouse location, which are given by e.getX() and e.getY(). The coordinates are passed to an instance method that will actually contain the code that responds to the event.

You need to add definitions of the instance methods doMouseDown and doMouseDragged to the program. Let's start with methods that will simply let the user sketch a curve. Add this code to the class:


private double prevX, prevY;  // location of previous mouse event

private void doMouseDown(double x, double y) {
    prevX = x;  // Just save the starting position.
    prevY = y;
}

private void doMouseDrag(double x, double y) {
    double offset = Math.sqrt((x-prevX)*(x-prevX) + (y-prevY)*(y-prevY));
    if (offset < 5) {
        // Don't draw anything until mouse has moved a bit.
        return;
    }
    g.setLineWidth(2);
    g.strokeLine(prevX, prevY, x, y);
    prevX = x;
    prevY = y;
}

You should once again have a runnable program, and if you click-and-drag on the canvas, you will sketch a curve.

Menus and Action Events

We want to be able to draw other things besides simple curves, and we would like to be able to draw in different colors. To support this, add two more instance variables and some constants to the class:

private final static int LINE = 0;
private final static int SQUARE = 1;
private final static int DISK = 2;

private int currentTool = LINE;
private Color currentColor = Color.RED;

Next, you will add a menu bar at the top of the window with a menu for changing the current color. A menu holds menu items, which are a kind of button, and a button emits an "action event" when the user clicks it. To program a response to the menu item, you use its setOnAction method to provide an event handler for the action event. A menu item must be added to a menu, which is in turn added to a menubar. Menu items, menus, and menubars are all components. Menus and menubars are containers that hold other components and lay them out on the screen. You should add an instance method to the class for creating the menubar. It makes a Color menu and also a Control menu with a Quit command. The actions for the menu items in the color menu simply change the value of the instance variable currentColor:

private MenuBar makeMenuBar() {
    MenuBar menubar = new MenuBar();
    
    Menu controlMenu = new Menu("Control");
    MenuItem quitItem = new MenuItem("Quit");
    quitItem.setOnAction( e -> System.exit(0) );
    controlMenu.getItems().add(quitItem);
    
    Menu colorMenu = new Menu("Color");
    MenuItem colorItem;
    colorItem = new MenuItem("Red");
    colorItem.setOnAction( e -> currentColor = Color.RED );
    colorMenu.getItems().add(colorItem);
    colorItem = new MenuItem("Green");
    colorItem.setOnAction( e -> currentColor = Color.GREEN );
    colorMenu.getItems().add(colorItem);
    colorItem = new MenuItem("Blue");
    colorItem.setOnAction( e -> currentColor = Color.BLUE );
    colorMenu.getItems().add(colorItem);
    colorItem = new MenuItem("Random");
    colorItem.setOnAction( e -> currentColor = null );
    colorMenu.getItems().add(colorItem);
    
    menubar.getMenus().addAll(controlMenu,colorMenu);
    return menubar;
}

Here, three lines are used to add each item to a menu. For example,

colorItem = new MenuItem("Red");
colorItem.setOnAction( e -> currentColor = Color.RED );
colorMenu.getItems().add(colorItem);

The first line creates the item. The string, "Red", is the text that will appear in the menu. The second line adds an event handler for the event that occurs when the user selects the item from the menu; it says what should happen in response to that command. The third line adds the item to the menu. (It might be peculiar that you have to add it to colorMenu().getItems() rather than to colorMenu itself, but that's juat the way JavaFX works.)

To implement the color change, the handler for mouse drag events has to be changed to use currentColor when it draws. Add the following lines to doMouseDrag(), just before stroking the line. (My idea for random colors is that when currentColor is null, the program should just select a random drawing color. This will produce a cute curve made up of little line segments with different colors.)

if (currentColor == null)
    g.setStroke(Color.hsb(360*Math.random(), 0.8, 1));
else
    g.setStroke(currentColor);

Finally, you have to add the menu bar to the window. It will go in the "top" position of the main BorderPane. The four possible positions for components around the edge of the center component of the pane are top, bottom, left, and right. A menu bar should go in the top position. Add the following line to the start() method, somewhere between the point where the BorderPane is created and the point where the stage is shown. (The menu bar should be in place before the window is opened.)

mainPane.setTop( makeMenuBar() );

At this point, you should be able to run the program and draw curves of different colors.

RadioButtons

Next, you will implement the use of various "tools". The default tool, for drawing simple curves, is the LINE tool. The SQUARE tool will leave a trail of small squares as the user drags the mouse, and the DISK tool leaves a trail of small circular disks. One could use a menu for selecting the tool, but to be different this time, use a set of "radio buttons" along the bottom of the window. Radio buttons come in groups, and only one button in the group can be selected at any given time. Clicking on one of the buttons will select that one and de-select the one that is currently selected.

A radio button is represented by an object of type RadioButton. Just like a MenuItem, a RadioButton emits an action event when the user clicks it, and we can use those action events to change the value of currentTool. The whole group of radio buttons is represented by an object of type ToggleGroup. A toggle group is not a component; it just manages the buttons. To add the buttons to the screen, we need a component to hold them, and that component has to be added to the bottom position in the main BorderPane. For the container, I use a TilePane, which can arrange its contents in a grid of rows and columns. By default, it will just put them in one row. Add the method for constructing the TilePane to your program:

private TilePane makeButtonBar() {
    ToggleGroup grp = new ToggleGroup();
    
    RadioButton lineBtn = new RadioButton("Line");
    lineBtn.setOnAction( e -> currentTool = LINE );
    lineBtn.setToggleGroup(grp);
    lineBtn.setSelected(true); // Make this one the selected radiobutton.

    RadioButton squareBtn = new RadioButton("Square");
    squareBtn.setOnAction( e -> currentTool = SQUARE );
    squareBtn.setToggleGroup(grp);

    RadioButton diskBtn = new RadioButton("Disk");
    diskBtn.setOnAction( e -> currentTool = DISK );
    diskBtn.setToggleGroup(grp);

    TilePane bottom = new TilePane(5,5);
    bottom.getChildren().add( new Label("Draw using: ") );
    bottom.getChildren().addAll(lineBtn,squareBtn,diskBtn);
    bottom.setStyle("-fx-padding: 5px; -fx-border-color: black; -fx-border-width: 2px 0 0 0");
    return bottom;
}

Note that a Label is also added to the TilePane. A Label is a component that simply displays some text; there is no user interaction with a Label. The next-to-last line is hard to explain. There is an entire language for customizing the style, or appearance, of JavaFX components. See Subsection 6.2.5 if you would like a little more information.

To add the TilePane to the program layout, add the following line to the start() method:

mainPane.setBottom( makeButtonBar() );

If you run the program now, you will see the radio buttons, and you can select different buttons, but the SQUARE and DISK tools are not implemented. We need to be able to draw a shape, depending on the current tool. Add this shape drawing method to your program:

private void putShape(double x, double y) {
    if (currentColor == null)
        g.setFill(Color.hsb(360*Math.random(), 0.8, 1));
    else
        g.setFill(currentColor);
    g.setStroke(Color.BLACK);
    g.setLineWidth(1);
    if (currentTool == SQUARE) {
        g.fillRect(x-15,y-15,30,30);
        g.strokeRect(x-15,y-15,30,30);
    }
    else if (currentTool == DISK) {
        g.fillOval(x-15,y-15,30,30);
        g.strokeOval(x-15,y-15,30,30);
    }
}

This method must be called in doMouseDrag to handle the case when the current tool is SQUARE or DISK. It's nice to also call it in doMouseDown so that the first shape appears when the user first presses the mouse. Add this code to doMouseDown:

if (currentTool != LINE)
    putShape(x,y);

and modify doMouseDrag so that it only draws the line when currentTool is LINE, and in the other cases, calls putShape(x,y) instead. You should then be able to draw things like this (shown at reduced size):

Exercises

To finish the program, here are a few exercises that you should do:

Exercise: Add a "Clear" command to the control menu. When the user chooses that command, the drawing should be cleared. You can implement that by filling the canvas with an 800-by-600 white rectangle.

Exercise: Add at least one more color to the "Color" menu.

Exercise: Add at least one more shape as a possibility for the current tool. One option is drawing a string, like your initials or "Hello". Another option is to use an image of a smiley face. To do that, download smiley.png and add it to the src folder for the project in Eclipse. The simley image has to be loaded into the program, represented by an object of type Image: Add this instance variable to the program:

private Image smiley = new Image("smiley.png");

You will need to import Image; be sure that it's javafx.scene.image.Image that you import. When you want to draw the image, you can use

g.drawImage(smiley, x-20, y-20);

No matter what kind of shape you use for this exercise, you will need to add code at several points in the program. If you want to use the smiley, it will look better if you allow a larger offset between copies of the image. This can be done by requiring a larger offset in doMouseDrag:

if (offset < 5 || (currentTool == SMILEY && offset < 30)) {
     // Don't draw anything until mouse has moved a bit.
   return;
}