Agenda

This tutorial touches the following subjects:

  • Creating new MVC group artifacts

  • Instantiating MVC groups

  • MVC Group parent-child relationships

  • MVC Group lifecycle

The goal of the Editor application shown here is to provide basic editing capabilities for text files. Each file will be handled by its own tab; tabs will be located inside a tab container.

This tutorial is almost identical to Tutorial 2::MVC Groups (Swing) but relies on JavaFX as the UI technology. A icon preceding a section or paragraph will be used to denote JavaFx specific content that differs from the other tutorial.

1. Creating an Editor application

We’ll follow similar steps as explained in Tutorial 1::Getting Started to create a brand new Griffon 2 application. Assuming you’ve got SDKMAN, Maven and Gradle already installed on your system, execute the following command on a console prompt, paying attention to the selections we’ve made

Use the griffon-javafx-java template

$ mvn archetype:generate \
      -DarchetypeGroupId=org.codehaus.griffon.maven \
      -DarchetypeArtifactId=griffon-javafx-java-archetype \
      -DarchetypeVersion=2.16.0 \
      -DgroupId=editor \
      -DartifactId=editor\
      -Dversion=1.0.0-SNAPSHOT \
      -DclassName=container \
      -Dgradle=true

There should be a new directory named editor with the freshly created application inside. At this point you can import the project in your favourite IDE. We’ll continue with Gradle on the command line to keep things simple. Verifying that the application is working should be our next step. Execute the following command

$ ./gradlew run

A window should pop up after a few seconds. Quit the application, we’re ready to begin customizing the application.

Top

2. Setting up the main MVC group

Each file we open requires a bit of metadata to be handled by the editor application, for example its title and contents. We’ll create a simple observable POJO that represents a Document.

src/main/java/editor/Document.java
package editor;

import org.codehaus.griffon.runtime.core.AbstractObservable;

import java.io.File;

public class Document extends AbstractObservable {
    private String title;
    private String contents;
    private boolean dirty;
    private File file;

    public Document() {
    }

    public Document(File file, String title) {
        this.file = file;
        this.title = title;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        firePropertyChange("title", this.title, this.title = title);
    }

    public String getContents() {
        return contents;
    }

    public void setContents(String contents) {
        firePropertyChange("contents", this.contents, this.contents = contents);
    }

    public boolean isDirty() {
        return dirty;
    }

    public void setDirty(boolean dirty) {
        firePropertyChange("dirty", this.dirty, this.dirty = dirty);
    }

    public File getFile() {
        return file;
    }

    public void setFile(File file) {
        firePropertyChange("file", this.file, this.file = file);
    }

    public void copyTo(Document doc) {
        doc.title = title;
        doc.contents = contents;
        doc.dirty = dirty;
        doc.file = file;
    }
}

The title and contents properties should be self explanatory. We’ll use the dirty property to keep track of changes. The final property, file, points to the File object that was used to load the document; we’ll use this value to save back edited changes.

Now imagine what happens when you have multiple tabs open in an editor; the save and close actions are context sensitive, that is, they operate on the currently selected editor/tab. We need to replicate this behavior, in order to do so we’ll use a presentation model for the Document class, aptly named DocumentModel.

src/main/java/editor/DocumentModel.java
package editor;

import java.beans.PropertyChangeListener;

import static griffon.util.GriffonClassUtils.setPropertyValue;

public class DocumentModel extends Document {
    private Document document;

    private final PropertyChangeListener proxyUpdater = (e) -> setPropertyValue(this, e.getPropertyName(), e.getNewValue());

    public DocumentModel() {
        addPropertyChangeListener("document", (e) -> {
            if (e.getOldValue() instanceof Document) {
                ((Document) e.getOldValue()).removePropertyChangeListener(proxyUpdater);
            }
            if (e.getNewValue() instanceof Document) {
                ((Document) e.getNewValue()).addPropertyChangeListener(proxyUpdater);
                ((Document) e.getNewValue()).copyTo(DocumentModel.this);
            }
        });
    }

    public Document getDocument() {
        return document;
    }

    public void setDocument(Document document) {
        firePropertyChange("document", this.document, this.document = document);
    }
}

The DocumentModel class extends from Document just as a convenience, it inherits all properties from Document in this way. It also defines a new property document which will hold the selected Document.

Alright, we can move on to the ContainerModel member of the container MVC group (our main group). Here we’ll see how the previous presentation model is put to good use.

griffon-app/models/editor/ContainerModel.java
package editor;

import griffon.core.artifact.GriffonModel;
import griffon.metadata.ArtifactProviderFor;
import org.codehaus.griffon.runtime.core.artifact.AbstractGriffonModel;

@ArtifactProviderFor(GriffonModel.class)
public class ContainerModel extends AbstractGriffonModel {
    private static final String MVC_IDENTIFIER = "mvcIdentifier";
    private final DocumentModel documentModel = new DocumentModel();
    private String mvcIdentifier;

    public ContainerModel() {
        addPropertyChangeListener(MVC_IDENTIFIER, (e) -> {
            Document document = null;
            if (e.getNewValue() != null) {
                EditorModel model = getApplication().getMvcGroupManager().getModel(mvcIdentifier, EditorModel.class);
                document = model.getDocument();
            } else {
                document = new Document();
            }
            documentModel.setDocument(document);
        });
    }

    public String getMvcIdentifier() {
        return mvcIdentifier;
    }

    public void setMvcIdentifier(String mvcIdentifier) {
        firePropertyChange(MVC_IDENTIFIER, this.mvcIdentifier, this.mvcIdentifier = mvcIdentifier);
    }

    public DocumentModel getDocumentModel() {
        return documentModel;
    }
}

This model keeps track of two items:

  1. the identifier of the selected tab, represented by mvcIdentifier.

  2. the document presentation model, represented by documentModel.

Notice that the documentModel property is declared as final; this means it will always have the same value, thus we can use it to create stable bindings. This is the reason for making DocumentModel a subclass of Document. As you can see the former listens to changes on the latter and copying the values over. This happens every time the application changes the value of documentModel.document due to the PropertyChangeListeners that were put into place.

Let’s move to the View. Open up ContainerView.java and paste the following into it

griffon-app/views/editor/ContainerView.java
package editor;

import griffon.core.artifact.GriffonView;
import griffon.core.controller.Action;
import griffon.inject.MVCMember;
import griffon.metadata.ArtifactProviderFor;
import javafx.fxml.FXML;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.TabPane;
import javafx.scene.paint.Color;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import javafx.stage.Window;
import org.codehaus.griffon.runtime.javafx.artifact.AbstractJavaFXGriffonView;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.File;
import java.util.Collections;

@ArtifactProviderFor(GriffonView.class)
public class ContainerView extends AbstractJavaFXGriffonView {
    private ContainerController controller;
    private ContainerModel model;

    @FXML
    private TabPane tabGroup;

    private FileChooser fileChooser;

    @MVCMember
    public void setController(@Nonnull ContainerController controller) {
        this.controller = controller;
    }

    @MVCMember
    public void setModel(@Nonnull ContainerModel model) {
        this.model = model;
    }

    @Nonnull
    public TabPane getTabGroup() {
        return tabGroup;
    }

    @Override
    public void initUI() {
        Stage stage = (Stage) getApplication()
            .createApplicationContainer(Collections.emptyMap());
        stage.setTitle(getApplication().getConfiguration().getAsString("application.title"));
        stage.setWidth(480);
        stage.setHeight(320);
        stage.setScene(init());
        getApplication().getWindowManager().attach("mainWindow", stage);

        fileChooser = new FileChooser();
        fileChooser.setTitle(getApplication().getConfiguration().getAsString("application.title", "Open File"));
    }

    // build the UI
    private Scene init() {
        Scene scene = new Scene(new Group());
        scene.setFill(Color.WHITE);
        scene.getStylesheets().add("bootstrapfx.css");

        Node node = loadFromFXML();
        ((Group) scene.getRoot()).getChildren().addAll(node);
        connectActions(node, controller);

        tabGroup.getSelectionModel().selectedItemProperty().addListener((v, o, n) -> model.setMvcIdentifier(n != null ? n.getId() : null));

        Action saveAction = actionFor(controller, "save");
        model.getDocumentModel().addPropertyChangeListener("dirty", (e) -> saveAction.setEnabled((Boolean) e.getNewValue()));

        return scene;
    }

    @Nullable
    public File selectFile() {
        Window window = (Window) getApplication().getWindowManager().getStartingWindow();
        return fileChooser.showOpenDialog(window);
    }
}

Here we find a Scene whose contents come from an FXML file. The file name is determined using a naming convention, in this case it’s the fully qualified View class name without the View suffix. These are the contents of said file.

Also, the view registers an anonymous javafx.beans.value.ChangeListener to listen to tab selection changes and update the documentModel property found in the model.

griffon-app/resources/editor/container.fxml
<?xml version="1.0" encoding="UTF-8"?>
<!--

    SPDX-License-Identifier: Apache-2.0

    Copyright 2008-2021 the original author or authors.

    Licensed under the Apache License, Version 2.0 (the "License");
    you may not use this file except in compliance with the License.
    You may obtain a copy of the License at

        http://www.apache.org/licenses/LICENSE-2.0

    Unless required by applicable law or agreed to in writing, software
    distributed under the License is distributed on an "AS IS" BASIS,
    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    See the License for the specific language governing permissions and
    limitations under the License.

-->
<?import javafx.scene.control.Menu?>
<?import javafx.scene.control.MenuBar?>
<?import javafx.scene.control.MenuItem?>
<?import javafx.scene.control.SeparatorMenuItem?>
<?import javafx.scene.control.TabPane?>
<?import javafx.scene.layout.BorderPane?>
<BorderPane maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="400.0"
            prefWidth="600.0" xmlns="http://javafx.com/javafx/8" xmlns:fx="http://javafx.com/fxml/1"
            fx:controller="editor.ContainerController">
    <top>
        <MenuBar BorderPane.alignment="CENTER">
            <Menu mnemonicParsing="false" text="File">
                <MenuItem mnemonicParsing="false" text="Open" fx:id="openActionTarget"/>
                <MenuItem mnemonicParsing="false" text="Close" fx:id="closeActionTarget"/>
                <SeparatorMenuItem mnemonicParsing="false"/>
                <MenuItem mnemonicParsing="false" text="Save" fx:id="saveActionTarget"/>
                <SeparatorMenuItem mnemonicParsing="false"/>
                <MenuItem mnemonicParsing="false" text="Quit" fx:id="quitActionTarget"/>
            </Menu>
        </MenuBar>
    </top>
    <center>
        <TabPane fx:id="tabGroup" prefHeight="200.0" prefWidth="200.0" tabClosingPolicy="UNAVAILABLE"
                 BorderPane.alignment="CENTER"/>
    </center>
</BorderPane>

This file defines a MenuBar and a tab container (a TabPane) named tabGroup. This tab container is exposed to the outside world via a getter method; we’ll see why it’s done this way when the second MVC group comes into play. The View is also responsible for managing a FileChooser that will be used to select files for reading.

We can define a few of the action properties using a resource bundle, from the example the mnemonic and accelerator properties. Paste the following into messages.properties.

There is no mnemonic support in JavaFX.

griffon-app/i18n/messages.properties
editor.ContainerController.action.Open.accelerator = Meta+O
editor.ContainerController.action.Close.accelerator = Meta+W
editor.ContainerController.action.Save.accelerator = Meta+S
editor.ContainerController.action.Quit.accelerator = Meta+Q
editor.ContainerController.action.Open.icon = org.kordamp.ikonli.javafx.FontIcon|fa-folder-open
editor.ContainerController.action.Close.icon = org.kordamp.ikonli.javafx.FontIcon|fa-window-close
editor.ContainerController.action.Save.icon = org.kordamp.ikonli.javafx.FontIcon|fa-save
editor.ContainerController.action.Quit.icon = org.kordamp.ikonli.javafx.FontIcon|fa-power-off

We’re almost done with the container MVC group, what remains to be done is update the ContainerController.

griffon-app/controllers/editor/ContainerController.java
package editor;

import griffon.core.artifact.GriffonController;
import griffon.metadata.ArtifactProviderFor;
import org.codehaus.griffon.runtime.core.artifact.AbstractGriffonController;

@ArtifactProviderFor(GriffonController.class)
public class ContainerController extends AbstractGriffonController {
    private ContainerModel model;
    private ContainerView view;

    public void open() {

    }

    public void save() {

    }

    public void close() {

    }

    public void quit() {
        getApplication().shutdown();
    }
}

We’ve got 4 actions (open, save, close and quit) and nothing more for the time being. You can run the application once again to verify that the code compiles and runs.

Top

3. Generating an Editor MVC group

You can create new artifact files by hand, just by copying existing one. We’ll create a new set of files for the editor MVC group, as follows:

griffon-app/models/editor/EditorModel.java
griffon-app/views/editor/EditorView.java
griffon-app/controllers/editor/EditorController.java
src/test/java/editor/EditorControllerTest.java

The final piece of the puzzle is letting the application know about this new MVC group, this is done by editing Config.java

griffon-app/conf/Config.java
import griffon.util.AbstractMapResourceBundle;

import javax.annotation.Nonnull;
import java.util.Map;

import static griffon.util.CollectionUtils.map;
import static java.util.Collections.singletonList;

public class Config extends AbstractMapResourceBundle {
    @Override
    protected void initialize(@Nonnull Map<String, Object> entries) {
        map(entries)
            .e("application", map()
                .e("title", "editor-javafx-java")
                .e("startupGroups", singletonList("container"))
                .e("autoShutdown", true)
            )
            .e("mvcGroups", map()
                .e("container", map()
                    .e("model", "editor.ContainerModel")
                    .e("view", "editor.ContainerView")
                    .e("controller", "editor.ContainerController")
                )
                .e("editor", map()
                    .e("model", "editor.EditorModel")
                    .e("view", "editor.EditorView")
                    .e("controller", "editor.EditorController")
                )
            );
    }
}

Great, we have the basics for this new MVC group ready. Now let’s make it work.

Top

4. Setting up the Editor MVC group

We’ll begin by looking at the Model again. This time the model will hold the actual Document that this group will edit.

griffon-app/models/editor/EditorModel.java
package editor;

import griffon.core.artifact.GriffonModel;
import griffon.metadata.ArtifactProviderFor;
import org.codehaus.griffon.runtime.core.artifact.AbstractGriffonModel;

@ArtifactProviderFor(GriffonModel.class)
public class EditorModel extends AbstractGriffonModel {
    private Document document;

    public Document getDocument() {
        return document;
    }

    public void setDocument(Document document) {
        firePropertyChange("document", this.document, this.document = document);
    }
}

Just a basic observable model with a single document property. Things get interesting once we move to the View, as is its responsibility to display the file’s contents. One thing that must happen at some point is the new tab being added to the tab container. We can make this happen at the point where an instance of the editor MVC group is created; this requires the EditorView to expose its tab somehow. Or we can keep this behavior local to this group and make sure that this View handles adding and removing the tab. This is the reason why we exposed the tabGroup field in ContainerView. Let’s look at the EditorView, shall we?

The EditorView relies on FXML to create the nodes.

griffon-app/views/editor/EditorView.java
package editor;

import griffon.core.artifact.GriffonView;
import griffon.inject.MVCMember;
import griffon.metadata.ArtifactProviderFor;
import javafx.fxml.FXML;
import javafx.scene.control.Tab;
import javafx.scene.control.TextArea;
import org.codehaus.griffon.runtime.javafx.artifact.AbstractJavaFXGriffonView;

import javax.annotation.Nonnull;
import java.util.Objects;

@ArtifactProviderFor(GriffonView.class)
public class EditorView extends AbstractJavaFXGriffonView {
    @MVCMember @Nonnull
    private EditorModel model;
    @MVCMember @Nonnull
    private ContainerView parentView;
    @MVCMember @Nonnull
    private String tabName;

    @FXML
    private TextArea editor;

    private Tab tab;

    @Override
    public void initUI() {
        tab = new Tab(tabName);
        tab.setId(getMvcGroup().getMvcId());
        tab.setContent(loadFromFXML());
        parentView.getTabGroup().getTabs().add(tab);

        model.getDocument().addPropertyChangeListener("contents", (e) -> editor.setText((String) e.getNewValue()));

        editor.textProperty().addListener((observable, oldValue, newValue) ->
            model.getDocument().setDirty(!Objects.equals(editor.getText(), model.getDocument().getContents())));
    }

    public TextArea getEditor() {
        return editor;
    }

    @Override
    public void mvcGroupDestroy() {
        runInsideUISync(() -> parentView.getTabGroup().getTabs().remove(tab));
    }
}

And the corresponding FXML file for this View

griffon-app/resources/editor/editor.fxml
<?xml version="1.0" encoding="UTF-8"?>
<!--

    SPDX-License-Identifier: Apache-2.0

    Copyright 2008-2021 the original author or authors.

    Licensed under the Apache License, Version 2.0 (the "License");
    you may not use this file except in compliance with the License.
    You may obtain a copy of the License at

        http://www.apache.org/licenses/LICENSE-2.0

    Unless required by applicable law or agreed to in writing, software
    distributed under the License is distributed on an "AS IS" BASIS,
    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    See the License for the specific language governing permissions and
    limitations under the License.

-->
<?import javafx.scene.control.ScrollPane?>
<?import javafx.scene.control.TextArea?>
<ScrollPane maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="400.0"
            prefWidth="600.0" xmlns:fx="http://javafx.com/fxml/1" xmlns="http://javafx.com/javafx/8"
            fx:controller="editor.EditorController">
    <TextArea prefHeight="406.0" prefWidth="600.0" fx:id="editor"/>
</ScrollPane>

Take special note in the usages of the parentView field. Notice that the type is set to ContainerView, this means this View is aware that it’s not a top level MVC group, that is, there will be another MVC group that is responsible for instantiating editor, in this case it is the container MVC group. A parent/child relationship is established between the two MVC group instances when this happens. More information about this relationship can be found at the guide.

Because this View is responsible for attaching the tab when ready it should also be responsible for detaching said tab when the group is no longer in use. We’ll use the mvcGroupDestroy() lifecycle method to achieve this goal. Also, the tab component saves the group’s identifier into a client property. This is the link that the ContainerModel uses to switch between active tabs.

Last but not least, we update the EditorController to take care of loading the document, saving any changes and closing the tab.

griffon-app/controllers/editor/EditorController.java
package editor;

import griffon.core.artifact.GriffonController;
import griffon.core.controller.ControllerAction;
import griffon.inject.MVCMember;
import griffon.metadata.ArtifactProviderFor;
import org.codehaus.griffon.runtime.core.artifact.AbstractGriffonController;

import javax.annotation.Nonnull;
import java.io.IOException;
import java.util.Map;

import static org.apache.commons.io.FileUtils.readFileToString;
import static org.apache.commons.io.FileUtils.writeStringToFile;

@ArtifactProviderFor(GriffonController.class)
public class EditorController extends AbstractGriffonController {
    @MVCMember @Nonnull
    private EditorModel model;
    @MVCMember @Nonnull
    private EditorView view;

    @Override
    public void mvcGroupInit(@Nonnull Map<String, Object> args) {
        model.setDocument((Document) args.get("document"));
        runOutsideUI(() -> {
            try {
                final String content = readFileToString(model.getDocument().getFile());
                runInsideUIAsync(() -> model.getDocument().setContents(content));
            } catch (IOException e) {
                getLog().warn("Can't open file", e);
            }
        });
    }

    @ControllerAction
    public void saveFile() {
        try {
            writeStringToFile(model.getDocument().getFile(), view.getEditor().getText());
            runInsideUIAsync(() -> model.getDocument().setContents(view.getEditor().getText()));
        } catch (IOException e) {
            getLog().warn("Can't save file", e);
        }
    }

    @ControllerAction
    public void closeFile() {
        destroyMVCGroup(getMvcGroup().getMvcId());
    }
}

Invoking the closeFile() action triggers the destruction of this MVC group, which in turns calls the mvcGroupDestroy() lifecycle method on all MVC instances, such as the View; this will automatically remove the tab from the tab container as expected.

Don’t forget to add the following dependency to build.gradle!

compile 'commons-io:commons-io:2.5'

Top

5. Instantiating the Editor MVC group

We go back to the ContainerController to add some more behavior to it. Now that we know the EditorView expects a ContainerView as parent it makes sense for the ContainerController to instantiate the editor group. We’ll do this in the open action.

griffon-app/controllers/editor/ContainerController.java
public void open() {
    File file = view.selectFile();
    if (file != null) {
        String mvcIdentifier = file.getName() + "-" + System.currentTimeMillis();
        createMVC("editor", mvcIdentifier, CollectionUtils.<String, Object>map()
            .e("document", new Document(file, file.getName()))
            .e("tabName", file.getName()));
    }
}

Now the pieces start to fall into their rightful place. Because the editor MVC group takes care of attaching and detaching itself the code in the container controller is much simpler, it just needs to create a new instance of the editor group and that’s it!

Top

6. Finishing up the main MVC group

We’re ready to make the final adjustments to this application, by adding the missing behavior to the ContainerController, that is, the code for save and close actions.

griffon-app/controllers/editor/ContainerController.java
package editor;

import griffon.core.artifact.GriffonController;
import griffon.core.controller.ControllerAction;
import griffon.inject.MVCMember;
import griffon.metadata.ArtifactProviderFor;
import griffon.transform.Threading;
import griffon.util.CollectionUtils;
import org.codehaus.griffon.runtime.core.artifact.AbstractGriffonController;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.File;

import static griffon.util.GriffonNameUtils.isNotBlank;

@ArtifactProviderFor(GriffonController.class)
public class ContainerController extends AbstractGriffonController {
    @MVCMember @Nonnull
    private ContainerModel model;
    @MVCMember @Nonnull
    private ContainerView view;

    @ControllerAction
    @Threading(Threading.Policy.SKIP)
    public void open() {
        File file = view.selectFile();
        if (file != null) {
            String mvcIdentifier = file.getName() + "-" + System.currentTimeMillis();
            createMVC("editor", mvcIdentifier, CollectionUtils.<String, Object>map()
                .e("document", new Document(file, file.getName()))
                .e("tabName", file.getName()));
        }
    }

    @ControllerAction
    public void save() {
        EditorController controller = resolveEditorController();
        if (controller != null) {
            controller.saveFile();
        }
    }

    @ControllerAction
    public void close() {
        EditorController controller = resolveEditorController();
        if (controller != null) {
            controller.closeFile();
        }
    }

    @ControllerAction
    public void quit() {
        getApplication().shutdown();
    }

    @Nullable
    private EditorController resolveEditorController() {
        if (isNotBlank(model.getMvcIdentifier())) {
            return getApplication().getMvcGroupManager()
                .findController(model.getMvcIdentifier(), EditorController.class);
        }
        return null;
    }
}

Here we see another interesting feature of the Griffon runtime. Every instance of an MVC group is tracked by the MVCGroupManager, this means we can search for a group as long as we know its id. This is why we store the selected mvcIdentifier in ContainerModel. The save and close actions are global, they are defined by the container group, but they must act upon a special context, the selected tab handled by an instance of the editor group. We accomplish this task by remembering the group id associated with a tab whenever its selected; this id is used to find the correct editor group instance and perform actions on it.

The full code for this application can be found link:https://github.com/griffon/griffon/tree/ development_2_x/samples/editor-javafx-java[here, window="_blank"].

Top