Clojure/FX My Life: 0.1.2: Transitions

·

13 min read

Last time, with a couple of hiccups, we got JavaFX running under Clojure and found some really fun aspects—but we were riding both on the fact that we were going over code we'd already written in Java, and that we know a lot more about JavaFX now. But the code didn't actually do anything, so it's a little early to rest on our laurels.

We're going to start with this old warhorse:

image.png

That's a flow-panel with our tic-tac-toe image and three extra fillers. We did that just to see how the tile panel would...flow. The FXML for this, if you don't have it handy (and I'll commit it, so you can pull it from there):

<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.scene.Cursor?>
<?import javafx.scene.image.Image?>
<?import javafx.scene.image.ImageView?>
<?import javafx.scene.layout.FlowPane?>

<FlowPane maxHeight="512.0" maxWidth="512.0" minHeight="256.0" minWidth="256.0" prefHeight="256.0" prefWidth="256.0" style="-fx-background-color: GREEN;" xmlns="http://javafx.com/javafx/16" xmlns:fx="http://javafx.com/fxml/1" fx:controller="fxgamesc.TileController">
    <children>
        <ImageView fx:id="tilettt" fitHeight="64.0" fitWidth="64.0" pickOnBounds="true" preserveRatio="true">
            <image>
                <Image url="@tilettt.png" />
            </image>
            <cursor>
                <Cursor fx:constant="OPEN_HAND" />
            </cursor>
        </ImageView>
        <ImageView fitHeight="64.0" fitWidth="64.0" pickOnBounds="true" preserveRatio="true">
            <image>
                <Image url="@filler.png" />
            </image>
            <cursor>
                <Cursor fx:constant="DEFAULT" />
            </cursor>
        </ImageView>
        <ImageView fitHeight="64.0" fitWidth="64.0" pickOnBounds="true" preserveRatio="true">
            <image>
                <Image url="@filler.png" />
            </image>
        </ImageView>
        <ImageView fitHeight="64.0" fitWidth="64.0" pickOnBounds="true" preserveRatio="true">
            <image>
                <Image url="@filler.png" />
            </image>
        </ImageView>
    </children>
</FlowPane>

You can see why most Clojure attempts to tame JavaFX start by abbreviating FXML. In order to import this, we'll need to: 1) Create a controller, much like we did for the frame:

(ns fxgamesc.TileController)

(gen-class
  :name fxgamesc.TileController
  :prefix tc-
  :main true
  :init init
  :constructors {[] []})

(defn tc-init[]
  (println "TILES!"))

You'll notice that it matches the value in the FXML: "fxgamesc.TileController". Now, we can add it just like we did the frame:

(add-screen :tiles "tilespanel.fxml")
(switch-screen t :tiles)

And we'll get:

image.png

Which is not what we wanted, but exactly what we got when we were doing this in Java as well. We don't want to replace the whole screen, just the center part.

Now, in our Java code, this was a simple (if somewhat clunky) matter of putting an AnchorPane in the center of the frame BorderPanel, giving it an fx:id of "central", and then adding a variable named "central" in the controller, with the SceneBuilder complaining about the ID if it wasn't in the controller filer.

What's the upshot of all this? Well, we can replace the central panel like so:

(on-fx-thread (.setAll (.getChildren (.getCenter (:frame @screens))) [tiles-panel]))

Which results in:

image.png

There's no animation yet, of course. But the green field appearing in the upper-left corner (and refusing to grow with the window) looks familiar.

Tic-Tac-Oh-No

We're going to use the final form of our original tic-tac-toe game where we managed to coerce a GridPane into doing our bidding. It looked like this:

image.png

And the FXML looked like this:

<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.geometry.Insets?>
<?import javafx.scene.Group?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.Separator?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.image.Image?>
<?import javafx.scene.image.ImageView?>
<?import javafx.scene.layout.BorderPane?>
<?import javafx.scene.layout.ColumnConstraints?>
<?import javafx.scene.layout.GridPane?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.RowConstraints?>
<?import javafx.scene.layout.VBox?>

<BorderPane fx:id="outerGroup" prefHeight="486.0" prefWidth="544.0" xmlns="http://javafx.com/javafx/11.0.1" xmlns:fx="http://javafx.com/fxml/1" fx:controller="fxgames.ttt.TttController">
    <center>
        <Group fx:id="innerGroup">
            <children>
                <GridPane fx:id="board" gridLinesVisible="true" layoutX="0.5" layoutY="0.5" maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" onDragDropped="#handleOnDrop" onDragOver="#handleOnDragOver" prefHeight="400.0" prefWidth="400.0">
                    <columnConstraints>
                        <ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" prefWidth="100.0" />
                        <ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" prefWidth="100.0" />
                        <ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" prefWidth="100.0" />
                    </columnConstraints>
                    <rowConstraints>
                        <RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
                        <RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
                        <RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
                    </rowConstraints>
                </GridPane>
            </children>
        </Group>
    </center>
    <top>
        <HBox alignment="CENTER" prefHeight="100.0" prefWidth="200.0" spacing="20.0" BorderPane.alignment="CENTER">
            <children>
                <HBox>
                    <HBox.margin>
                        <Insets />
                    </HBox.margin>
                    <children>
                        <VBox prefHeight="102.0" prefWidth="156.0" spacing="15.0">
                            <children>
                                <Label contentDisplay="RIGHT" prefHeight="25.0" prefWidth="159.0" text="Player 1">
                                    <graphic>
                                        <TextField fx:id="player1" onKeyTyped="#nameChange" prefHeight="25.0" prefWidth="95.0" />
                                    </graphic>
                                </Label>
                                <Label contentDisplay="RIGHT" text="Player 2">
                                    <graphic>
                                        <TextField fx:id="player2" onKeyTyped="#nameChange" prefHeight="25.0" prefWidth="95.0" />
                                    </graphic>
                                </Label>
                            </children>
                            <padding>
                                <Insets top="25.0" />
                            </padding>
                        </VBox>
                    </children>
                </HBox>
                <ImageView fx:id="XoverO" fitHeight="64.0" fitWidth="32.0" onMouseClicked="#togglePlayerX" pickOnBounds="true" preserveRatio="true">
                    <image>
                        <Image url="@xo.gif" />
                    </image>
                </ImageView>
                <Separator orientation="VERTICAL" prefHeight="200.0" />
                <VBox alignment="CENTER" prefHeight="100.0" prefWidth="179.0">
                    <children>
                        <Label fx:id="p1label" text="Player 1 Wins" />
                        <Label fx:id="p1Score" text="0" />
                        <Label fx:id="p2Label" text="Player 2 Wins" />
                        <Label fx:id="p2Score" text="0" />
                        <Label text="Draws" />
                        <Label fx:id="tieScore" text="1,234,646" />
                    </children>
                </VBox>
            </children>
        </HBox>
    </top>
    <right>
        <GridPane id="xobin" gridLinesVisible="true" BorderPane.alignment="TOP_LEFT" fx:id="xobin">
            <columnConstraints>
                <ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" prefWidth="100.0" />
            </columnConstraints>
            <rowConstraints>
                <RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
                <RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
            </rowConstraints>
            <children>
                <ImageView id="X" fx:id="X" fitHeight="79.0" fitWidth="105.0" onDragDetected="#handleOnDragDetected" pickOnBounds="true" preserveRatio="true">
                    <image>
                        <Image url="@X.gif" />
                    </image>
                </ImageView>
                <ImageView id="O" fx:id="O" fitHeight="79.0" fitWidth="105.0" onDragDetected="#handleOnDragDetected" pickOnBounds="true" preserveRatio="true" GridPane.rowIndex="1">
                    <image>
                        <Image url="@O.gif" />
                    </image>
                </ImageView>
            </children>
        </GridPane>
    </right>
    <bottom>
        <Label fx:id="message" text="Welcome to the exciting world of Tic-Tac-Toe" BorderPane.alignment="CENTER" />
    </bottom>
</BorderPane>

If we scan through this, we'll some red, for sure. We'll need another controller:

(ns fxgamesc.ttt.TttController)

(gen-class
  :name fxgamesc.ttt.TttController
  :prefix ttt-
  :main true
  :init init
  :constructors {[] []})

(defn ttt-init[]
  (println "Tic-tac-toe!"))

Note that we're using a sub-directory of "ttt" here. We'll have to add this to the :aot key of our project file:

:aot [fxgamesc.Controller fxgamesc.TileController fxgamesc.ttt.TttController]

And now, if we fix the fxml file:

fx:controller="fxgamesc.ttt.TttController"

It will switch from red to green. Kind of cool how that works. We can get rid of some of the other "red" by copying over GIFs:

image.png

The remaining "red" items are methods. For now, we'll just take those out. This should allow it to load, at least.

Methods

But we've barely kicked the can, because the next thing we need to do is handle a click on the tic-tac-toe tile! We need an onMouseClicked added to the "tilespanel.fxml":

<ImageView fx:id="tilettt" fitHeight="64.0" fitWidth="64.0" pickOnBounds="true" preserveRatio="true" onMouseClicked="#openTTT">

You can do this in the SceneBuilder or type it in, but either way it's red. Our Java code originally looked like this:

 public void openTTT(MouseEvent mouseEvent) {
        System.out.println("Opening Tic-Tac-Toe");
    }

We can do that by giving our generated class a method called openTTT which is a two-part process:

(ns fxgamesc.TileController
  (:import (javafx.fxml FXML)))

(gen-class
  :name fxgamesc.TileController
  :prefix tc-
  :main true
  :init init
  :constructors {[] []}
  :methods [[openTTT [javafx.scene.input.MouseEvent] void]]) ;Add this

(defn tc-init[]
  (println "TILES!"))

;And add the below function
(defn tc-openTTT[this e]
  (println "Opening Tic-Tac-Toe!"))

One thing that's quickly apparent: We're going to be seeing baroque and rather weird things the more directly we interface with the things that are so helpful in Java—but really completely unnecessary in Clojure. But for now, we'll be content to try to imitate our original Java code.

Off the cuff (though with a fair amount of cringe), it seems like we could do something like this:

(def tic-tac-toe (FXMLLoader/load (io/resource "tilespanel.fxml")))

(defn tc-openTTT[this e]
  (println "Opening Tic-Tac-Toe!")
  (on-fx-thread (.setAll (.getChildren (.getCenter (:frame @core/screens))) [tic-tac-toe])))

I just took this out of core and changed it to load tic-tac-toe, but this creates a cyclic dependence because now TileController depends on core and core, somehow, depends on TileController. It's not in the requires but we are loading the TilePanel there and that probably pulls in the controller as well.

Anyway, it's kind of awful, so let's make a namespace that handles all the transitions: This will replace our ScreenController and NodeController classes:

(ns fxgamesc.transitions
  (:require [clojure.java.io :as io])
  (:import (javafx.fxml FXMLLoader)))

(def screens (atom nil))
(defn add-screen [handle resource-name]
  (swap! screens assoc handle (FXMLLoader/load (io/resource resource-name))))
(defn get-screen [handle]
  (handle @screens))
(defn switch-screen [scene new-root]
  (.setRoot scene (new-root @screens)))

Now core just has this:

(ns fxgamesc.core
  (:require [clojure.java.io :as io]
            [fxgamesc.platform :refer [on-fx-thread initialize]]
            [fxgamesc.transitions :refer [add-screen switch-screen get-screen]])
  (:import (javafx.stage Stage)
           (javafx.scene Scene Group)
           (javafx.fxml FXMLLoader)))

(def s (on-fx-thread (Stage.)))
(.setTitle @s "Fun 'n' Games with JavaFX -- Clojure Style")
(def t (Scene. (Group.) 800 600))
(on-fx-thread (.setScene @s t))
(on-fx-thread (.show @s))
(add-screen :frame "frame.fxml")
(switch-screen t :frame)
(.add (.getStylesheets t)  (.toExternalForm (io/resource "styles.css")))
(def tiles-panel (FXMLLoader/load (io/resource "tilespanel.fxml")))
(on-fx-thread (.setAll (.getChildren (.getCenter (get-screen :frame))) [tiles-panel]))

I removed the (initialize) to the platform namespace because it needs to be called first thing.

TilesController now looks like this:

(ns fxgamesc.TileController
  (:require [fxgamesc.platform :refer [on-fx-thread]]
            [fxgamesc.transitions :refer [get-screen]]
            [clojure.java.io :as io])
  (:import (javafx.fxml FXMLLoader)))

(gen-class
  :name fxgamesc.TileController
  :prefix tc-
  :main true
  :init init
  :constructors {[] []}
  :methods [[openTTT [javafx.scene.input.MouseEvent] void]])

(defn tc-init[]
  (println "TILES!"))

(def tic-tac-toe (FXMLLoader/load (io/resource "tttgrid.fxml")))

(defn tc-openTTT[this e]
  (println "Opening Tic-Tac-Toe!")
  @(on-fx-thread (.setAll (.getChildren (.getCenter (get-screen :frame))) [tic-tac-toe])))

And the central area clicks instantly over from the tile to the tic-tac-toe game.

The way the Java Controller object works is to load up all the nodes in its constructor:

    public Controller() {
        me = this;
        nodeController = new NodeController();
        loadNode("tiles-panel", "/resources/main/tilespanel.fxml");
        loadNode("tic-tac-toe", "/resources/ttt/tttgrid.fxml");
    }

We just need the loadNode. But do we even need that? Why are we being so judgmental, here? Is there a substantial difference between "screens" and "nodes"? All screens are nodes, after all.

What if we just did this?

(ns fxgamesc.transitions
  (:require [clojure.java.io :as io]
            [fxgamesc.platform :refer [on-fx-thread]])
  (:import (javafx.fxml FXMLLoader)))

(def nodes (atom nil))
(defn add-node [handle resource-name]
  (swap! nodes assoc handle (FXMLLoader/load (io/resource resource-name))))
(defn get-node [handle]
  (handle @nodes))
(defn switch-screen [scene new-root]
  (.setRoot scene (new-root @nodes)))
(defn activate [new-node owner]
  (on-fx-thread (.setAll (.getChildren owner) [(new-node @nodes)])))

One map for all the nodes, added and fetched with add-node and get-node respectively, with switch-screen being used to swap out the whole UI and activate being used to switch out selected nodes.

Since we don't have a central variable any more, let's make a central function in Controller:

(defn central []
  (.getCenter (get-node :frame)))

Our core is now down to:

(ns fxgamesc.core
  (:require [clojure.java.io :as io]
            [fxgamesc.platform :refer [on-fx-thread]]
            [fxgamesc.Controller :refer [central]]
            [fxgamesc.transitions :refer [add-node switch-screen get-node activate]])
  (:import (javafx.stage Stage)
           (javafx.scene Scene Group)))

(def s (on-fx-thread (Stage.)))
(.setTitle @s "Fun 'n' Games with JavaFX -- Clojure Style")
(def t (Scene. (Group.) 800 600))
(on-fx-thread (.setScene @s t))
(on-fx-thread (.show @s))
(add-node :frame "frame.fxml")
(switch-screen t :frame)
(.add (.getStylesheets t)  (.toExternalForm (io/resource "styles.css")))
(activate :tiles-panel (central))

And TileController now is a sleek:

(ns fxgamesc.TileController
  (:require [fxgamesc.transitions :refer [activate]]
            [fxgamesc.Controller :refer [central]]))

(gen-class
  :name fxgamesc.TileController
  :prefix tc-
  :main true
  :init init
  :constructors {[] []}
  :methods [[openTTT [javafx.scene.input.MouseEvent] void]])

(defn tc-init[]
  (println "TILES!"))

(defn tc-openTTT[this e]
  (println "Opening Tic-Tac-Toe!")
  (activate :ttt-grid (central)))

It's like the code gets smaller and smaller without any particular effort on our part to make it so.

I am going to make one change which will make it smaller, though it's more about restricting access to Controller. Let's re-factor add-node a bit:

(defn add-raw [handle node]
  (swap! nodes assoc handle node))

(defn add-node [handle resource-name]
  (add-raw handle (FXMLLoader/load (io/resource resource-name))))

Our hash-map is just a list of keys pointing to nodes, right? Well, why don't we make it possible for it to accept any node, as shown with add-raw (and add-node re-factored to call add-raw).

Now, let's make activate variadic:

(defn activate
  ([new-node owner]
   (on-fx-thread (.setAll (.getChildren owner) [(new-node @nodes)])))
  ([new-node]
   (activate new-node (:central @nodes))))

When a specific node is passed in as an owner, we'll use that, but otherwise we'll default to the node labeled :central. Now, in core, we'll set it:

(add-node :frame "frame.fxml")
(add-raw :central (central))
(switch-screen t :frame)
(.add (.getStylesheets t)  (.toExternalForm (io/resource "styles.css")))
(activate :tiles-panel)

We have to set it here, rather than in Controller (which would be ideal) because the central property may not exist yet. (There's almost certainly a better way to deal with that, but this works for now.)

Note, however, activate now only requires the key! And TileController no longer needs to know about Controller:

(defn tc-openTTT[this e]
  (println "Opening Tic-Tac-Toe!")
  (activate :ttt-grid))

Most of our namespaces are less than 20 lines long: Quite the contrast from Java.

Real Talk

Now, the hallmark of this particular series is me "keeping it real": In the Java stream, I've made plenty of mistakes and cringe-inducing design decisions, and I've complained as things irritated me. This series, recall, is partly a response to the awful state of documentation and educational books, which tend to be both shallow and glib.

So I would be re-miss if I didn't point out that all was not well in the Clojure stream. There's this error, for example:

WARNING: Unsupported JavaFX configuration: classes were loaded from 'unnamed module @69c6161d'

Which sometimes comes up. It's apparently not an issue at the moment but it might bode ill. More significantly, there's this from the leiningen project:

Q: I need to do AOT for an uberjar; can I avoid it during development?

A: Yes, it is strongly recommended to do AOT only in the uberjar task if possible. But by default the AOT'd files will still be visible during development unless you also change :target-path to something like "target/uberjar" in the :uberjar profile as well.

One thing I've seen over the years, pretty consistently, is the admonition to not use :aot in development because "weird things will happen". And as I was going through this, I saw some pretty weird things which were resolved by either a lein clean or just shutting down the IDE and restarting it, or by just going to bed and trying again the next day.

Because I couldn't rule out that last possibility—being tired—as the source of the weirdness, I've been hanging in with the :gen-class, but in practice it seems as though most Clojure apps that dabble in JavaFX for very long end up not using them.

I knew this all along, of course, but a big part of what we're doing here is figuring out why things are done the way they are. We'll see how long we can use the Controller approach.

My tolerance for mysterious things and things that require rituals (like running lein clean all the time) is very low, so I don't know how long I can hold out. But I have to be sure it's really random and not a misunderstanding on my part. For example, these errors seem to appear at random when trying to load the frame:

#error {
 :cause nth not supported on this type: PersistentArrayMap
 :via
 [{:type clojure.lang.Compiler$CompilerException

And I discovered that this would fix it:

(defn add-node [handle resource-name]
  (add-raw handle (FXMLLoader/load (io/resource resource-name)))
  nil) ; return nil

Why? Well, near as I can figure, just because the functions are defined as returning void doesn't necessarily mean that there's nothing going on with that return value.

Back To Animations

The original Java code for animation was:

protected void activate(String name, Pane owner) {
        Node node  = nodeMap.get(name);
        node.translateXProperty().set(owner.getWidth());
        owner.getChildren().remove(node);
        owner.getChildren().add(node);

        KeyValue kv = new KeyValue(node.translateXProperty(), 0, Interpolator.EASE_IN);
        KeyFrame kf = new KeyFrame(Duration.seconds(0.25), kv);

        Timeline timeline = new Timeline();
        timeline.getKeyFrames().add(kf);
        timeline.setOnFinished(event -> {
            owner.getChildren().setAll(node); 
        });
        timeline.play();
    }

We can translate that line-by-line:

(defn activate
  ([new-node owner]
   (on-fx-thread

We're just going to do this all on the FX thread. There's no real gain in trying to pick-and-choose which lines don't have to be on it. First, we fetch our node:

     (let [n (get-node new-node)

Next, we set the translate:

           _ (.set (.translateXProperty n) (.getWidth owner))

Next, we remove the node we're transitioning, and add it right back in:

           _ (.remove (.getChildren owner) n)
           _ (.add (.getChildren owner) n)

We do this because the node isn't necessarily in the owner already, and it doesn't cause an exception to try to remove a node that's not there but it does cause an exception to add a node that is there.

Creating the KeyValue is straight-up:

           kv (KeyValue. (.translateXProperty n) 0 Interpolator/EASE_IN)

But we run into a problem when we create the KeyFrame. We can't just do this:

           kf (KeyFrame. (Duration/seconds 0.25) kv))

Because despite the Java code taking a single KeyValue, the parameter itself is actually jammed into an array when it's passed, and that's hidden due to some Java sugar. But we could do this:

KeyFrame kf2 = new KeyFrame(Duration.seconds(0.25), kv1, kv2, kv3, kv4, kv5, kv6);

In the Java, and not because the method has a bunch of different overloads. To get the same effect in Clojure, we have to put it into a vector and then use into-array:

           kf (KeyFrame. (Duration/seconds 0.25) (into-array [kv]))

Now we create the Timeline and add the key frames to it:

           tl (Timeline.)
           _ (.add (.getKeyFrames tl) kf)

And to use the .setOnFinished, I think we're stuck using proxy with a handle method:

           _ (.setOnFinished tl (proxy [EventHandler] [] (handle [e] (.setAll (.getChildren owner) (into-array [n])))))

Note that, once again, we need to use into-array. The ObservableList that's returned has setAll defined as:

    boolean setAll(E... var1);

This may bite us in the butt a lot. We'll have to get good at spotting it. We may even want to build a library to insulate us from these things.

Finally we can play!

           _ (.play tl)])))

And the single parameter part is unchanged:

  ([new-node]
   (activate new-node (:central @nodes))))

All together, it's:

(defn activate
  ([new-node owner]
   (on-fx-thread
     (let [n (get-node new-node)
           _ (.set (.translateXProperty n) (.getWidth owner))
           _ (.remove (.getChildren owner) n)
           _ (.add (.getChildren owner) n)
           kv (KeyValue. (.translateXProperty n) 0 Interpolator/EASE_IN)
           kf (KeyFrame. (Duration/seconds 0.25) (into-array [kv]))
           tl (Timeline.)
           _ (.add (.getKeyFrames tl) kf)
           _ (.setOnFinished tl (proxy [EventHandler] [] (handle [e] (.setAll (.getChildren owner) (into-array [n])))))
           _ (.play tl)])))
  ([new-node]
   (activate new-node (:central @nodes))))

Which I'm not crazy about, but it's a start, and we're about where we left the original Java entry, so let's push it and move on.

Code for this is at github.com/dsbw/fxgamesc/releases/tag/v0.1.2