Metodologie di Programmazione: Lezione 18

Riccardo Silvestri

JavaFX: mini Web Browser

Continuiamo l'esplorazione degli strumenti offerti da JavaFX per la costruzione di GUI. In questa lezione iniziamo l'implementazione di un mini Web Browser. Questo ci permetterà di vedere all'opera layout e molti controlli.

Mini Web Browser

Il componente fondamentale per rendere pagine web è una cosiddetta web engine un sistema software piuttosto complesso e sofisticato capace di interpretare sia HTML/CSS che codice JavaScript, comunicare con server remoti, visualizzare testo, link, layout, immagini, video, ecc. e rispondere agli eventi prodotti dall'utente. JavaFX offre la componente WebView, nel package javafx.scene.web, che è una specie di finestra su una web engine rappresentata da un oggetto di tipo WebEngine, sempre nello stesso package. Quando una WebView è creata, crea anche una WebEngine che è disponibile tramite il metodo WebEngine getEngine().

La finestra principale del nostro Web Browser conterrà per ora solamente un campo di testo per digitare l'URL di una pagina web e una WebView che scaricherà le pagine e le renderà graficamente.

package gui;

...

/** Un mini web browser */
public class Browser extends Application {
    public static void main(String[] args) { launch(args); }

    @Override
    public void start(Stage primaryStage) {
        Scene scene = new Scene(createUI(), 700, 400);
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    /** Crea lo scene graph della UI e ne ritorna la radice.
     * @return la radice dello scene graph */
    private Parent createUI() {
        WebView wView = new WebView();      // Visualizza pagine web
        WebEngine we = wView.getEngine();   // La sottostante web engine
        TextField url = new TextField();    // Per immettere l'URL delle pagine
        url.setOnAction(e -> we.load(url.getText()));
        HBox hb = new HBox(url);    // Per i controlli, per ora solo il campo
        VBox vb = new VBox(hb, wView);
        HBox.setHgrow(url, Priority.ALWAYS);   // Si estende in orizzontale
        VBox.setVgrow(wView, Priority.ALWAYS); // Si estende in verticale
        return vb;
    }
}

Eseguendolo e digitando l'URL http://www.di.uniroma1.it,

Per il campo di testo url abbiamo impostato l'azione che sarà eseguita ogni volta che si digita l'ENTER. Questa invoca il metodo void load(String u) della web engine per accedere alla risorsa web con URL u. Il metodo ritorna immediatamente perché il downloading della risorsa avviene in modo asincrono1, vedremo fra poco come controllare tale task asincrono.

Abbiamo usato un layout HBox per contenere il campo url, ci aggiungeremo poi altri controlli. Infine abbiamo un VBox per contenere i controlli e la web view. Siccome vogliamo che il campo url possa estendersi in larghezza occupando tutto lo spazio disponibile abbiamo usato il metodo statico void setHgrow(Node child, Priority value) di HBox che permette di impostare tale comportamento con la costante Priority.ALWAYS. In modo analogo impostiamo che la web view possa estendersi anche in verticale.

Ora vogliamo aggiungere due bottoni che permettono di navigare indietro e avanti nella sequenza delle pagine che sono state già visitate, come in un qualsiasi web browser. Ci aiuta la web engine che mantiene la history delle pagine in un oggetto WebHistory che è ritornato dal metodo WebHistory getHistory(). Quindi, nel metodo createUI, otteniamo la history e la lista dei suoi elementi una volta per tutte

 WebHistory h = we.getHistory();
 ObservableList<WebHistory.Entry> hList = h.getEntries();

Stiamo tranquilli perché sia getHistory che il metodo ObservableList<WebHistory.Entry> getEntries() ritornano sempre gli stessi oggetti per una fissata web engine, anche se gli elementi della lista cambiano. Creiamo il bottone per andare indietro nella history,

Button back = new Button("<");
back.setOnAction(e -> h.go(-1));

Il metodo void go(int offset) throws IndexOutOfBoundsException di WebHistory fa sì che la web engine vada alla pagina della history all'indice curr + offset, dove curr è l'indice corrente. Quindi con -1 andiamo alla pagina che precede quella corrente. Per evitare che quando non c'è una pagina precedente l'invocazione di go(-1) vada in errore è necessario che il bottone back sia disabilitato quando la pagina corrente ha indice 0. Dobbiamo legare l'indice della pagina corrente alla disabilitazione del bottone back, cioè, se l'indice delle pagina corrente è 0, back deve essere disabilitato, altrimenti deve essere abilitato. L'indice della pagina corrente è una proprietà ottenibile con il metodo ReadOnlyIntegerProperty currentIndexProperty() di WebHistory. Potremmo introdurre un listener di tale proprietà che, similmente alla lezione scorsa, imposta l'abilitazione/disabilitazione del bottone con il metodo setDisable(boolean value), però JavaFX offre anche un altro meccanismo per legare i valori di due proprietà.

Binding

Casi come quello della history e il bottone back, in cui i valori di due proprietà hanno una qualche dipendenza o legame, sono piuttosto frequenti. Per questo JavaFX offre un meccanismo generale per definire come e quando modificare il valore di una proprietà in dipendenza del cambiamento del valore di un'altra proprietà. Il meccanismo si chiama binding e il primo pilastro su cui poggia è il metodo void bind(ObservableValue<? extends T> observable) dell'interfaccia Property<T>2. Per spiegarne il significato, consideriamo che P sia una proprietà, cioè un oggetto di tipo Property<T>, e O sia un ObservableValue<? extends T> (si tenga presente che Property<T> è una sotto-interfaccia di ObservableValue<T>), allora il binding P.bind(O) ha l'effetto che quando il valore di O cambia anche il valore di P è impostato al nuovo valore. Chiaramente, da solo, tale metodo è quasi inutile perché lega solamente proprietà con valori dello stesso tipo (o compatibili), la modifica è sempre innescata e il valore modificato è sempre uguale a quello della proprietà osservata. Infatti, è necessario il secondo pilastro del binding rappresentato da oggetti che implementano l'interfaccia Binding<T> o altre sotto-interfacce simili di ObservableValue<T>. Questi oggetti creano degli osservabili con valori di tipo T il cui valore cambia secondo certe regole in dipendenza dei cambiamenti di valore di altri osservabili. La classe Bindings contiene molti metodi statici che forniscono binding di utilità, come ad esempio

BooleanBinding greaterThan(ObservableStringValue op1, String op2) crea e ritorna un osservabile booleano (un BooleanBinding) che assume valore true quando la stringa osservabile op1 è maggiore della stringa costante op2.
NumberBinding min(ObservableNumberValue op1, int op2) crea e ritorna un osservabile numerico (un NumberBinding) che assume valore pari al minimo tra il numero osservabile op1 e l'intero costante op2.

Di questi metodi la classe Bindings ne ha più di 200. Ovviamente, i bindings semplici, come quelli di Bindings, possono essere combinati per costruire bindings più complessi. Comunque è bene sottolineare che il meccanismo del binding è implementato tramite i listener dei sottostanti osservabili.

Vediamo quindi come definire il binding che disabilita il bottone back quando la currentIndexProperty della history ha valore 0. La proprietà che regola la disabilitazione è una proprietà generale di Node che si ottiene con il metodo BooleanProperty disableProperty(). Dobbiamo legare il valore di tale proprietà al valore di currentIndexProperty e in particolare quando currentIndexProperty diventa 0 la disableProperty deve diventare true, altrimenti è false. Possiamo usare il metodo BooleanBinding isEqualTo(int other) di ReadOnlyIntegerProperty (il tipo di currentIndexProperty), che è uno dei tanti metodi di utilità che le varie proprietà mettono a disposizione per trasformare un osservabile in un altro osservabile. L'effetto di

back.disableProperty().bind(h.currentIndexProperty().isEqualTo(0));

è esattamente quello voluto.

Introduciamo anche il bottone per andare avanti nella history

Button forth = new Button(">");
forth.setOnAction(e -> h.go(1));
forth.disableProperty().bind(Bindings.createBooleanBinding(
        () -> h.getCurrentIndex() >= hList.size() - 1,
        h.currentIndexProperty()));

Qui il binding è un po' più complicato. Abbiamo dovuto usare il metodo statico BooleanBinding createBooleanBinding(Callable<Boolean> func, Observable... dependencies) della classe Bindings che quando uno degli osservabili dependencies cambia valore l'oggetto func è usato per determinare il nuovo valore booleano. Nel nostro caso quando la currentIndexProperty cambia, il nuovo valore booleano è determinato eseguendo il test h.getCurrentIndex() >= h.getEntries().size() - 1.

Chiaramente si sarebbero potuti usare dei ChangeListener al posto dei binding. I binding risultano convenienti quando le espressioni che legano due proprietà sono sufficientemente semplici, altrimenti è meglio usare i listener.

Rimane solamente di aggiungere i bottoni back e forth al layout HBox dei controlli,

HBox hb = new HBox(back, forth, url);

Eseguendo la nuova versione,

possiamo navigare indietro e avanti tra le pagine già visitate.

Menu contestuali

I web browser oltre ai bottoni per la navigazione mostrano la lista delle pagine precedenti e quella delle pagine successive. Introduciamo quindi sul bottone back un menu contestuale che mostra la lista delle pagine precedenti a quella corrente. Questo ci permetterà di vedere quanto sia facile in JavaFX creare e gestire menu contestuali. Ogni nodo può avere un menu contestuale ed è sufficiente usare un solo metodo void setOnContextMenuRequested(EventHandler<? super ContextMenuEvent> handler) di Node. Il metodo imposta un gestore di eventi handler che sarà invocato ogni volta che l'utente chiede il menu contestuale su quel nodo. Il gestore tipicamente crea (o lo prende da qualche parte) un appropriato menu contestuale e lo rende visibile. Il controllo ContextMenu gestisce completamente un menu contestuale che in sostanza è una finestra di popup che contiene una lista di voci di menu. Le voci del menu sono gestite da controlli di tipo MenuItem.

 back.setOnContextMenuRequested(e -> {     // Menu contestuale pagine precedenti
     int curr = h.getCurrentIndex();
     if (curr < 1) return;    // Se non ci sono pagine precedenti, esce
     ContextMenu cm = new ContextMenu();    // Altrimenti, crea un menu contestuale
     for (int i = curr - 1; i >= 0; i--) {  // Aggiunge una voce di menu per
         int offset = i - curr;             // ogni pagina precedente
         MenuItem mi = new MenuItem(hList.get(i).getUrl());
         mi.setOnAction(v -> h.go(offset));  // Ad ognuna associa l'azione di
         cm.getItems().add(mi);              // di andare alla pagina
     }
     cm.setAutoHide(true);  // Diventa invisibile non appena perde il focus
     cm.show(back.getScene().getWindow(), e.getScreenX(), e.getScreenY());
 });

Quindi il menu contestuale dopo essere stato creato viene riempito di MenuItem ognuno dei quali ha un testo relativo all'URL di una pagina della history che precede la pagina corrente. Inoltre l'azione porta la web engine sulla pagina selezionata. Il metodo void show(Node anchor, double screenX, double screenY) di ContextMenu, rende visibile il menu contestuale alle specificate coordinate di schermo e queste possono essere ottenute dall'evento e (sono precisamente le coordinate del mouse).

In modo del tutto analogo possiamo aggiungere un menu contestuale anche per il bottone forth relativamente alle pagine successive a quella corrente.

forth.setOnContextMenuRequested(e -> {    // Menu contestuale pagine successive
    int curr = h.getCurrentIndex();
    if (curr >= h.getEntries().size() - 1) return;
    ContextMenu cm = new ContextMenu();
    for (int i = curr + 1; i < hList.size(); i++) {
        int offset = i - curr;
        MenuItem mi = new MenuItem(hList.get(i).getUrl());
        mi.setOnAction(v -> h.go(offset));
        cm.getItems().add(mi);
    }
    cm.setAutoHide(true);
    cm.show(forth.getScene().getWindow(), e.getScreenX(), e.getScreenY());
});

Eseguendo questo nuova versione

Task asincroni

Si sarà notato che quando si naviga a un'altra pagina cliccando su un link, il testo del campo url non viene modificato. Per poterlo modificare dobbiamo essere avvertiti di quando una nuova pagina è caricata nella web engine. Come abbiamo già detto il downloading di una nuova pagina nella web engine è effettuato in modo asincrono. Più precisamente la web engine usa un oggetto di tipo Worker<V>, nel package javafx.concurrent, per eseguire in modo asincrono il task del downloading di una pagina. Il package javafx.concurrent offre alcune classi e interfacce (di cui Worker<V> è l'interfaccia base) che sono utili per implementare task che devono lavorare in background e che devono anche interagire con le componenti grafiche di JavaFX. Il metodo Worker<Void> getLoadWorker() di WebEngine, ritorna il Worker usato dalla web engine. Un Worker rende disponibile il suo stato tramite il metodo ReadOnlyObjectProperty<Worker.State> stateProperty(). Quindi possiamo aggiungere un ChangeListener a tale proprietà per essere avvertiti quando il Worker termina con successo il downloading, cioè quando entra nello stato Worker.State.SUCCEEDED,

we.getLoadWorker().stateProperty().addListener((o, ov, nv) -> {
    if (nv == Worker.State.SUCCEEDED)
        url.setText(we.getLocation());
});

Quando il downloading termina con successo impostiamo il testo del campo url alla nuova locazione.

Icone e risorse

Ora vogliamo usare delle icone per i due bottoni. Le immagini delle icone e qualsiasi altro tipo di risorse come file CSS, conviene che siano contenute in una directory separata da quelle che contengono i file sorgenti. Ad esempio si può usare una directory chiamata resources. Inoltre se una risorsa è usata da una classe con nome completo base.sub.MyClass, allora la risorsa dovrebbe essere messa nella directory resources/base/sub, cioè il percorso relativo della risorsa ricalca quello dei package della classe. Così la JVM, come vedremo, riesce più facilmente a individuare le risorse. Ma non solo, in questo modo le risorse sono naturalmente separate in funzione delle classi che le usano. Se stiamo usando un IDE come IntelliJ la directory base per le risorse la possiamo creare alla stesso livello della directory che contiene i sorgenti (chiamata di solito src). Inoltre dopo averla creata, usando il menu contestuale su di essa possiamo scegliere dal menu Mark Directory As di marcarla come Resources Root. Così l'IDE imposterà il class path in modo da contenere anche il percorso della directory resources. Questo garantirà che durante l'esecuzione la JVM troverà le risorse.

Poniamo in resources/mp/gui due immagini left16.png e right16.png, da usare come icone per i bottoni. Per impostare l'immagine per il bottone back sostituiamo la linea

Button back = new Button("<");

con

Image backIcon = new Image(getClass().getResource("left16.png").toString());
Button back = new Button(null, new ImageView(backIcon));

L'invocazione getClass().getResource("left16.png") ritorna l'URL della risorsa con nome "left16.png". Il metodo URL getResource(String name) di Class<T>, cerca la risorsa di nome name tramite il class loader della classe sul quale è invocato (nel nostro caso è invocato relativamente all'oggetto Class<?> della classe Browser che è ritornato dall'invocazione getClass()). Parleremo in dettaglio dei class loader più avanti. Il metodo getResource non passa direttamente il name al class loader ma gli passa il nome assoluto della risorsa. Il nome assoluto è costruito nel seguente modo:

Quindi nel nostro caso il nome assoluto della risorsa "left16.png" sarà "mp/gui/left16.png". Se il percorso assoluto della directory resources è nel class path siamo sicuri che la risorsa sarà trovata. In caso contrario il metodo getResource ritornerà null.

Prima di poter visualizzare l'icona del bottone, l'immagine deve essere caricata in memoria e rappresentata tramite un oggetto Image del package javafx.scene.image. E questo può farlo il costruttore di Image. Il tipo Image non essendo un sotto-tipo di Node non può visualizzare l'icona sul bottone. Dobbiamo quindi creare un oggetto capace di visualizzare immagini e questo è ImageView, sempre in javafx.scene.image, che è un sotto-tipo di Node. Il primo argomento passato al costruttore di Button è null per indicare che non c'è testo.

Per l'icona delll'altro bottone possiamo procedere in modo analogo sostituendo le linee

Button forth = new Button(">");

con le linee

Image forthIcon = new Image(getClass().getResource("right16.png").toString());
Button forth = new Button(null, new ImageView(forthIcon));

Ed ecco il risultato:

Esercizi

[URL]    Modificare l'azione del campo url in modo tale che se la stringa digitata non contiene nessuno schema, inserisce lo schema di default http:// prima di sottometterlo alla WebEngine.

[Progress]    Aggiungere un controllo che mostra il progresso durante il downloading di una pagina. Si può usare il controllo ProgressIndicator o ProgressBar legando la ProgressProperty del controllo con quella del Worker della WebEngine.

[Zoom]    Aggiungere un controllo al mini browser che permette di fare lo zoom in e out della pagina visualizzata. Si può usare uno Spinner o un ChoiceBox<T> e il metodo setZoom(double value) di WebView o la corrispondente proprietà zoomProperty.

[Bookmarks]    Aggiungere un bottone che permette di fare il bookmark della pagina che è visualizzata dalla WebView. I bookmark possono essere mantenuti in una lista ListView contenuta in un'apposita finestra (Stage). Gli indirizzi possono essere resi tramite Hyperlink.

[Bookmarks+]    Migliorare l'esercizio precedente visualizzando oltre all'indirizzo della pagina anche una sua immagine. Un'istantanea (uno snapshot) della WebView può essere presa tramite il metodo WritableImage snapshot(SnapshotParameters params, WritableImage image) di Node.

[History]    Aggiungere al mini browser una history globale di tutte le pagine visitate. Si potrebbe aggiungere un bottone (o un altro controllo) che mostra una finestra (Stage) con la lista ListView con tutte le pagine visitate finora (senza ripetizioni).

6 Mag 2016


  1. Se non fosse così, la GUI rimarrebbe bloccata finché il downloading non termina.

  2. C'è anche il metodo bindBidirectional(Property<T> other) per definire binding bidirezionali.