GUIs mit JavaFX erstellen (2/2)
Im ersten Teil dieses Java Tutorials haben wir uns mit der Implementierung eines graphischen Taschenrechners in JavaFX beschäftigt. Bisher haben wir die Oberfläche in FXML, der Designsprache von JavaFX, gestaltet und mit CSS gestylt. Der Taschenrechner verfügt aber noch über keinerlei Logik. Diese werden wir nun hinzufügen. Oben sehen Sie, wie der Taschenrechner bisher aussieht.
Es gibt zwei Labels. Das obere soll das letzte Zwischenergebnis, das untere die aktuelle Eingabe des Nutzers enthalten. Außerdem gibt es insgesamt 16 Buttons, die Zahlen 0 – 9, ein Komma, die Operationen +, -, * und / und das Gleichheitszeichen. Zudem haben wir bisher folgende Anforderungen an die Logik des Taschenrechners definiert:
Klickt der Nutzer auf eine Zahl oder das Komma, wird das gewählte Zeichen hinter der aktuellen Eingabe angefügt. Nach einem Klick auf einen der Operatoren gibt es zwei Möglichkeiten: Entweder es gibt bereits ein Zwischenergebnis im oberen Label oder es gibt keins. Gibt es bereits ein Zwischenergebnis, ist die aktuelle Eingabe der zweite Operand und die Operation wird direkt ausgeführt. Ansonsten wird die Eingabe als erster Operand in das obere Label kopiert, das untere Label für eine weitere Eingabe geleert und die Operation vorgemerkt. Mit einem Klick auf das Gleichheitszeichen kann die Operation anschließend angewandt werden. Klickt der Nutzer während keine Operation ausgewählt wurde und die Eingabe leer ist auf das Gleichheitszeichen, signalisiert er, dass er eine neue Berechnung anstoßen möchte und beide Label werden geleert.
Beginnen wir nun damit, diese Logik zu implementieren.
Implementierung der Logik des Taschenrechners
Bisher sind wir bei der Entwicklung dem Model View Controller-Muster gefolgt. Jede Komponente in diesem Muster hat eine klare Aufgabe. Der View entspricht der Oberfläche, die keinerlei Logik enthält. Diesen haben wir bereits fertiggestellt. Es fehlen an jetzt also noch das Model (die Daten, auf denen gearbeitet wird) und der Controller (ein Objekt, dass sowohl die Daten als auch die Oberfläche manipuliert).
Das Model des Taschenrechners ist relativ einfach: Es entspricht der aktuellen Eingabe, dem letzten Zwischenergebnis und der vorgemerkten Operation. Die Rolle des Controllers wird es sein, die Nutzereingaben entgegenzunehmen, zu verarbeiten und auf dieses Model anzuwenden. Da wir die Daten nicht persistieren oder anderweitig verwenden wollen, genügt es, sie als Instanzvariablen auf dem Controller zu speichern. Wir benötigen also nur eine weitere Datei.
Beginnen wir damit, die Logik zum Anfügen eines Zeichens zu implementieren. Anschließend setzen wir die Berechnungslogik um und nehmen zum Schluss Feinschliffe an der Ausführung vor.
Schritt 1: Die Nutzereingabe
Legen Sie mit einem Rechtsklick auf das application-Package eine neue Java Class-Datei an. Nennen Sie diese Klasse CalculatorController. Sie ist öffentlich und braucht keine statische Main-Methode.
Damit wir die Datei als Controller verwenden können, muss sie das Interface Initializable implementieren.
public class CalculatorController implements Initializable
Dazu muss die Methode initialize wie im Codeausschnitt zu sehen überschrieben werden. Die Methode kann leer gelassen werden.
Legen Sie nun im CalculatorController folgendes Enum an:
Ein Enum ist eine Aufzählung von festen Werten, die im Code verwendet werden können, um beispielsweise Optionen darzustellen. Per Konvention werden die Enum-Werte in Java vollständig großgeschrieben. Da wir jeweils die zuletzt angeklickte Operation speichern wollen, benötigen wir eine Instanzvariable vom Typ Operation, die zuerst den Wert NONE besitzt.
private Operation nextOperation = Operation.NONE;
Die anderen beiden Informationen des Models (über das zuletzt ausgerechnete Ergebnis und die aktuelle Eingabe) brauchen keine eigene Instanzvariable, da wir sie direkt aus den Labels auslesen können. Um im Code auf die Labels zugreifen zu können, wird die Annotation @FXML verwendet. Beachten Sie, dass der Name des Objektes der ID in der FXML-Datei entsprechen muss.
Schreiben wir nun eine Funktion, die an das input-Label ein Zeichen anhängt. Da die Funktion später im FXML verwendet werden soll, benötigt Sie ebenfalls die Annotation @FXML. Zudem darf Sie keinen Rückgabewert haben und kann entweder parameterlos sein oder den Parameter ActionEvent besitzen. Ein ActionEvent repräsentiert verschiedene Aktionen wie Nutzereingaben, aber auch den Lebenszyklus einer Komponente. Das Event hat ein Ziel, eine Quelle und verschiedene weitere Informationen, die abhängig von seiner Art sind. Für uns ist die Quelle interessant:
In der Funktion handleInput erweitern wie den Text des input-Labels um den Text der Eventquelle, also beispielsweise 1, 2 oder das Komma. Damit wir auf diese Eigenschaft über die Methode getText() zugreifen können, müssen wir das Objekt zu einem Button casten. Beachten Sie, dass dieser Cast fehlschlagen wird, wenn die Quelle des Events kein Button ist. Da wir sicherstellen können, dass die Funktion nur durch Buttons aufgerufen wird, müssen wir den Fehler an dieser Stelle nicht behandeln.
Verlinken wir diese erste Funktion nun an der Oberfläche: Dazu geben Sie dem Wurzelelement des FXML ein neues Attribut: fx:controller.
<AnchorPane xmlns:fx="http://javafx.com/fxml/1" stylesheets="@application.css" fx:controller="application.CalculatorController" fx:id="body">
Nun können Sie einem Oberflächenelement eine Funktion des Controllers durch das Attribut onAction zuweisen.
onAction="#functionName"
Falls die Funktion nicht existiert, zeigt Eclipse Ihnen einen Fehler an.
Verlinken Sie die Funktion handleInput auf jedem Button, der für die Eingabe gedacht ist.
Starten Sie das Programm und testen Sie, ob Sie eine Zahl in das untere Label eingeben können. Grundsätzlich sollte das möglich sein. Wie Ihnen aber vielleicht auffällt, können Sie einer Zahl noch mehrere Kommata hinzufügen. Es fehlt also noch eine Logik, um das zu verhindern. Eine Möglichkeit dazu ist die Prüfung, ob die Zahl bereits ein Komma enthält und das neue Zeichen ein Komma ist. In diesem Fall brechen wir die Ausführung ab, bevor wir das Zeichen anfügen.
Starten Sie das Programm erneut und prüfen Sie, ob Ihre Implementierung funktioniert.
Schritt 2: Rechenoperationen anwenden
Als nächstes wird die Berechnungslogik benötigt. Je nachdem, welcher Button angeklickt wird, wird eine Operation vorgemerkt. Um zu prüfen, welchen Button der Nutzer gewählt hat, benötigen wir Zugriff auf die Oberflächenelemente:
Anschließend schreiben wir eine Methode prepareOperation, die je nachdem, welcher Button angeklickt wurde, die ausgewählte Operation speichert. Außerdem wird die aktuelle Eingabe in das obere Label kopiert, damit der Nutzer die nächste Eingabe vornehmen kann.
Verlinken Sie die Funktion auf den zugehörigen Buttons:
Diese Änderung implementiert keine Funktionalität, die direkt auf der Oberfläche getestet werden könnte. Daher schreiben wir nun die Funktion evaluate, die die vorgemerkte Operation ausführt.
Gehen wir Schritt für Schritt durch, was diese Funktion tut:
Wenn keine Operation gesetzt ist, leeren wir wie in der ursprünglichen Anforderung beschrieben beide Felder. Ansonsten konvertieren wir beide Werte von String nach Float und führen je nach gesetzter Operation die Berechnung durch. Für die Division müssen wir beachten, dass der zweite Operand nicht 0 sein darf. In diesem Fall geben wir statt einem Ergebnis Error aus und beenden die Ausführung. Haben wir die Operation ansonsten erfolgreich durchgeführt, leeren wir das input-Label und kopieren das Ergebnis in das lastResult-Label. Außerdem setzen wir die Operation wieder auf NONE
Starten Sie das Programm und führen es aus. Sie sollten nun bereits Ganzzahlen verrechnen können, Kommazahlen führen allerdings zu einem Problem. Das liegt daran, dass Float.parseFloat für die Kommastelle einen Punkt und kein Komma erwartet. Wir müssen daher das Parsen verbessern:
Da es sich hier um etwas mehr Logik handelt, eignet sich eine eigene Methode parseFloatFromLabel. Verwenden Sie die Methode in der Funktion evaluate:
In der Funktion evaluate muss außerdem nicht nur geprüft werden, ob die Operation NONE ist, sondern auch, ob beide Label nicht leer sind. In diesem Fall können wir keine Berechnung durchführen, beenden also die Ausführung bevor es zum Parse-Fehler kommt und setzen den Taschenrechner zurück.
if (nextOperation == Operation.NONE || input.getText().isBlank() || lastResult.getText().isBlank()) { …
Aus Gründen der Einheitlichkeit sollte zuletzt beim Eintragen des neuen Wertes der Punkt wieder in ein Komma umgewandelt werden. Ansonsten kann die Oberfläche schnell inkonsistent werden, wie hier zu sehen.
Nehmen Sie die Änderung vor:
lastResult.setText(String.valueOf(result).replace(".", ","));
Nun sollten Sie ohne Probleme Berechnungen durchführen können.
Schritt 3: Feinschliffe
Obwohl die Grundfunktionalität nun implementiert ist, erfüllen wir die Anforderungen noch nicht vollständig. So wird unter anderem definiert, dass ein Zwischenergebnis bei der Auswahl einer Operation wenn möglich direkt verrechnet wird. Wir müssen in der Funktion prepareOperation also noch eine entsprechende Prüfung einbauen:
Sind beide Label gefüllt, rufen wir direkt die Funktion evaluate auf. Ansonsten kopieren wir wie gehabt die Eingabe in das obere Label.
Weiterhin ist für den Nutzer nicht immer klar, wann die aktuelle Eingabe gelöscht wird. 17 Buttons lassen sich nur schwer auf einem Grid platzieren – daher wird das Gleichheitszeichen doppelt belegt. Wenn Sie einen separaten Button zum Löschen einfügen möchten, gibt es zwei Möglichkeiten: Entweder, Sie ändern das Grid, indem Sie einige Leerstellen auf der Oberfläche zulassen oder weitere Funktionen hinzufügen, oder sie setzen einen Button zum Löschen und einen zur Auswertung an die gleiche Stelle und blenden zweiteren aus, wenn eine Auswertung nicht möglich ist.
In jedem Fall müssen Sie einen weiteren Button hinzufügen und eine Methode clear schreiben, die beide Labels leert.
Entfernen Sie die Logik zum Löschen aus der Funktion evaluate, sodass die Funktion direkt mit dem Parsen der Eingaben und der Berechnung beginnt:
Für den ersten Ansatz können Sie den Button wie im Codeausschnitt zu sehen beispielsweise an Position (4, 0) platzieren. Im CSS sollten Sie anschließend noch die Breite der Labels und des Grids auf 500 erhöhen. Das Ergebnis sollte so aussehen und direkt funktionieren:
Für die zweite Variante legen Sie den Button auf dieselbe Position wie das Gleichheitszeichen, also (2, 3). Schreiben Sie eine Funktion switchEvalVisible, die das Gleichheitszeichen ausblendet, wenn entweder keine Operation gewählt ist oder einer der beiden Operanden leer ist. Decken Sie auch den Fall ab, dass das letzte Ergebnis Error ist.
Rufen Sie diese Funktion in jeder Methode auf, die Nutzereingaben verarbeitet. Dadurch stellen Sie sicher, dass nach jeder Nutzerinteraktion geprüft wird, ob das Gleichheitszeichen angezeigt werden muss. Die betroffenen Methoden sind handleInput (im Codeausschnitt zu sehen), evaluate und prepareOperation.
Im Initialize des Controllers muss diese Funktion ebenfalls aufgerufen werden.
Starten Sie das Programm und prüfen Sie, ob Sie mehrere Berechnungen hintereinander durchführen können und das Verhalten der Oberfläche Ihnen schlüssig erscheint. In diesem Fall ist der Taschenrechner fertig. Als Übung können Sie noch weitere Operationen wie Modulo, Quadratrechnung oder Wurzelziehen implementieren oder beispielsweise eine komplette Historie der letzten Rechenschritte anzeigen.
Gesamter Code:
Main.java
calculator.fxml
CalculatorController.java
application.css