Ein groovy Vaadin Builder

Vaadin GUI einfach zusammenbauen

Übersicht

Baumartige Daten- und Objektstrukturen lassen sich in Groovy mit sogenannten Buildern relativ einfach konstruieren. Die Grundlage davon ist, dass Groovy Methodenaufrufe über das Meta Object Protocol abfangen und behandeln kann. Damit können Pseudo-Methoden-Aufrufe behandelt werden, die nicht zu einer gleichnamigen Methode führen, sondern auf Basis des Methodennamens ein neues Objekt erzeugen. In ein paar Folien (GroovyMetaprog.pdf) habe ich einige Grundlagen der Metaprogrammierung zusammengefasst. Builder werden ab Folie 18 behandelt. Groovy stellt mehrere Klassen zur Verfügung, mit denen man eigene Builder implementieren kann, speziell BuilderSupport und FactoryBuilderSupport. Im Web findet man dazu auch einige Tutorials.

Der hier vorgestellte VaadinBuilder ist eine Subklasse von BuilderSupport und implementiert einige spezielle Features, die den Aufbau komplexer GUIs mit Vaadin erleichtern sollen:

  • Alle unterstützten Vaadin Komponenten sind in zwei enums definiert. Damit ist eine code completion Unterstützung in modernen IDEs gegeben.
    • enum C enthält Vaadin Container, enum F die Feld-Komponenten
    • mit import static de.geobe.util.vaadin.VaadinBuilder.C (bzw. F) kann man unter Verwendung der Groovy Methodensyntax object."methodenname"(...) und GStrings auf die Builder-Pseudomethoden [z.B. builder."$C.hlayout" (...) { ... }] zugreifen. Tippfehler sind dadurch weitgehend ausgeschlossen. Beispiele gibt es weiter unten.
  • Alle angelegten Komponenten werden unter ihrem Namen in einer Map abgelegt. Wenn kein Name vergeben wird, werden sie automatisch durchnummeriert (label1, label2, …).  Die Namen können mit einem Präfix versehen werden, um Komponenten verschiedener GUI-Bereiche (z.B. verschiedene Tab Seiten) einfach auseinanderhalten zu können.
  • Es können verschiedene Teilbäume von Komponenten separat erstellt und zentral zusammengefügt werden. Damit lassen sich komplexe GUIs leicht auf mehrere Klassen aufteilen (z.B. eine Klasse pro Tab Seite).
  • Vaadin Menus (MenuBar und MenuBar.MenuItem Objekte) lassen sich im Builder in die GUI integrieren.
  • Alle setXxx() und addXxx() Methoden mit keinem oder einem Parameter der Vaadin Komponenten können über den Builder angesprochen werden. Event Handler werden als Closures hinzugefügt. Damit fällt zusätzlicher Konfigurationscode weitgehend weg.

Verwendung

Beginnen wir mit einem einfachen Beispiel. Hier legen wir ein Dialogfeld zur Erfassung neuer Tasks an:

class SubtaskDialog {
    TextField tag, estimate, spent
    TextArea description
    CheckBox supertask, completed
    Button saveButton, cancelButton
    Window window

    private VaadinBuilder winBuilder = new VaadinBuilder()

    public Window build() {
        String keyPrefix = "${subkeyPrefix}dialog."
        winBuilder.keyPrefix = keyPrefix
        window = winBuilder."$C.window"('Subtask anlegen', [spacing: true, 
                                                            margin : true,
                                                            modal  : true,
                                                           closable: false]) {
            "$C.vlayout"('top', [spacing: true, margin: true]) {
                "$F.text"('Aufgabe', [uikey: TAG])
                "$C.hlayout"('Status', [spacing: true, margin: false]) {
                    "$F.checkbox"('übergeordnet', [uikey: IS_SUPERTASK])
                    "$F.checkbox"('abgeschlossen', [uikey: IS_COMPLETED])
                }
                "$F.text"('Schätzung', [uikey: ESTIMATE])
                "$F.text"('aktueller Verbrauch', [uikey: SPENT])
                "$F.textarea"('Beschreibung', [uikey: DESCRIPTION])
                "$C.hlayout"([uikey: 'buttonfield', spacing: true]) {
                    "$F.button"('Cancel', [uikey         : 'cancelbutton', 
                                           disableOnClick: true,
                                           enabled       : true,
                                           clickListener : {
                                               sm.execute(Event.Cancel) }])
                    "$F.button"('Save', [uikey         : 'savebutton', 
                                         disableOnClick: true,
                                         enabled       : true,
                                         clickListener : {
                                             sm.execute(Event.Save) }])
                }
            }
        }

        def dialogComponents = winBuilder.uiComponents
        tag = dialogComponents."$keyPrefix$TAG"
        estimate = dialogComponents."$keyPrefix$ESTIMATE"
        spent = dialogComponents."$keyPrefix$SPENT"
        description = dialogComponents."$keyPrefix$DESCRIPTION"
        completed = dialogComponents."$keyPrefix$IS_COMPLETED"
        supertask = dialogComponents."$keyPrefix$IS_SUPERTASK"
        saveButton = dialogComponents."${keyPrefix}savebutton"
        cancelButton = dialogComponents."${keyPrefix}cancelbutton"
        window.center()
    }
}

Gehen wir die einzelnen Bereiche der Klasse durch:

  • [Zeile 2 – 6]
    Feldvariablen werden für alle relevanten Komponenten angelegt, auf die wir zugreifen wollen. Groovy stellt automatisch Zugriffsfunktionen bereit, so dass auch aus anderen Klassen z.B. auf die erfassten Werte zugegriffen werden kann. Durch den Modifier private oder protected kann dies verhindert werden
  • [Zeile 8]
    In diesem Beispiel wird ein neuer Builder nur für dieses Dialogfenster angelegt. Später werden wir sehen, dass Builder an verschiedene Klassen „durchgereicht“ werden können, um eine umfassendere GUI aufzubauen.
  • [Zeile 11]
    Der keyPrefix wäre hier nicht unbedingt notwendig, da der Builder nur lokal verwendet wird.
  • [ab Zeile 13]
    Dann wird die GUI zusammengebaut. Die Build Pseudomethode bekommt in der Regel folgende Parameter, die alle optional sind:

    • Einen String (caption), der bei den meisten Vaadin-Komponenten als Beschriftung verwendet wird.
    • Eine Map mit key/value Paaren, über die die Komponente konfiguriert wird.
    • Für Container-Komponenten einen Code-Block (also eine Closure), in dem die enthaltenen Komponenten angelegt werden.
  • Die keys in der Map entsprechen in der Regel set… oder add… Methoden der entsprechenden Komponente, wie sie in der Vaadin-API definiert sind. Es gibt aber auch ein paar Ausnahmen.
    • modal: true in Zeile 15 ruft die Methode setModal(true) der Klasse Window auf
    • clickListener: { sm.execute(Event.Save) } ruft die Methode addClickListener(…) der Klasse Button auf und übergibt die Closure als Parameter.
    • Ausnahmen und Besonderheiten:
       
      /** key of the component in the component map */
      public static final String UIKEY = 'uikey'
      /** gridPosition is set in the parent component */
      public static final String GRID_POSITION = 'gridPosition'
      /** alignment is set in the parent component */
      public static final String ALIGNMENT = 'alignment'
      
      • uikey gibt der Komponente einen Namen für die uiComponents Map, unter dem sie angesprochen werden kann.
      • gridPosition und alignment sind bei Vaadin (anders als etwa bei Swing) Methoden des übergeordneten Containers. Sie werden im Builder bei der Komponente angegeben und automatisch beim Container aufgerufen.
      • Parameterlose set… Methoden (so etwas gibt es in Vaadin) müssen mit dem value null konfiguriert werden.
  • [Zeile 42]
    Die Window-Position lässt sich nicht über den Builder konfigurieren, da die Methode window.center()  nicht dem set… oder add… Muster entspricht.

Komplexe GUIs mit mehreren Teilbäumen

Mit subtree kann man mehrere Teilbäume von Komponenten zu einer komplexen GUI zusammenfügen. Dazu wird ein VaadinBuilder Objekt in einer Vaadin UI-Klasse angelegt und dann an weitere Klassen „durchgereicht“, die jeweils einen Teil der GUI aufbauen. Als Beispiel dient eine GUI, die in einem HorizontalSplitPanel links einen TreeView zeigt, rechts ein TabSheet mit mehreren Tabs.

va4spbuild01

Sowohl der TreeView als auch alle einzelnen Tabs sind jeweils in einer eigenen Klasse implementiert. Damit ist eine Separation of Concerns gut realisierbar, jede Klasse behandelt einen Aspekt der GUI.

Top Level UI Klasse

In der Klasse ScrumView.groovy werden die Komponenten-Teilbäume zusammengefügt und in die Container-Komponenten der obersten Ebene eingesetzt.

package de.fh_zwickau.pti.geobe.view

import com.vaadin.annotations.Theme
import com.vaadin.server.VaadinRequest
import com.vaadin.spring.annotation.SpringUI
import com.vaadin.ui.Component
import com.vaadin.ui.TabSheet
import com.vaadin.ui.UI
import de.geobe.util.vaadin.VaadinBuilder
import de.fh_zwickau.pti.geobe.util.VaadinSelectionKeyListener
import org.springframework.beans.factory.annotation.Autowired

import static VaadinBuilder.C
import static VaadinBuilder.F

/**
 * The main view class for Scrum UI
 * Created by georg beier on 16.11.2015.
 */
@SpringUI(path = "")
@Theme("valo")
class ScrumView extends UI implements VaadinSelectionKeyListener {

    def VaadinBuilder vaadin
    def widgets = [:]
    @Autowired
    private ProjectTab projectTab
    @Autowired
    private TaskTab taskTab
    @Autowired
    private SprintTab sprintTab
    @Autowired
    ProjectTree projectTree

    private Component root, projectSelectTree, 
            projectSubtree, sprintSubtree, taskSubtree

    @Override
    protected void init(VaadinRequest request) {
        setContent(initBuilder())
        initComponents()
    }

    /**
     * Aufbau des Vaadin Komponentenbaums
     *     "Äste" werden vor dem "Stamm" angelegt und mit subtree hinzugefügt
     * @return
     */
    Component initBuilder() {
        vaadin = new VaadinBuilder()
        projectSelectTree = projectTree.buildSubtree(vaadin, 'menutree.')
        projectSubtree = projectTab.buildSubtree(vaadin, 'project.')
        sprintSubtree = sprintTab.buildSubtree(vaadin, 'sprint.')
        taskSubtree = taskTab.buildSubtree(vaadin, 'task.')

        root = vaadin."$C.hsplit"([uikey: 'topsplit', splitPosition: 20.0f]) {
            "$F.subtree"(projectSelectTree, [uikey: 'menu'])
            "$C.tabsheet"([uikey: 'tabs']) {
                "$F.subtree"(projectSubtree, [uikey: 'projectpanel'])
                "$F.subtree"(sprintSubtree, [uikey: 'sprintpanel'])
                "$F.subtree"(taskSubtree, [uikey: 'taskpanel'])
            }
        }
        widgets = vaadin.uiComponents
//        def wtree = vaadin.toString()
//        println wtree
        root
    }

    private initComponents(){
        // untergeordnete views erst nach Zusammenbau der ganzen UI initialisieren
        projectTree.init()
        projectTab.init()
        sprintTab.init()
        taskTab.init()
        projectTree.selectionModel.addKeyListener(this)
    }
/**
 * is fired when an entry of a selection component was selected
 * @param event id of the selected element, normally its domain class itemId
 */
    @Override
    void onItemKeySelected(Map<String, Serializable> itemId) {
        TabSheet tabs = widgets['tabs']
        switch (itemId['type']) {
            case 'Project':
            case 'project':
                tabs.selectedTab = projectSubtree
                break
            case 'Task':
            case 'task':
                tabs.selectedTab = taskSubtree
                break
            case 'Sprint':
            case 'sprint':
                tabs.selectedTab = sprintSubtree
                break
        }
    }
}

Erläuterungen zum Code:

20 – 22
Die Top Level UI Klasse ist eine normale Vaadin UI Klasse. Entsprechend werden der URL-Path und das Theme annotiert und die Klasse von UI abgeleitet.
26 – 33
Die untergeordneten GUI-Komponenten werden als managed beans angelegt und über @Autowire eingebunden. Dadurch werden die Objekte von Spring verwaltet und können miteinander kommunizieren.
39 – 42
Die Vaadin init() Methode baut erst den Komponentenbaum auf und initialisiert anschließend die untergeordneten GUI-Komponenten mit den einzelnen Teilbäumen.
50
Ein VaadinBuilder für den gesamten Komponentenbaum wird angelegt.
51 – 54
Die Teilbäume werden aufgebaut. Dazu implementieren sie eine Methode buildSubtree(…), die den Builder und einen Prefix-String erhält. Der Prefix garantiert, dass die Komponenten der Teilbäume unterschiedliche Namen (d.h. keys) in der components-Map erhalten. Damit wird ein einfaches Äquivalent von namespaces realisiert.
56 – 61
Hier wird die GUI zusammengebaut: ein SplitPanel enthält links die Tree-Komponente, rechts ein TabShhet mit weiteren drei Teilbäumen als Tabs.
64-66
Die vom Builder angelegten Komponenten werden in einer Map (widgets) gespeichert. Die Testausgabe, die den Komponentenbaum als ASCII-Grafik ausgibt, ist auskommentiert. Zur Überprüfung des Aufbaus der GUI kann diese Ausgabe hilfreich sein. [TODO: die Methode sollte umbenannt werden, um Fehlermeldungen im Debugger zu vermeiden]
70
Jetzt können die untergeordneten Objekte initialisiert werden.
76, 83 …
Hier ist die Callback-Methode des VaadinSelectionKeyListener Interface implementiert: Der KeyListener wird registriert und implementiert. Reaktionen auf ein Click-Ereignis im Tree führen ggf. zum Wechsel des ausgewählten Tabs.

Basisklasse zur Implementierung der Komponenten-Teilbäume

Als Basisklasse für die Teilbäume kann die Klasse SubTree.groovy verwendet werden, die gemeinsame Funktionalität zusammenfasst und eine Struktur vorgibt.

package de.geobe.util.vaadin

import com.vaadin.ui.Component
import com.vaadin.ui.UI

/**
 * A base class for building Vaadin component subtrees with VaadinBuilder
 * Created by georg beier on 16.11.2015.
 */
abstract class SubTree {
    /**
     * builder is configured here and used in subclasses
     */
    protected VaadinBuilder vaadin
    /**
     * make prefix available for accessing components in the subclasses
     */
    protected String subkeyPrefix
    protected def uiComponents

    /**
     * set component prefix in builder, delegate building subtree to subclass
     * and reset prefix afterwards.
     * @param builder   VaadinBuilder instance that builds the whole GUI
     * @param componentPrefix   name prefix for components in this subtree
     * @return  topmost component (i.e. root) of this subtree
     */
    Component buildSubtree(VaadinBuilder builder, String componentPrefix) {
        this.vaadin = builder
        def oldKeyPrefix = builder.getKeyPrefix()
        subkeyPrefix = oldKeyPrefix + componentPrefix
        builder.setKeyPrefix subkeyPrefix
        Component component = build()
        builder.setKeyPrefix oldKeyPrefix
        component
    }

    /**
     * build component subtree.
     * @return  topmost component (i.e. root) of subtree
     */
    abstract Component build()

    /**
     * initialize subtree components. should be called after whole component tree is built.
     * call sequence of different subtrees may be important.
     * @param value various parameters needed for initialization
     */
    void init(Object... value) {}

    protected setComponentValue(String id, Object value) {
        uiComponents."${subkeyPrefix + id}".value = value.toString()
    }

    protected setComponentValue(String id, Boolean value) {
        uiComponents."${subkeyPrefix + id}".value = value
    }

    protected UI getVaadinUi(Component c) {
        Component parent = c?.parent
        if(parent instanceof UI) {
            parent
        } else {
            getVaadinUi(parent)
        }
    }

    protected Long longFrom(String val) {
        try {
            new Long(val)
        } catch(NumberFormatException e) {
            0L
        }
    }
}

Erläuterungen zum Code:

28
Diese Methode setzt einen Präfix im VaadinBuilder, der vor alle Komponentennamen gestellt wird. Damit ist eine Art Namensraumverwaltung für die Teilbäume leicht zu realisieren. Der Aufbau des Teilbaums wird an Subklassen delegiert. Anschließend wird der Namenspräfix zurückgesetzt.
33 und 42
Hier erfolgt der eigentliche build() Aufruf, der in der Subklasse realisiert werden muss.
49
Die Initialisierungsmethode init(…) kann in der Regel erst aufgerufen werden, wenn alle Teilbäume aufgebaut sind. Hier können z.B.  Listener auf Komponenten in anderen Teilbäumen angelegt werden.

Aufbau einer Tab Seite als Beispiel für einen Komponenten-Teilbaum

Die Klasse ProjectTree.groovy verwaltet die in der linken Hälfte der GUI angeordnete Tree Darstellung der Domain-Klassen.

import ...

import static de.geobe.util.vaadin.VaadinBuilder.C
import static de.geobe.util.vaadin.VaadinBuilder.F

/**
 * Main selection component is this tree view that represents relevant
 * objects and associations in the domain model
 * Created by georg beier on 17.11.2015.
 */
@SpringComponent
@UIScope
class ProjectTree extends SubTree
        implements Serializable {

    public static final String PROJECT_TYPE = 'Project'
    public static final String SPRINT_TYPE = 'Sprint'
    public static final String TASK_TYPE = 'Task'

    private static final String PTREE = 'ptree'
    private static final String MENU = 'logoutmenu'
    private Tree projectTree
    private VaadinTreeHelper treeHelper

    private uiComponents

    private Map<String, Serializable> selectedProjectId

    def getSelectedProjectId() { selectedProjectId }

    @Autowired
    private ProjectService projectService

    @Autowired
    private VaadinSecurity vaadinSecurity

    VaadinSelectionModel selectionModel = new VaadinSelectionModel()

    @Override
    Component build() {
        vaadin."$C.vlayout"() {
            "$F.menubar"([uikey: MENU]) {
                "$F.menuitem"('Logout', [command: { vaadinSecurity.logout() }])
            }
            "$C.panel"('Projekte', [spacing: true, margin: true]) {
                "$F.tree"('Projekte, Backlogs und Sprints',
                        [uikey: PTREE, caption: 'MenuTree',
                        valueChangeListener: {treeValueChanged(it)}])
            }
        }
    }

    @Override
    void init(Object... value) {
        uiComponents = vaadin.uiComponents
        projectTree = uiComponents."${subkeyPrefix + PTREE}"
        treeHelper = new VaadinTreeHelper(projectTree)
        buildTree(projectTree)
    }

    /**
     * handle changes of tree selection
     * @param event info on the newly selected tree item
     */
    private void treeValueChanged(Property.ValueChangeEvent event) { ... }

    /**
     * build a tree representing the domain model
     * @param projectTree
     */
    private void buildTree(Tree projectTree) { ... }

    /**
     * disable the tree while a tree item is edited on one of the tab pages
     */
    public void onEditItem() { ... }

    /**
     * enable and update the tree after editing an item
     * @param itemId identifies edited item
     * @param caption eventually updated caption of the edited item
     * @param mustReload tree must reload after new item was created 
     *        or structure changed
     */
    public void onEditItemDone(Object itemId, String caption, boolean mustReload = false) { ... }
}

Erläuterungen zum Code:

22, 23
Die Klasse ist als @SpringComponent annotiert und damit als Bean verfügbar. Der Bean Scope wird mit @UIScope auf die Vaadin Session begrenzt, d.h. zu jeder VaadinSession gibt es genau eine Bean dieses Typs, die mit @Autowired gebunden werden kann.
51
Hier wird der Teilbaum aufgebaut.
53-55
Über dem Tree wird noch ein VaadinMenu mit einem Logout-Eintrag aufgebaut.
59, 76
Die Listener-Definition für den Tree erfolgt direkt im Builder. Die umfangreichere Implementierung wird an die Methode treeValueChanged(…) delegiert.
65
Die init() Methode ist recht kurz, weil die meiste Konfiguration im Builder stattgefunden hat bzw. an das TreeHelper Objekt delegiert wird.
76, 82, 87, 96
Diese Methoden sind auf einer eigenen Seite im Zusammenhang mit der Nutzung von Vaadin Tree Objekten beschrieben.

Komponenten zum VaadinBuilder hinzufügen

Für Vaadin gibt es sehr viele GUI Komponenten, freie und kostenpflichtige. Außerdem ist es manchmal notwendig, vorhandene GUI-Klassen zu erweitern, beispielsweise um das Interface View für einen Navigator zu implementieren. Dazu können neue Felder und Container mit den Methoden addCustomField(String name, String fqn) bzw. addCustomComponent(String name, String fqn) hinzugefügt werden.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.