Der levigo-utils GUI-Context

Bei der Entwicklung von grafischen Benutzeroberflächen stößt man, sobald die Komplexität der Oberfläche zunimmt, schnell auf ein grundlegendes Problem: Wie vermeide ich es, daß der Aufbau der Oberfläche zu eng mit dem Design der Anwendung verwoben wird? Oder salopp gesagt: Wie vermeidet man, daß die Verdrahtung der Komponenten untereinander zu einem unentwirrbaren Verhau wird, der ein Refactoring praktisch unmöglich macht?

Die levigo utils - genauer gesagt das Maven-Paket com.levigo.util:swing - bieten eine Lösung für dieses "klassische" Problem: den GUI-Kontext com.levigo.util.swing.action.Context. Der Context ist seit langer Zeit Teil der levigo-utils und wird z.B. in der jadice document platform intensiv genutzt. In diesem Artikel beschreiben wir zunächst die Funktionsweise und Möglichkeiten des GUI-Kontexts um diese dann in einer einer Beispielanwendung in der Praxis zu demonstrieren.

Motivation

Steigen wir zunächst mit einem simplen Gedankenexperiment ein: Wir wollen eine Anwendung entwicklen, die eine Liste (z.B: mit einer JList) darstellt, sowie einen Button (oder ein Toolbar-Icon) um diese Liste zu aktualisieren. Die Verkabelung ist denkbar einfach: Der Button (bzw. die Aktion, die bei der Betätigung des Buttons aufgerufen wird) muss die Liste kennen.
Zunächst sind unsere Anwender mit dieser einfachen Anwendung zufrieden. Schnell aber kommt die Forderung auf, mehrere Listen in einer Reiteransicht (z.B. JTabbedPane) verwalten zu können. Nun wird die Verkabelung schon komplexer, denn, sofern wir nicht jedem Reiter einen eigenen "Aktualisieren"-Knopf geben wollen - was z.B. bei einer fensterweiten Toolbar oder Menüleiste nicht möglich ist, muss nun die Aktion prüfen, welche Liste gerade im Vordergrund ist und aktualisiert werden soll. Sie muss sich also mit JTabbedPanes auskennen.
Die nächste Anforderung betrifft die Statusleiste: Diese soll jeweils die Anzahl der Elementen in der gerade aktiven Liste darstellen. Nun haben wir eine zweite Stelle, die sich mit der TabbedPane und der Frage der aktiven Liste befassen muss.
Man sieht: Wächst die Komplexität einer Oberfläche, wächst gleichzeitig die Anzahl der Abhängigkeiten schnell in Dimensionen, deren Handhabung zumindest zeitraubend wird. Es muss deshalb eine Lösung gefunden werden, die einerseits eine Entkopplung der Komponenten erzielt und andererseits eine Trennung der Zuständigkeiten erlaubt.

Grundlagen

In seiner einfachsten Verwendungsform kann man den Context zunächst als simple Kollektion betrachten, in der all diejenigen Objekte abgelegt werden, die im Zentrum des Interesses einer grafischen Benutzeroberfläche stehen. Dies können einerseits Geschäftsobjekte, mit denen gerade gearbeitet wird, sein, also z.B. ein Dokument, eine Datei oder Ähnliches. Andererseits können aber auch GUI-Komponenten selbst ins Blickfeld rücken, nämlich dann, wenn sie in irgendeiner Form gesteuert werden sollen oder Daten liefern können, die an anderer Stelle benötigt werden.
Den Kontext als Kollektion zu betrachten ist keine metaphorische Vereinfachung: In der Tat implementiert der Context das Interface Collection<Object> weshalb der Inhalt des Kontextes z.B. sehr einfach in eigene Listen kopiert werden kann.

Kontexte sind dynamisch

GUIs sind in der Regel dynamisch, deshalb ist auch die Liste der Objekte in einem Kontext dynamisch: Komponenten, die sich für den Inhalt bzw. den Zustand des Kontextes interessieren, können sich durch die Registrierung eines ContextListener über Änderungen am Kontext informieren lassen und z.B. ihren eigenen Zustand entsprechend aktualisieren.
Die Änderung eines Kontextes kann dreierlei Bedeutung haben:

  • Der Inhalt des Context hat sich geändert, d.h. dem Kontext - oder einem verbundenen Kontext, siehe unten - wurden Elemente hinzugefügt oder entfernt. Hinzufügen und Entfernen von Elementen sowie das Leeren des Kontextes führen automatisch zur Propagation entsprechender ContextChanged-Events.
  • Der Zustand eines Elements im Kontext hat sich geändert. Konsumenten des Kontextes interessieren sich unter Umständen nicht nur dafür welche Objekte im Kontext enthalten sind, sondern auch für deren Zustand. Der Context bietet keine automatische Unterstützung für die Verfolgung von Änderungen des Zustands von Elementen. Deshalb muss eine solche Änderung, sofern sie für Konsumenten von Interesse sein könnte, über Context.contextChanged() bekannt gemacht werden. Liefert das Kontext-Element JavaBean-konforme PropertyChangeEvents, kann mit ContextUtils.bindBeanProperty(<context>, <bean>, <property name>) eine automatisierte Weitergabe von Änderungen installiert werden.
  • Der Zustand eines verbundenen Kontextes oder der Kontexthierarchie hat sich geändert. Die Funktion der Kontexthierarchie wird im nächsten Abschnitt beschrieben.

Kontexte sind hierarchisch

Die Elemente einer GUI bilden eine Hierarchie, weshalb es nicht weiter verwundern dürfte, daß auch Kontexte hierarchisch strukturiert werden können. Die Hierarchien, die GUI-Komponenten bilden, sind in der Regel sehr komplex und umfangreich, da sie den Eigenheiten und Bedürfnissen des GUI-Toolkits Rechnung tragen müssen. So existieren z.B. unter Swing oft Komponenten (z.B. JPanel) mit dem einzigen Zweck, einen bestimmten Rahmen (Border) darzustellen. Die Kontexthierarchie dagegen ist in der Regel sehr viel einfacher: sie orientiert sich am logischen Aufbau der Oberfläche. Einfache Dialoge ohne TabbedPanes, SplitPanes usw. kommen oft mit einem einzigen Kontext aus. Im Beispiel aus dem Abschnitt Motivation benötigen wir einen Kontext für das Anwendungsfenster (den Wurzelkontext) sowie für jeden Reiter einen weiteren Kontext. Aus technischen Gründen erhält auch die TabbedPane selbst noch einen eigenen Kontext, dieser tritt jedoch nicht weiter in Erscheinung.

Kontexte können aktiv oder inaktiv sein

Komponenten in grafischen Benutzeroberflächen können aktiv oder inaktiv sein. Was genau als aktive Komponente betrachtet wird, hängt vom Blickwinkel und den Erfordernissen der Anwendung ab. Bei einer TabbedPane ist jeweils nur ein einziger Tab aktiv, alle anderen Tabs sind inaktiv. In anderen Situationen kann es sinnvoll sein, dem Fokus der Anwendung zu folgen.

Kontexte erlauben es, das Konzept der Aktivität eines Zweiges der GUI auf die Kontexthierarchie zu übertragen. Sie besitzen hierzu die Property active(setActive(boolean)/isActive():boolean). Wird ein Kontext aktiviert oder deaktiviert hat dies Konsequenzen für die Aggregation der Elemente (siehe unten). In einigen Fällen wird die Aktivierung des Kontextes automatisch gehandhabt:

  • Bei TabbedPanes werden die Kindkontexte der Tabs automatisch so aktiviert bzw. deaktiviert, dass immer nur der Kontext des aktiven Tabs selbst aktiv ist.
  • Ist ein Kontext an eine Komponente gebunden, die den GUI-Fokus erhalten kann, folgt die Aktivierung des Kontextes automatisch der Fokussierung.

In allen anderen Fällen muss, falls dies benötigt wird, die Aktivierung der Kontexte manuell gesteuert werden.

Aggregation von Kontextelementen

Bilden Kontexte eine Hierarchie, können Elemente aus einem Kontext in verbundenen Kontexten sichtbar werden. Ein verbundener Kontext macht dadurch nicht mehr nur seine eigenen Elemente sichtbar, sondern auch die der anderen Kontexte. Das genaue Verhalten wird dabei über Aggregationsregeln gesteuert.Die Aggregation erfolgt entlang zweier Achsen:

  • Die ancestor-Aggregation schließt Elemente aus dem oder den übergeordneten Kontexten ein.
  • Die child-Aggregation schließt Elemente aus dem oder den untergeordneten Kontexten ein.

Folgende Arten von Aggregation gibt es:

  • Children
    • NONE: keinerlei Elemente aus Kindkontexten werden sichtbar
    • ALL: alle Elemente aus Kindkontexten, egal, ob diese aktiv sind oder inaktiv, werden sichtbar
    • ACTIVE: nur die Elemente aus aktiven Kindknoten werden sichtbar
  • Ancestors
    • NONE: keine Elemente aus übergeordneten Kontexten werden sichtbar
    • ALL: alle Elemente aus übergeordneten Kontexten werden sichtbar. Dies betrifft jedoch nur die direkte Linie zum Wurzelkontext, nicht aber z.B. Geschwisterkontexte.
    • PARENT: die Elemente des direkten Elternkontexts werden sichtbar
    • PARENT_WITH_AGGREGATION: die Elemente des direkten Elternkontextes sowie dessen aggregierte Elemente werden sichtbar. Dies kann also z.B. auch Geschwisterkontexte, Onkel usw. mit einschließen.

Verwenden von Kontexten

Kontexte sind in der Regel an GUI-Komponenten gebunden und werden oft direkt bei deren Erzeugung miterstellt. Wie bereits erwähnt ist es jedoch nicht notwendig und sinnvoll, jede GUI-Komponente mit einem eigenen Kontext auszustatten - es genügt, diese an "neuralgischen" Punkten zu platzieren.
Eine Ausnahme von der Regel, daß Kontexte immer an GUI-Komponenten gebunden sind, ist der Fall einer Anwendung mit mehreren Fenstern. In diesem Fall kann es sinnvoll sein, einen übergeordneten globalen Kontext zu etablieren, der die Verknüpfung mehrerer Fenster erlaubt.

Erstellen von Kontexten

Soll an eine Komponente ein Kontext gebunden werden, kann dies mit Context.install(JComponent,Children,Ancestors) erfolgen. Hierbei werden auch direkt die gewünschten Aggregationsmodi für die Kind- und Ahnenachsen festgelegt. Der hierbei installierte Kontext wird direkt zurückgegeben. Er kann aber jederzeit später auch per Context.get(<die Komponente>) wieder geholt werden.

Die Hierarchie der Kontexte wird automatisch anhand der GUI-Hierarchie gepflegt. Wird ein Kontext für eine neue Komponente erstellt, ist dieser zunächst nicht mit Eltern oder Kindern verbunden. Sobald die Komponente aber in die GUI-Hierarchie eingebunden wird, werden die richtigen Verwandschaftsbeziehungen automatisch verdrahtet.

Ein nicht an eine Komponente gebundener Kontext wird mit Context.create(Children,Ancestors) erstellt. So erstellte Kontexte nehmen aber nicht an der automatischen Verwaltung von Verwandschaftsbeziehungen teil - diese müssen deshalb manuell mit addChildContext(Context) gepflegt werden.

Beschaffen von Kontexten

Um Konsumenten mit Kontexten zu versorgen, können die erstellten Kontexte manuell gehalten und entsprechend weitergegeben werden. In vielen Fällen wird dies aber schnell lästig. Deshalb kann sich eine Komponente jederzeit mit Context.get(JComponent) denjenigen Kontext geben lassen, der für die Komponente zuständig ist. Die Methode hangelt sich dabei solange entlang der Komponentenhierarchie nach oben, bis sie eine Komponente findet, die einen Kontext hat.

Dieses Verhalten kann übrigens eine Motivation sein, einen Kontext für eine Komponente zu erstellen, die eigentlich keinen benötigen würde: soll eine Komponente zunächst unabhängig erstellt werden und einen Kontext bereitstellen, ohne bereits in die Hierarchie eingebunden zu sein, so kann sie mit einem eigenen Kontext ausgestattet werden, der später, bei der Einbindung in die Hierarchie, die Brücke zur Außenwelt darstellt. Ein Anwendungsbeispiel hierfür sind die Statusleisten-SnapIns des jadice viewers: jedes SnapIn hat einen eigenen Kontext, der später. wenn das SnapIn der Statusleiste hinzugefügt wird, automatisch eingebunden wird.

Die Beispielanwendung

Die Beispielanwendung illustriert das bisher Gesagte auf anschauliche Weise. Sie besteht aus einem Fenster mit mehreren Buttons (diese könnten auch als Werkzeugleiste ausgeführt sein), das eine TabbedPane mit einigen Reitern enthält. In den Kontexten werden zwei Arten von Objekten geführt:

  • JSlider dienen als Beispiel für GUI-Komponenten, die selbst Teil des Kontextes werden und z.B. der Bereitstellung von Informationen dienen. In der Praxis wird es oft nicht sinnvoll sein, direkt Standardkomponenten von Swing zu benutzen, da diese ggf. nicht ausreichend eindeutig von anderen Instanzen unterschieden werden können. Spezialisiertere Komponenten wie der PageView des jadice viewer haben sich aber uneingeschränkt für die Verwendung in Kontexten bewährt.
  • Geschäftsobjekte der Anwendung werden von der Klasse Thingy repräsentiert. Sie hat in unserem einfachen Beispiel keine wirkliche Funktionalität, sondern trägt lediglich einen Namen. In der Praxis sind z.B. das Document der jadice document platform und ähnliche Klassen heiße Kandidaten für die Exposition via Kontext.

Die Kontexthierarchie

Die Kontexthierarchie ist sehr einfach und folgt der logischen Struktur der Oberfläche. Es gibt zunächst einen Wurzelkontext, der an die ContentPane des JFrame gebunden ist. Darüber hinaus besitzt jeder Tab einen eigenen Kontext. Dies wäre für die Strukturierung der Oberfläche zunächst schon ausreichend. Damit jedoch die automatische Aktivierung und Deaktivierung der Kontexte funktioniert, muss die JTabbedPane zusätzlich noch mit einem eigenen Kontext ausgestattet werden.

Tabs

Die Tabs der Anwendung demonstrieren das Aggregationsverhalten einiger (aber nicht aller) Aggregationsmodi. Die in den Tabs enthaltenen Listen sowie die Liste auf der Linken Seite, die dem Wurzelkontext zugeordnet ist, zeigen jeweils den Inhalt, den die jeweiligen Kontexte sichtbar machen. Diese umfassen jeweils die Elemente des lokalen Kontexts sowie diejenigen Elemente, die durch die Aggregation eingeschlossen werden.

Tab 1

Tab 1 ist vollkommen statisch. Er exponiert lediglich eine Thingy-Instanz. Allerdings aggregiert der Kontext von Tab 3 den Elternkontext, wodurch der "globale" Slider sichtbar wird.

Tab 2

Tab 2 zeigt, wie Objekte, die im Kontext exponiert wurden und deren innerer Zustand sich geändert hat, gehandhabt werden. Um die Änderunng des Thingy beim Betätigen des Buttons in den Kontexten sichtbar zu machen, muss manuell Context.contextChanged() aufgerufen werden.

Tab 3

Tab 3 demonstriert das Verhalten beim Hinzufügen zu und Entfernen von Elementen eines Kontextes.

Aktionen

Die Aktionen auf oberer Ebene demonstrieren, wie Kontexte genutzt werden können. Der Kontext versorgt sie dabei nicht nur mit den jeweils zu bearbeitenden Zielobjekten (z.B. den Slidern) sondern sie nutzen ihn auch bei der Entscheidung, ob sie ausführbar sind oder nicht (enabled/disabled).

Wann eine Aktion ausführbar ist, kann sehr unterschiedlich gehandhabt werden. In vielen Fällen ist es erforderlich, mindestens ein Zielobjekt einer bestimmten Klasse im Kontext vorzufinden. Andere Aktionen sind unter Umständen nicht möglich, wenn nicht genau ein gesuchtes Objekt gefunden wird.