Metodologie di Programmazione: Lezione 17

Riccardo Silvestri

Hello JavaFX

La prima libreria di Java per lo sviluppo di interfacce grafiche, graphic user interfaces, in breve GUI, è AWT (Abstract Window Toolkit) e fu introdotta nel 1996. È un'astrazione della sottostante GUI nativa e quindi può gestire solamente le componenti e caratteristiche che sono comuni a tutte le piattaforme sulle quali la JVM è supportata. Questo implica che la libreria AWT è inevitabilmente piuttosto povera. Uno dei pochi vantaggi è che l'aspetto delle componenti della GUI è esattamente quello delle componenti native della piattaforma sottostante. Ma è praticamente impossibile modificare una componente o crearne di nuove.

Più o meno nello stesso periodo in cui fu introdotta AWT, Netscape sviluppò la libreria Internet Foundation Classes, IFC. A differenza di AWT, IFC non è basata su un'astrazione delle componenti native ma su componenti che sono gestite e rese graficamente in modo del tutto indipendente dalla piattaforma sottostante. Nel 1997 la Sun Microsystems (l'allora proprietaria di Java) annunciò l'intenzione di fondere IFC in Java. Il framework Java Foundation Classes, JFC, è il risultato della fusione che comprende AWT, Java2D e Swing. È stato parte di Java SE fin dal 1998. In particolare Swing è stato da allora e fino a Java 7 la principale libreria per lo sviluppo di GUI in Java. La libreria Swing insieme a Java2D, essendo libera da vincoli dovuti alla sottostante piattaforma, ha enormemente arricchito l'arsenale delle componenti e degli strumenti per la costruzione di GUI. Però dal 1998 ad oggi le GUI si sono evolute e Swing ha iniziato a manifestare alcune delle sue debolezze. Molti degli effetti (ad es. riflessioni o effetti blur) che sono divenuti comuni nelle moderne applicazioni sono o impossibili con Swing o molto difficili da realizzare. Lo stesso dicasi per le animazioni. Inoltre dare speciali aspetti alle componenti o crearne di nuove, anche se possibile in Swing, risulta essere un compito molto arduo. Queste sono le principali ragioni che hanno portato alla sostituzione di Swing.

Nel 2007 Sun Microsystems introdusse una tecnologia chiamata JavaFX con lo scopo di competere con l'allora molto in voga Flash. Però all'inizio JavaFX anche se era eseguito sulla JVM aveva un suo linguaggio di programmazione chiamato JavaFX script. Da allora si sono succedute diverse versioni di JavaFX fino all'attuale JavaFX 8 che usa il linguaggio Java ed è distribuita sia nel JRE che nel JDK. Rispetto a Swing JavaFX offre una maggiore varietà di componenti, supporto per grafica 3D, strumenti per facilitare l'uso di effetti ed animazioni. Oltre a ciò JavaFX offre i tipici benefici dello sviluppo di interfacce tramite HTML/CSS grazie a FXML che è un linguaggio di markup basato su XML che permette di definire, in modo dichiarativo, l'intero layout di un'applicazione JavaFX.

In questo prima lezione dedicata a JavaFX introduciamo le basi per lo sviluppo di applicazioni JavaFX come la gestione degli eventi e le componenti fondamentali di una GUI quali finestre, controlli e layout.

Le basi di JavaFX

Introduciamo un nuovo package nella nostra code base mp.gui per sperimentare con GUI e in particolare con JavaFX.

Per realizzare un'applicazione JavaFX è necessario estendere la classe astratta Application nel package javafx.application. Questa contiene un solo metodo astratto abstract void start(Stage primaryStage) che ovviamente deve essere implementato, vedremo fra poco il significato del parametro primaryStage. Ma prima vediamo la più semplice applicazione JavaFX che si possa scrivere1, non fa molto, mostra solamente una finestra

package mp.gui;

import javafx.application.Application;
import javafx.stage.Stage;

/** Una classe per fare le prime sperimentazioni con JavaFX */
public class TestGUI extends Application {
    public static void main(String[] args) {
        launch(args);  // Lancia l'applicazione, ritorna quando l'applicazione
    }                  // termina. Può essere invocato una sola volta

    @Override
    public void start(Stage primaryStage) {
        primaryStage.show();  // Rende visibile la finestra (o stage) principale
    }
}

Lo stile della finestra dipende dalla piattaforma (la figura è relativa a MacOS X). Si può notare che la finestra è perfettamente funzionante, trascinabile e ridimensionabile.

Il metodo statico void launch(String... args) di Application, lancia l'applicazione JavaFX. Più precisamente

  1. Costruisce un'istanza della classe che estende Application, nel nostro caso TestGUI.
  2. Invoca il metodo void init() sull'istanza. Come si intuisce dal nome, può essere ridefinito per inizializzare risorse o altro. L'implementazione di default non fa nulla.
  3. Invoca il metodo start(javafx.stage.Stage), sempre sull'istanza, passandogli un oggetto della classe Stage, del package javafx.stage. Uno Stage rappresenta una finestra di primo livello (top level window), cioè non contenuta in altre componenti. Quella che è passata a start è costruita dal metodo launch ed è la finestra principale e per questo è chiamata primaryStage, l'applicazione può crearne altre.
  4. Aspetta che l'applicazione termini. L'applicazione termina quando o è invocato il metodo Platform.exit() o l'ultima finestra viene chiusa.
  5. Invoca il metodo void stop() dell'istanza dell'applicazione che può essere ridefinito per rilasciare risorse e cose simili. L'implementazione di default non fa nulla.

Il metodo launch crea anche uno speciale thread, chiamato JavaFX Application Thread, in cui è invocato il metodo start, sono elaborati tutti gli eventi di input e sono eseguite le animazioni. La creazione e la modifica di tutti gli oggetti che fanno parte della GUI di JavaFX deve essere fatta nel JavaFX Application Thread. Il metodo init invece è invocato nel thread in cui è stato invocato launch quindi in init non si devono creare o modificare oggetti della GUI. Invece il metodo stop è invocato nel JavaFX Application Thread.

Una finestra, Stage, di JavaFX è un contenitore di primo livello. Tutti le altre componenti, controlli, immagini, grafica, ecc. sono contenuti in uno Stage. Però lo Stage può contenere direttamente solamente un oggetto di tipo Scene, nel package javafx.scene. Uno Scene contiene un cosiddetto grafo di scena (scene graph) che è una struttura ad albero i cui nodi sono le componenti di una GUI. Una qualsiasi componente della GUI, bottone, testo, grafico, immagine, ecc. è rappresentata tramite un sotto-tipo del tipo astratto Node, sempre in javafx.scene. I Node possono essere di due tipologie: quelli che possono avere figli, cioè possono contenere altri nodi, e quelli che non possono avere figli e sono le foglie di uno scene graph. I nodi che possono contenere altri nodi sono sotto-tipi del tipo astratto Parent che è ovviamente un sotto-tipo di Node. Uno Scene contiene solamente il Node che è la radice del grafo di scena. Generalmente la radice è un sotto-tipo di Parent, altrimenti non potrebbe contenere altri nodi. Come vedremo ci sono molti tipi diversi di nodi contenitori, ad esempio i layout che permettono di dislocare controlli con diverse organizzazioni. Un nodo contenitore di base è Group, nel package javafx.scene. Questo è usato sopratutto per contenere nodi di grafica perché i nodi devono essere posizionati specificando esplicitamente le loro coordinate.

Forme

Vediamo un semplice esempio

public void start(Stage primaryStage) {
    Rectangle r = new Rectangle(40, 20, 200, 80);  // Rettangolo in (40, 20)
    Ellipse e = new Ellipse(120, 70, 80, 60); // Ellisse con centro (120, 70)
    e.setFill(Color.rgb(255,0,0));
    Group root = new Group(r, e);       
    Scene scene = new Scene(root, 400, 200);  // La radice del grafo di scena
    scene.setFill(Color.AQUA);            // Colore di background della scena
    primaryStage.setScene(scene);         // La scena della finestra
    primaryStage.show();    // Rende visibile la finestra (o stage) principale
}

Il package javafx.scene.shape contiene classi (sotto-tipi di Node) che rappresentano molte forme 2D come ad esempio quelle che abbiamo usato nell'esempio Rectangle e Ellipse.

Volendo fare diverse prove usando sempre la stessa classe TestGUI, ci conviene introdurre un metodo che crea il grafo di scena e ne ritorna la radice

public void start(Stage primaryStage) {
    Parent root = createShapes();         // La radice del grafo di scena
    Scene scene = new Scene(root, 400, 200);
    scene.setFill(Color.AQUA);            // Colore di background della scena
    primaryStage.setScene(scene);         // La scena della finestra
    primaryStage.show();    // Rende visibile la finestra (o stage) principale
}

private Parent createShapes() {
    Rectangle r = new Rectangle(40, 20, 200, 80);  // Rettangolo in (40, 20)
    Ellipse e = new Ellipse(120, 70, 80, 60); // Ellisse con centro (120, 70)
    e.setFill(Color.rgb(255,0,0));
    return new Group(r, e);
}

Si noti che createShapes ritorna un Parent perché tutti i costruttori di Scene richiedono un Parent e quindi il nodo radice dello scene graph deve essere un Parent.

Testo e layout

I nodi contenitori più usati sono quelli che permettono di disporre i nodi in modo automatico secondo schemi prefissati, ad esempio allineati in orizzontale o in verticale, disposti in una griglia, ecc. Questi sono detti layout e si trovano nel package javafx.scene.layout. Vediamo un esempio con il layout VBox che dispone i nodi in verticale.

private Parent createTexts() {
    Text t1 = new Text("Hello JavaFX");   // Oggetti che visualizzano testo
    Text t2 = new Text("Ciao");
    VBox vb = new VBox(t1, t2);
    vb.setAlignment(Pos.CENTER);  // Allineamento dei nodi
    vb.setSpacing(30);            // Spazio tra i nodi
    return vb;
}

Ovviamente in start sostituiamo la linea Parent root = createShapes(); con Parent root = createTexts();.

Abbiamo usato nodi Text, nel package javafx.scene.text, che visualizzano testo. Si noti che i layout a differenza di Group si ridimensionano automaticamente quando le dimensioni della finestra cambiano.

Controlli ed eventi

Vediamo ora un esempio con un controllo, un bottone che quando cliccato dovrebbe mostrare o nascondere un testo.

private Parent createTextChange() {
    Text txt = new Text("Hello JavaFX");
    Button showHide = new Button("Show/Hide");  
    VBox vb = new VBox(txt, showHide);
    vb.setAlignment(Pos.CENTER);  // Allineamento dei nodi
    vb.setSpacing(30);            // Spazio tra i nodi
    return vb;
} 

Il bottone Button è nel package javafx.scene.control che contiene tutti i controlli di JavaFX. Per ora cliccando sul bottone non accade nulla perché non abbiamo specificato l'azione da compiere in risposta all'evento di click. Per specificarla possiamo usare il metodo di Button void setOnAction(EventHandler<ActionEvent> act) che imposta l'azione act che deve essere eseguita quando il bottone è fired, il che accade quando è cliccato, è toccato (su una device con touching screen), tramite un particolare tasto premuto o con l'invocazione diretta del metodo fire(). L'azione è rappresentata dall'interfaccia funzionale EventHandler<T extends Event> nel package javafx.event. Questa è parametrica nel tipo T dell'evento ed è quindi usata per rispondere a tutti i tipi di eventi non solo ActionEvent. L'unico metodo dell'interfaccia è void handle(T event) che sarà appunto invocato su un EventHandler quando si verifica un evento di tipo T relativamente a un nodo che ha quel EventHandler impostato per eventi di tipo T. Nel nostro caso modifichiamo il metodo createTextChange e subito dopo la creazione del bottone showHide, impostiamo l'azione

showHide.setOnAction(e -> {
    if (txt.getOpacity() > 0.0) txt.setOpacity(0.0);
    else txt.setOpacity(1.0);
});

La proprietà OpacityProperty è una delle tante proprietà generali di un Node che possono essere modificate. L'opacità è un valore con virgola compreso tra 0.0 e 1.0 che determina il grado di opacità con il quale il nodo è visualizzato, 0.0 significa completamente trasparente e 1.0 completamente opaco.

Ora vorremmo che il nome del bottone riflettesse il cambiamento di visibilità del testo, cioè quando il testo è visibile il nome dovrebbe essere Hide e quando è invisibile Show. Possiamo farlo modificando l'azione e anche il nome iniziale

Button showHide = new Button("Hide");
showHide.setOnAction(e -> {
    if (txt.getOpacity() > 0.0) {
        txt.setOpacity(0.0);
        showHide.setText("Show");
    } else {
        txt.setOpacity(1.0);
        showHide.setText("Hide");
    }
});

Proprietà

Vogliamo poter modificare la dimensione del testo visualizzato. Uno dei controlli che possiamo usare per tale scopo è lo Slider che permetta all'utente di cambiare agevolmente un valore facendo scorrere una specie di cursore. Nel nostro caso il valore è la dimensione della fonte del testo. Dovendo aggiungere lo Slider conviene mettere i controlli, che per adesso sono solo due, in un layout separato che li dispone su una riga orizzontale. Usiamo quindi un layout HBox.

private Parent createTextChange() {
    Text txt = new Text("Hello JavaFX");
    Button showHide = new Button("Hide");
    showHide.setOnAction(e -> {
        if (txt.getOpacity() > 0.0) {
            txt.setOpacity(0.0);
            showHide.setText("Show");
        } else {
            txt.setOpacity(1.0);
            showHide.setText("Hide");
        }
    });
    Slider size = new Slider(8, 40, 12); // Per la dimensione della fonte
    HBox hb = new HBox(showHide, size);  // Layout per i controlli
    hb.setAlignment(Pos.CENTER);
    VBox vb = new VBox(txt, hb);
    vb.setAlignment(Pos.CENTER);  // Allineamento dei nodi
    vb.setSpacing(30);            // Spazio tra i nodi
    return vb;
}

Nel costruttore dello Slider si può specificare, nell'ordine, il minimo e il massimo valore 8, 40 e il valore iniziale 12. Manca ancora la connessione tra lo Slider e la fonte del Text. Per prima cosa ci conviene scrivere un piccolo metodo di utilità per modificare la dimensione della fonte di un nodo Text2

/** Modifica la dimensione della fonte del nodo Text specificato.
 * @param t  un nodo Text
 * @param s  la nuova dimensione della fonte */
private static void tSize(Text t, double s) {
    t.setFont(new Font(t.getFont().getName(), s));
}

Un oggetto Font, in javafx.scene.text, rappresenta una fonte ed è immutabile. Il metodo Font getFont() di Text ritorna la fonte e il metodo void setFont(Font value) imposta la fonte.

Adesso vediamo come far sì che quando l'utente muove lo Slider la dimensione della fonte diventi uguale al valore dello Slider. Il metodo DoubleProperty valueProperty() di Slider ritorna un oggetto di tipo DoubleProperty del package javafx.beans.property. Così come molti altri tipi di proprietà un oggetto DoubleProperty implementa l'interfaccia Property<T>, con T uguale a Number, sempre del package javafx.beans.property. Questa interfaccia permette di creare dei legami diretti tra proprietà in modo tale che ogniqualvolta il valore di una proprietà cambia il valore dell'altra proprietà può essere automaticamente aggiornato di conseguenza. Per il momento non tratteremo questo meccanismo e invece consideriamo un meccanismo più generale che è veicolato da una super-interfaccia di Property<T> che si chiama ObservableValue<T> nel package javafx.beans.value. Tale interfaccia ha il metodo void addListener(ChangeListener<? super T> listener) che permette di aggiungere un listener (cioè un ascoltatore) che è invocato ogni volta che il valore della proprietà cambia. Il tipo di listener è dato dall'interfaccia funzionale ChangeListener<T>, nel package javafx.beans.value, nel nostro caso il tipo T è Number. L'unico metodo di ChangeListener<T> è void changed(ObservableValue<? extends T> observable, T oldValue, T newValue) e questo è il metodo che deve essere implementato perché sarà invocato ogni volta che il valore del nostro Slider cambia. Quindi subito dopo la creazione dello Slider aggiungiamo le seguenti linee

tSize(txt, 12);
size.valueProperty().addListener((o,ov,nv) -> tSize(txt, (Double)nv));

Purtroppo il cast a Double è necessario perché la valueProperty dello Slider implementa ObservableValue<Number>.

Si può notare che facendo crescere e decrescere la dimensione del testo i controlli si spostano. Questo è normale perché il layout si aggiusta automaticamente quando i nodi in esso contenuti cambiano dimensione. Per evitare che i controlli si spostino occorre porre il nodo che cambia dimensione, nel nostro caso il nodo Text, in un layout che ha una dimensione iniziale sufficientemente grande. Per questo possiamo usare un layout StackPane che dispone i nodi l'uno sopra l'altro, come in una pila. Quindi aggiungiamo le seguenti linee subito dopo la creazione del Text

StackPane sp = new StackPane(txt);
sp.setPrefHeight(80);

e sostituiamo la linea VBox vb = new VBox(txt, hb); con VBox vb = new VBox(sp, hb);.

Altri controlli

Aggiungiamo ora la possibilità di cambiare il nome delle fonte. Più precisamente il nome completo della fonte che comprende sia il nome della famiglia (ad es. Verdana o Monaco) che lo stile, se italico o normale, e il peso cioè più o meno in grassetto. Prima di tutto ci serve un metodo di utilità per poter modificare solamente il nome della fonte di un Text senza modificare la dimensione

/** Modifica il nome completo della fonte del nodo Text specificato.
 * @param t  un nodo Text
 * @param fName  il nuovo nome completo della fonte */
private static void tFName(Text t, String fName) {
    t.setFont(new Font(fName, t.getFont().getSize()));
}

Il controllo che conviene usare, dato il gran numero di possibili nomi di fonti, è il ComboBox<T> che è un bottone che premuto mostra una lista di valori possibili, di tipo T, tra cui scegliere. Quindi aggiungiamo le seguenti linee

ComboBox<String> ff = new ComboBox<>();  // Per il nome della fonte
ff.setPrefWidth(100);
ff.getItems().addAll(Font.getFontNames());  // I nomi delle fonti disponibili
ff.setValue(txt.getFont().getName());       // Imposta la font attuale
ff.setOnAction(e -> tFName(txt, ff.getValue()));

e modifichiamo la linea HBox hb = new HBox(showHide, size); con HBox hb = new HBox(showHide, size, ff);. Il metodo statico List<String> getFontNames() di Font ritorna la lista dei nomi di tutte le fonti disponibili nella piattaforma corrente.

Volendo cambiare anche il colore del testo, possiamo usare il controllo specializzato ColorPicker che permette all'utente di scegliere un colore. Aggiungiamo quindi le seguenti linee (e aggiungiamo cp al HBox hb)

ColorPicker cp = new ColorPicker();     // Per il colore del testo
cp.setValue(Color.BLACK);
cp.setOnAction(e -> {
    txt.setStroke(cp.getValue());
    txt.setFill(cp.getValue());
});

Abbiamo anche esteso la larghezza della scena da 400 a 600. Come ultimo tocco aggiungiamo un campo per poter cambiare il testo. Per questo c'è il controllo TextField che permette all'utente di inserire una linea di testo.

TextField tf = new TextField("Hello JavaFX");  // Per cambiare il testo
tf.setMaxWidth(400);
tf.setOnAction(e -> txt.setText(tf.getText()));

L'azione scatta quando l'utente digita l'ENTER sul campo di testo. Il nodo tf lo aggiungiamo al VBox vb.

In questa prima lezione su JavaFX abbiamo appena sfiorato la superficie di questa vasta libreria. Nelle prossime lezioni avremo modo di trattare molti altri strumenti.

Esercizi

[Layout]    Provare ad usare altri layout per disporre i controlli e il testo della piccola applicazione che è stata presentata nella lezione. Ad esempio, invece di usare VBox e HBox usare BorderPane.

[FamilyAndStyle]    Modificare l'applicazione in modo che l'utente possa scegliere il nome di famiglia della fonte, lo stile e il peso indipendentemente l'uno dall'altro e non tutti insieme tramite il nome completo della fonte. Si può usare il metodo statico List<String> getFamilies() di Font che ritorna la lista delle famiglie di fonti disponibili. Inoltre il metodo statico Font font(String family, FontWeight weight, FontPosture posture, double size) permette di ottenere una fonte con le specificate caratteristiche.

[Colori]    Modificare l'applicazione in modo tale che l'utente possa scegliere i tre colori: background, contorno (stroke) e riempimento (fill) del testo.

[Rettangolo]    Modificare l'applicazione aggiungendo un bottone che mostra e nasconde un rettangolo nero che racchiude il testo ed è sullo sfondo. Il rettangolo deve evere una dimensione appena superiore a quella del testo e deve quindi essere aggiornato tutte le volte che il testo cambia dimensione.

[Rate]    Scrivere un'applicazione con tre campi per l'inserimento dell'importo del capitale, il numero totale di rate, il tasso di interesse annuo e un bottone che quando cliccato calcola e mostra l'importo della rata. Ogni campo dovrebbe avere un'etichetta appropriata.

[Conversioni]    Scrivere un'applicazione per effettuare conversioni tra unità di misura.

[CompApp]    Scrivere un'applicazione che permette all'utente di effettuare vari tipi di calcoli, ad es. i coefficienti binomiali Lezione 10, i numeri di Fibonacci Lezione 13, numeri primi, la congettura di Collatz Lezione 15, ecc. Se il calcolo richiede tempi lunghi, l'utente dovrebbe avere la possibilità di interromperlo in qualsiasi momento.

[FileApp]    Scrivere un'applicazione per info sul file system. Ad es. permette di calcolare il numero totale di bytes di una directory Lezione 16 o altre informazioni come il numero di file di un certo tipo (immagini, testo, ecc.), ecc. Per far scegliere directory e file all'utente di possono usare i dialoghi DirectoryChooser e FileChooser.

[TheLatestApp]    Scrivere un'applicazione per interrogare servizi web. L'utente digita dei termini e questi sono ricercati in vari servizi web Lezione 14. L'applicazione deve rimane reattiva durante l'esecuzione delle interrogazioni. I risultati possono essere visualizzati tramite una TextArea non editabile.

3 Mag 2016


  1. Se la classe è creata tramite l'IDE IntelliJ si può usare il template JavaFXApplication che prepara automaticamente l'estensione di Application, il main e il metodo start.

  2. Purtroppo JavaFX non offre nessun modo agevole per modificare una proprietà di una fonte lasciando invariate le altre (il nome, la dimensione, lo stile).