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.

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, Lazybones and Gradle already installed on your system, execute the following command on a console prompt, paying attention to the selections we’ve made

$ lazybones create griffon-swing-java editor
Creating project from template griffon-swing-java (latest) in 'editor'
Define value for 'group' [org.example]: editor
Define value for 'artifactId' [editor]:
Define value for 'version' [0.1.0-SNAPSHOT]:
Define value for 'griffonVersion' [{jbake-griffon_version_current}]:
Define value for 'package' [editor]:
Define value for 'className' [Editor]: container

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

$ gradle 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 {
    public 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 org.codehaus.griffon.runtime.swing.artifact.AbstractSwingGriffonView;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.swing.JComponent;
import javax.swing.JFileChooser;
import javax.swing.JFrame;
import javax.swing.JMenu;
import javax.swing.JMenuBar;
import javax.swing.JMenuItem;
import javax.swing.JTabbedPane;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import java.awt.BorderLayout;
import java.awt.Image;
import java.awt.Toolkit;
import java.awt.Window;
import java.io.File;
import java.util.Collections;
import java.util.Map;

import static griffon.util.GriffonApplicationUtils.isMacOSX;
import static java.util.Arrays.asList;
import static javax.swing.WindowConstants.DO_NOTHING_ON_CLOSE;

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

    private JTabbedPane tabGroup;
    private JFileChooser fileChooser;

    public JTabbedPane getTabGroup() {
        return tabGroup;
    }

    @Override
    public void initUI() {
        JFrame window = (JFrame) getApplication()
            .createApplicationContainer(Collections.<String, Object>emptyMap());
        window.setName("mainWindow");
        window.setTitle(getApplication().getConfiguration().getAsString("application.title"));
        window.setSize(480, 320);
        window.setDefaultCloseOperation(DO_NOTHING_ON_CLOSE);
        window.setIconImage(getImage("/griffon-icon-48x48.png"));
        window.setIconImages(asList(
            getImage("/griffon-icon-48x48.png"),
            getImage("/griffon-icon-32x32.png"),
            getImage("/griffon-icon-16x16.png")
        ));
        getApplication().getWindowManager().attach("mainWindow", window);

        fileChooser = new JFileChooser();

        Map<String, Action> actionMap = getApplication().getActionManager().actionsFor(controller);
        Action saveAction = actionMap.get("save");
        model.getDocumentModel().addPropertyChangeListener("dirty", (e) -> saveAction.setEnabled((Boolean) e.getNewValue()));

        JMenu fileMenu = new JMenu("File");
        fileMenu.add(new JMenuItem((javax.swing.Action) actionMap.get("open").getToolkitAction()));
        fileMenu.add(new JMenuItem((javax.swing.Action) actionMap.get("close").getToolkitAction()));
        fileMenu.addSeparator();
        fileMenu.add(new JMenuItem((javax.swing.Action) actionMap.get("save").getToolkitAction()));
        if (!isMacOSX()) {
            fileMenu.addSeparator();
            fileMenu.add(new JMenuItem((javax.swing.Action) actionMap.get("quit").getToolkitAction()));
        }
        JMenuBar menuBar = new JMenuBar();
        menuBar.add(fileMenu);
        window.setJMenuBar(menuBar);

        window.getContentPane().setLayout(new BorderLayout());
        tabGroup = new JTabbedPane();
        tabGroup.addChangeListener(new ChangeListener() {
            @Override
            public void stateChanged(ChangeEvent e) {
                JTabbedPane tabbedPane = (JTabbedPane) e.getSource();
                int selectedIndex = tabbedPane.getSelectedIndex();
                if (selectedIndex < 0) {
                    model.setMvcIdentifier(null);
                } else {
                    JComponent tab = (JComponent) tabbedPane.getComponentAt(selectedIndex);
                    model.setMvcIdentifier((String) tab.getClientProperty(ContainerModel.MVC_IDENTIFIER));
                }
            }
        });
        window.getContentPane().add(tabGroup, BorderLayout.CENTER);
    }

    @Nullable
    public File selectFile() {
        Window window = (Window) getApplication().getWindowManager().getStartingWindow();
        int result = fileChooser.showOpenDialog(window);
        if (JFileChooser.APPROVE_OPTION == result) {
            return new File(fileChooser.getSelectedFile().toString());
        }
        return null;
    }

    private Image getImage(String path) {
        return Toolkit.getDefaultToolkit().getImage(ContainerView.class.getResource(path));
    }
}

Here we find a Window object containing a JMenuBar and a tab container (a JTabbedPane) 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 JFileChooser that will be used to select files for reading. Notice the conditional enabling of the save action given the state of the dirty property coming from model.documentModel. Also, the view registers an anonymous javax.swing.event.ChangeListener to listen to tab selection changes and update the documentModel property found in the model.

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.

griffon-app/i18n/messages.properties
editor.ContainerController.action.Save.accelerator = meta S
editor.ContainerController.action.Save.mnemonic = S
editor.ContainerController.action.Open.accelerator = meta O
editor.ContainerController.action.Open.mnemonic = O
editor.ContainerController.action.Close.accelerator = meta W
editor.ContainerController.action.Close.mnemonic = W
editor.ContainerController.action.Quit.accelerator = meta Q
editor.ContainerController.action.Quit.mnemonic = Q

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. But you can also let Lazybones generate these files for you. Lazybones ability to generate additional files well after the project’s creation is what sets it apart from Maven’s archetype solution. Lazybones has a feature called subtemplates or generators; these are regular template files with the added distinction that they have been hidden inside the project’s sources (actually inside .lazybones, local to the project). Unfortunately as of version 0.8 Lazybones does not have a command that can list existing subtemplates, however Griffon only exposes one by default, its name is artifact.

Armed with this information we can generate new artifacts by invoking the following command

$ lazybones generate artifact

The following artifact templates are available

  controller
  integrationspec
  integrationtest
  model
  service
  spec
  test
  view
  mvcgroup

Which type of artifact do you want to generate? mvcgroup
Define value for 'package' [editor]:
Define value for 'class' name: editor
Created new artifact griffon-app/models/editor/EditorModel.java
Created new artifact griffon-app/views/editor/EditorView.java
Created new artifact griffon-app/controllers/editor/EditorController.java
Created new artifact src/test/java/editor/EditorControllerTest.java
Created new artifact src/integration-test/java/editor/EditorIntegrationTest.java
Do not forget to add the group 'editor' to griffon-app/conf/Config.java

We select the mvcgroup option. Notice that Lazybones remembered the default package we used when we created the application. We select editor as the name of the new MVC group. There are 5 new files generated using the default templates. Take special care of the last message; the files were created but this doesn’t mean the application will be able to instantiate the MVC group at runtime. 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.Arrays.asList;

public class Config extends AbstractMapResourceBundle {
    @Override
    protected void initialize(@Nonnull Map<String, Object> entries) {
        map(entries)
            .e("application", map()
                    .e("title", "editor-swing-java")
                    .e("startupGroups", asList("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?

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

import griffon.core.artifact.GriffonView;
import griffon.inject.MVCMember;
import griffon.metadata.ArtifactProviderFor;
import org.codehaus.griffon.runtime.swing.artifact.AbstractSwingGriffonView;

import javax.annotation.Nonnull;
import javax.swing.JScrollPane;
import javax.swing.JTabbedPane;
import javax.swing.JTextArea;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import java.util.Objects;

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

    private JScrollPane tab;
    private JTextArea editor;

    @Override
    public void initUI() {
        editor = new JTextArea();
        editor.setEditable(true);
        editor.setEnabled(true);

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

        tab = new JScrollPane();
        tab.putClientProperty("mvcIdentifier", getMvcGroup().getMvcId());
        tab.setViewportView(editor);

        editor.getDocument().addDocumentListener(new DocumentListener() {
            @Override
            public void insertUpdate(DocumentEvent e) {
                updateDirtyStatus();
            }

            @Override
            public void removeUpdate(DocumentEvent e) {
                updateDirtyStatus();
            }

            @Override
            public void changedUpdate(DocumentEvent e) {
                updateDirtyStatus();
            }

            private void updateDirtyStatus() {
                model.getDocument().setDirty(!Objects.equals(editor.getText(), model.getDocument().getContents()));
            }
        });

        JTabbedPane tabGroup = parentView.getTabGroup();
        tabGroup.addTab(tabName, tab);
        tabGroup.setSelectedIndex(tabGroup.getTabCount() - 1);
    }

    public JTextArea getEditor() {
        return editor;
    }

    @Override
    public void mvcGroupDestroy() {
        parentView.getTabGroup().remove(tab);
    }
}

Besides the convoluted binding on the textArea's text property (unfortunately text is not a bound property) the other special pieces of code found in this class are the usages of the parentView field. Notice the 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.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);
            }
        });
    }

    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);
        }
    }

    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.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.isBlank;

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

    @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()));
        }
    }

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

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

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

    @Nullable
    private EditorController resolveEditorController() {
        if (!isBlank(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 here.

Top