UI-építés FXML-ben

Akinek van bármilyen webfejlesztési tapasztalata, bizonyára tisztában van vele, ott mennyire alapvető gyakorlat a tartalom (HTML), a megjelenés (CSS), és a funkcionalitás (JS) szétválasztása. Azzal, hogy ezek külön vannak szedve, sokkal áttekinthetőbbé és karbantarthatóbbá válik az egész. Ha azt akarják a megrendelők, hogy az egyikféle gomb holnaptól hupizöld legyen hupilila helyett, az 1 sor átírását jelenti a CSS-ben.

Mindeközben ahogy eddig UI-t építettünk Javaban, az maga az őskáosz. Még a saját kódunkat olvasva is úgy hat, mintha a programozója ott helyben találta volna ki a egészet, aztán (jó)néhány sörrel a vérkeringésében addig püfölte volna a billentyűzetet, amíg egész véletlenül az nem jelent meg a képernyőn, amire saccperkábé gondolt: Hopp, kéne ide egy BorderPane... ja és legyen neki egy pici paddingja, jó, meg akkor már legyen világoskék a háttere, éééés egyébként ha rákattintok, akkor történjen ez, oké? Zsír.

Vagyis hát mégsem annyira.

A JavaFX ezért aztán a fenti kínszenvedés mellett biztosít a felhasználói felületek készítésére egy modern megközelítést is.

A Model-View-Controller paradigma

Az MVC betűszó már lett említve a második swinges Prog3 előadáson, de ha tippelnem kellene, elég sokan átsiklottunk rajta, pedig alapvetően egy hasznos gondolat: Van nekünk valami modellünk, amit valahogy megjelenítünk, meg valami irányító izénk, ami a júzer kattintgatásának hatására változtatja ezt a modellt. (Öt csillagos, professzionális megfogalmazás...)

Ami példát eddig láthattunk erre, az egy JTable lelkivilágának fürkészése volt a második swinges laboron. Pedig a Model-View-Controller tervezési minta igazából az egész programról szól. Arról, hogy a „mit csinál” és a „hogy néz ki” mindenképpen két teljesen különválasztható – és különválasztandó – feladat.

A JavaFX-ben, ha FXML-t használunk, ezen elgondolás szerint rakjuk össze a programunkat. A Model rész a program lényege, a business logic, ezt nyilván teljesen mi készítjük. Maradt tehát a View és a Controller, ezekben segít nekünk a JavaFX.

View – az FXML

A név valószínűleg „FX” és az „XML” összerántásából származik. Az FXML lehetővé teszi, hogy a JavaFX UI-nkat XML jelölőnyelvben írhassuk le: hogyan, milyen elemekből áll össze, és azok hogyan legyenek elrendezve. Mindezt a viselkedését meghatározó kódtól teljesen függetlenül, külön fájlban. Ha már csináltál bármilyen weblapot, akkor már sejted, hogy ez miért jó. Ha nem:

Ismétlésképpen: egy XML fájl címkékből (tag) épül fel, amiknek lehetnek attribútumai. Így: <címkenév attrimútum1="attribútumérték"> </címkenév>. A címkék egymásba ágyazhatók: <a><b></b></a>. De hogy lesz ebből UI?

Mint láthattuk, a JavaFX nagyrészt megtartotta a már ismert különböző panelfajtákat. Az FXML-ben minden ilyen panelnek, illetve az összes UI-elemnek van egy, az osztálynévvel egyező nevű címke-megfelelője. (Illetve minden másnak is, ahogy később kiderül.) És ahogy az FXML-fájlban ezek a címkék egymásba vannak ágyazva, úgy lesznek a panelek, gombok, egyebek ennek megfelelően egymásba téve a betöltött UI-ban is. Mit jelent ez? Azt, hogy a JavaFX megcsinálja helyettünk a UI-elemek egymásba legózását, amitől mindenki a falra mászik.

Nézzünk egy példát! Legyen ez a kód (egyébként majdnem pont ezt adja oda az IntelliJ, amikor új JavaFX projektet csinálsz):

package sample;

import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;

public class Main extends Application {

    @Override
    public void start(Stage primaryStage) throws Exception {
        Parent root = FXMLLoader.load(getClass().getResource("sample.fxml"));
        primaryStage.setTitle("Hello World");
        primaryStage.setScene(new Scene(root));
        primaryStage.show();
    }


    public static void main(String[] args) {
        launch(args);
    }
}

És nézzen ki így a hivatkozott sample.fxml:

<?import javafx.scene.control.Button?>
<?import javafx.scene.layout.StackPane?>

<?import javafx.scene.layout.VBox?>
<?import javafx.scene.control.TextArea?>
<StackPane xmlns:fx="http://javafx.com/fxml"
           fx:controller="sample.Controller"
           prefWidth="300" prefHeight="275">
    <VBox spacing="5" alignment="CENTER" fillWidth="false">
        <TextArea fx:id="hellotxt" text="Hello world!" prefRowCount="0" prefColumnCount="10"></TextArea>
        <Button text="Uppercase!" onAction="#demoAction"></Button>
    </VBox>
</StackPane>

Ebből ezt kapjuk:

Mint láthatjuk, a varázslat ebben a sorban történik:

Parent root = FXMLLoader.load(getClass().getResource("sample.fxml"));

Az FXMLLoader fogja az FXML fájlunkat, és a címkék alapján létrehozza és egymásba ágyazza a megfelelő UI-elemeket, illetve beállítja rajtuk a címkék attribútumainak megfelelő attribútumokat: ha egy UI elemnek van valamire egy setIzé() függvénye, akkor esélyes, hogy ugyanez beállítható elegánsan az izé FXML-attribútumként is.

Megkapjuk tehát a kész node tree-t, amit utána szépen oda lehet adni a Scene-nek és meg is lehet jeleníteni. Persze még maradnak kérdések: Hogyan tudunk innentől hozzáférni a UI-nek egyes részeihez? Hol van leírva ez a demoAction, amiről azt találgathatnánk, hogy valahogy lekezeli a gombnyomást? Nos...

A Controller

Észrevehetted, hogy az FXML fájlunkban a gyökér címkének van egy fx:controller attribútuma, aminek az értéke mintha egy osztály lenne: a Controller nevű, a sample package-ből. Nos, ez pontosan így van.

Az fx:controller attribútummal megadhatunk (és általában meg szoktunk adni) egy vezérlő, controller osztályt, aminek a funkcióját a fentebbi MVC részből már kitalálhatod. Ezt az osztályt nekünk kell megírni (hiszen mi tudjuk, mit fog csinálni a programunk), viszont a JavaFX készségesen összedrótozza nekünk az FXML-ből betöltött UI-al. Nézzük, hogy néz ki a példaprogramunk Controller osztálya!

package sample;

import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.control.TextArea;

public class Controller {

    @FXML private TextArea hellotxt;

    @FXML
    private void demoAction(ActionEvent actionEvent) {
        hellotxt.setText(hellotxt.getText().toUpperCase());
    }

}

Az FXMLLoader, amikor betöltötte az FXML fájlunkat, többek közt a következőket csinálta:

  • Látta, hogy a gyökér elemnek van fx:controller attribútuma, így létrehozott egy példányt az attribútum által megadott Controller osztályból
  • A hellotxt tagváltozó megkapta értékül az FXML-ben lévő, hellotxt azonosítójú szövegdoboz referenciáját, így innentől azon keresztül elérhettük a kódunkban ezt a szövegdobozt.
  • A Controller osztály demoAction függvényét beállította, mint a gombnyomás eseménykezelőjét.

Általánosabban:

  • Az FXMLLoader csinál nekünk egy példányt az fx:controller attribútum által megadott osztályból, ami így kezelheti a felépített UI-t.
  • Ha egy címkének van fx:id attribútuma, akkor a controller osztályunk létrehozott példányában azt a mezőt, aminek a neve egyezik az attribútum értékével, beállítja, hogy a címkéből létrehozott objektumra mutasson. Ha a controller osztályunk tagváltozóit szeretnénk megtartani privátnak, akkor az FXMLLoader-nek az @FXML annotációval kell segítenünk, hogy megtalálja a kérdéses tagváltozót.
  • Ha egy címke egyik attribútumával a címkéből létrehozott objektum egy olyan mezőjét akarjuk beállítani, ami eseménykezelőt vár, akkor ha az attribútum értéke # jellel kezdődik, beállítja a controller osztály azonos nevű függvényét az attribútum szerinti esemény eseménykezelőjének. (A # jelről lásd még itt.) A tagváltozós esethez hasonlóan ha azt akarjuk, hogy az eseménykezelőnek szánt függvény privát legyen, ugyanúgy ki kell tennünk az @FXML annotációt.

Ha controllernek szánt osztálynak van egy paraméter nélküli initialize() metódusa, akkor a műveletek végén az FXMLLoader ezt meghívja. Ezzel van lehetőségünk a betöltött UI-struktúrát dinamikus tartalommal feltölteni: adatokat hozzáadni adatfájlokból, bármilyen dinamikusan változó UI-részt elrendezni.

Oké, ez így nagyon magic. Nézzük meg, hogy mi történik a háttérben!

Az FXML jelölőnyelv

Amikor az FXML-t kitalálták, a tervezői betartották azt a jó szokást, hogy ha már csinálsz valamit, csináld minél általánosabban: egy FXML-fájlban leírhatunk tetszőleges osztályokból (bizonyos tág megkötésekkel) álló objektumhierarchiát, és az FXMLLoader boldogan betölti nekünk. És a JavaFX egy jelenetgráfja éppenséggel egy ilyen objektumhierarchia.

Osztálypéldány-elemek

Minden címke, aminek a neve nagybetűvel kezdődik és az egyező nevű osztály importálva van, abból egy objektum jön létre:

  • Az egyszerű eset az, ha a kérdéses osztály betartja a JavaBean konvenciókat, így van paraméter nélküli konstruktora és megfelelően elnevezett setter metódusai. Amikor az FXMLLoader ilyen címkét lát, simán létrehoz egy példányt az osztályból és ha a címkének van egy valami attribútuma, akkor a setValami() metódusát hívva az objektumnak beállítja a kérdéses tagváltozót. Az FXMLLoader ebben elég okos, a sztringként megkapott értékből elő tudja állítani a kért típusú értéket.
  • Ha nincs ilyen konstruktor és a kért objektumot egy Builderrel kell legyártani, az FXMLLoader azzal is elboldogul. Hacsak nem akarunk valami saját ilyen osztályt csinálni, nem nagyon kell ezzel törődnünk, a JavaFX-ben lévő ilyen builderes osztályokkal az FXMLLoader észrevétlenül elbánik, pl. simán létrehoz ebből egy Color objektumot:
    <Color red="1.0" green="0.0" blue="0.0"/>
    
    (A Color-nak nyilván nem lehet paraméter nélküli konstruktora, csak mindhárom komponenst megadva kapunk értelmes színt.)
  • Ha a címke nevét adó osztály implementálja a Map interfészt, akkor az FXMLLoader a címke attribútum-attribútumérték párjait a map kulcs-érték párjaiként értelmezi és állítja be; pl. ebből egy az elvártaknak megfelelően feltöltött HashMap keletkezik:
    <HashMap foo="123" bar="456"/>

Tagváltozó-elemek

Minden kisbetűvel kezdődő nevű címkét úgy értelmez az FXMLLoader, hogy egy tagváltozót akarunk vele beállítani. Így például <Button text="Random gomb"/> helyett írhatjuk ezt is: <Button><text>Random gomb</text></Button>, eredményként ugyanazt a „Random gomb” szövegű gombot kapjuk.

Ez persze az egyszerű tagváltozókra annyira nem hasznos, legfeljebb akkor érdemes így írni, ha mondjuk valami nagyon hosszú szöveget akarunk beírni, és így picit olvashatóbb marad a markup. Azonban máris értelmet nyer, ha a kérdéses tagváltozónk maga is összetett, pl. egy lista:

<VBox>
    <children>
        <Button text="Foo"/>
        <Button text="Bar"/>
    </children>
</VBox>

Mint látható, az FXMLLoader itt is rájön, mi a dolga: ha a kérdéses címke által jelölt tagváltozó egy getterrel elkérhető lista (read-only List property), akkor az FXMLLoader a címkébe ágyazott címkékből keletkező dolgokat szépen bepakolja a listába.

Megjegyzendő, hogy a JavaFX UI-elemeknél ezt a children címkét eddig sosem írtuk ki, de az FXMLLoader valahogy mégiscsak tudta, hogy hova kell tennie az elemeket. Ez sem külön varázslat, hanem a @DefaultProperty annotációt használva megadható, hogy az osztályunk melyik property-jét kezelje így az FXMLLoader. (Ha van erre nyilvánvaló választásunk, természetesen.)

Speciális FXML címkék, attribútumok

Az <fx:id> és az <fx:controller> mellett van még néhány, amit a teljesség igénye nélkül érdemes megemlíteni:

<fx:include>
Amire számítanál: betölti a megadott FXML fájlt és beleteszi a jelenlegibe a címkének megfelelő helyen. Pl. az <fx:include source="foo.fxml"/> betölti és beilleszti a foo.fxml fájlt.
<fx:reference>
Hivatkozás egy másik betöltött objektumra (vagy szkript változóra... erről nem beszélünk) az FXML fájlban. Pl. az <fx:reference source="myImage"/> címke helyére behelyettesítésre kerül a myImage fx:id-jú elem.
<fx:constant>
A címke nevét adó osztályban az attribútum értékének megfelelő konstanst adja vissza, pl. <Color fx:constant="BLUE"/>
<fx:value>
A címke nevét adó osztály statikus valueOf(String) metódusát hívja az attribútum értékével, így állítva elő belőle egy példányt, pl. <Color fx:value="#0000FF"/>
<fx:define>
Úgy hoz létre egy objektumot, hogy az nem kerül beillesztésre az épülő objektumhierarchiába. Ez pont jól jön, ha rádiógombokat csinálunk:
<VBox>
	<fx:define>
	    <ToggleGroup fx:id="myToggleGroup"/>
	</fx:define>
	<children>
	    <RadioButton text="A" toggleGroup="$myToggleGroup"/>
	    <RadioButton text="B" toggleGroup="$myToggleGroup"/>
	    <RadioButton text="C" toggleGroup="$myToggleGroup"/>
	</children>
</VBox>
(A példában lévő $ jelről lentebb szó lesz.)
<fx:root>
Egy már létező objektumra hivatkozik, amit az FXMLLoader kívülről fog megkapni, és ez lesz a objektumhierarchia gyökere. Ennek megfelelően csak az FXML dokumentum gyökereként szerepelhet. Ezzel lényegében megmondhatjuk az FXMLLoader-nek, hogy erre a bizonyos elemre aggasson rá mindent, ne csak úgy töltse be a levegőbe és adja oda. Ez többek közt kicsi saját (ismétlődő) UI részek készítéséhez nagyon jó, ahogy arról lentebb szó lesz.

Speciális FXML attribútumértékek

# – Controllerbeli eseménykezelő
Ahogy fentebb szerepelt: ez jelöli az FXMLLoader-nek, hogy az attribútumértékben egy, a controller osztályban lévő függvényre hivatkozunk, mint beállítandó eseménykezelőre. (Lehetne eseménykezelőt máshogy is, pl. JavaScripttel, bele a markupba... de ilyet inkább ne.)
@ – URL
Jelzi az FXMLLoader-nek, hogy az attribútumérték nem egyszerű string, hanem egy URL. Pl. ha képet nyitunk meg: <Image url="@my_image.png"/>. A @ után a relatív útvonalak természetesen az FXML-fájl helyzetéhez képest vannak értelmezve, tehát a példabeli my_image.png-nek az éppen betöltött FXML-fájllal egy helyen kell lennie.
% – Resource
Bár eddig erről nem volt szó, de az FXMLLoader-nek a konstruktorában oda lehet adni egy ún. ResourceBundle-t, amiben többféle nyelven lehet megadni ugyanazt a sztringet, és ki lehet venni a kért nyelvnek megfelelőt. Ez pont ezt csinálja, a % jelzi az FXMLLoader-nek, hogy az attribútumérték nem egyszerű szöveg, hanem a kapott ResourceBundle-ben valami lokalizált szöveg azonosítója, és onnan kell kiszednie a megfelelő nyelvű változatot.
$ – Hivatkozás
Jelzi az FXMLLoader-nek, hogy az attribútumérték egy hivatkozás a megfelelő nevű változóra a dokumentumban, ez legtöbbször a megfelelő fx:id-jú elemet jelenti. Pl. ha mondjuk egy betöltött kép több helyre is kell, vagy a fentebbi rádiógombos példánál ToggleGroup-ra való hivatkozás. (Az FXMLLoader ha lát egy fx:id-val ellátott címkét, létrehoz magának a címkéből keletkező objektumra mutató változót. Amúgy hivatkozhatnánk JavaScript változóra is... erről most nem lesz szó.)
${ } – Expression binding
Ez talán az összes közül a legnagyobb mágia, de később szó lesz róla, hogy működik. Most legyen itt csak az, hogy mi történik. Ha a változóhivatkozás $ jele után kiteszünk egy { } zárójelpárt, akkor közéjük írhatunk egy egyszerűbb kifejezést (mezőelérések, literálok, aritmetikai, logikai operátorok megengedettek), ami utána automatikusan mindig ki lesz értékelve, az attribútum értéke hozzá lesz kötve a kifejezés értékéhez. Pl:
<HBox>
	<CheckBox fx:id="abcCheck"/>
	<TextField disable="${ ! abcCheck.selected}"/>
</HBox>
Csupán ezzel a kóddal a szövegdoboz tiltásra kerül, ha a CheckBox-ot bepipálta a felhasználó és engedélyezve, ha nem.
Megjegyzendő, hogy bár esetleg számíthatunk rá a sok okosság után, de az FXMLLoader itt nem fog nekünk JavaScriptet játszva típuskonverziókat végezni: ha stringet akarunk pl. számmal szorozni, már repül is a betöltéskor a ClassCastException.
\ – Escape
A megszokott módon, ha karakterként szeretnénk az attribútumérték elejére írni a fenti jeleket, akkor a \ karakterrel kerülhetjük el a speciális értelmezést, pl. <Label text="\$10.00"/>

További trükkök

Ha úgy igazán fancy dolgokat akarunk csinálni.

Betöltés részletesen

A statikus load() metódusa az FXMLLoader-nek nem az egyetlen módja egy FXML dokumentum betöltésének, lehet ezt részletesebben is, példányosítva az FXMLLoader-t:

FXMLLoader loader = new FXMLLoader(getClass().getResource("sample.fxml");

Ez lehetőséget ad rá, hogy kicsit több ráhatásunk legyen a betöltésre:

  • A load() hívása előtt a setController() metódussal mi adhatunk meg controllert ahelyett, hogy a markup alapján az FXMLLoader hozná létre.
  • A load() hívása előtt a setResources() metódussal hozzáadhatunk egy ResourceBundle-t lokalizációhoz (ezt lehet konstruktorban is).
  • A load() hívása előtt a setRoot() metódussal adhatjuk meg az objektumhierarchia gyökerét, ha a markupban <fx:root>-ot használtunk.
  • Ezek után a load() hívásával a megszokotthoz hasonlóan megkapjuk a betöltött objektumhierarchiát:
    Parent root = loader.load();
  • Ha az FXMLLoader csinálta nekünk a controllert, akkor load() hívása után a getController() metódussal elkérhetjük tőle.

Nézetváltás

Gyakran előfordul a grafikus programok készítése során, hogy a felhasználói felület az éppen használt programfunkciótól függően teljesen megváltozik, egy másik nézetre vált: pl. játékoknál van a menü és maga a játéknézet. Nézzük, milyen lehetőségeink vannak ezt FXML-t használva megoldani!

Nézetváltás csúnyán

Ha a kérdéses terület csak valami kisebb részlet, vagy csak egyszerűen lusták vagyunk, elmegy ez a megoldás is:

  1. Csináljuk egy nagy StackPane-t
  2. Pakoljuk rá az összes kérdéses nézetet tartalmazó paneleket
  3. Legyen mindig csak az aktuális nézetet tartalmazó panel látható: legyen a controllerben egy eseménykezelő függvény a nézetek közti váltása, ami láthatóvá teszi a kívánt nézet paneljét és elrejt minden mást.
<StackPane fx:id=”root” fx:controller="valami.RootController">
  <SomethingPane fx:id=”viewOne”>
    …
  </SomethingPane>
  <SomethingPane fx:id=”viewTwo”>
    …
  </SomethingPane>
</StackPane>

Persze ez nem igazán a StackPane miatt csúnya, az lényegében ilyesmire van kitalálva. Hanem azért érezhetjük kellemetlennek, mert ha úgy igazán lusták vagyunk, akkor esetleg a teljes alkalmazásunk UI kódja megmarad egyetlen gigászi FXML fájlban, és ugyanaz az egy a controller fog foglalkozni az összes nézettel.

Erről így lehet érezni, hogy nem ideális: egy nagyobb alkalmazásban a különböző nézeteknek nem feltétlenül van sok közük egymáshoz, az is elképzelhető, hogy teljesen más csapatok dolgoznak rajtuk. Jó lenne tehát, ha saját FXML fájlban lenne leírva az egyes nézetek felhasználói felülete és mindegyiknek lenne saját controllere. Hogyan hozhatnánk ezt össze?

Nézetváltás szépen

Jó barátunk az <fx:include>:

<StackPane fx:id=”root” fx:controller="valami.RootController">
    <fx:include fx:id=”viewOne” source=”viewOne.fxml” />
    <fx:include fx:id=”viewTwo” source=”viewTwo.fxml” />
</StackPane>

Így máris külön fájlokba szedtük szét a nézeteinket!

Sőt, az FXMLLoader még pluszban is segít nekünk:

public class RootController {
  @FXML private StackPane root;
  @FXML private SomethingPane viewOne;
  @FXML private SomethingPane viewTwo;

  @FXML private ViewOneController viewOneController; // Ide automatikusan bekerül az első nézet controllere!
  @FXML private ViewTwoController viewTwoController; // Ide meg a másodiké
// …
}

Ez azért tök jó, mert ezután a nézeteink controllereit megírhatjuk úgy, hogy tároljon egy referenciát a gyökérnézet controllerére, amit aztán szépen beállítunk betöltésnél:

public class RootController {

// …

  public void initialize() {
    viewOneController.setRootCtrl(this);
    viewTwoController.setRootCtrl(this);
  }
}

És mondhatni készen is vagyunk, az egyes nézetek controllerein belüli, nézetváltást kiváltó eseményeket kezelő függvények innentől egyszerűen hívhatják a fő controller nézetváltó függvényét.

Ami persze változatlanul megjelenít-elrejt, mert a StackPane-t benn hagytuk. De ha zavarna...

Nézetváltás még szebben

Ötlet: a nézetekből úgyis csak az éppen megjelenítettnek kellene bent kellni a gyökérpanelben, szóval igazából az összes nézetet töltsük be simán „a levegőbe” <fx:define>-nal:

<Pane fx:id="root" fx:controller=”valami.RootController”>
	<fx:define>
	  <fx:include fx:id=”viewOne” source=”viewOne.fxml” />
	</fx:define>
	<fx:define>
	  <fx:include fx:id=”viewTwo” source=”viewTwo.fxml” />
	</fx:define>
</Pane>

A betöltött nézetek és controllereik ugyanúgy bekerülnek <fx:id> alapján a fő controller megfelelő tagváltozóiba, de a root panelbe nem lesznek automatikusan bepakolva.

Viszont ezután már megírhatjuk a nézetváltást a fő controllerben így:

public class RootController {
// …
  private void changeView(ViewEnum toWhich) {
    root.getChildren().clear(); // Kitakarítjuk a gyökérpanelt...
    switch (toWhich) { // És betesszük csak a kívánt nézetet
      case ViewEnum.VIEW_ONE:
        root.getChildren().add(viewOne);
        return;
      // …
    }
  }
// …
}

Örülünk? Örülünk.

UI-elemcsoportok

Szóval az FXML egy remek dolog, szépen leírjuk fájlba a UI-t és be lesz nekünk töltve. Egy gond maradt: mi van akkor, ha van valami olyan elemcsoportunk, ami sokszor előfordul, a tartalma dinamikus, és azt sem tudjuk, hány kell belőle?