Metodologie di Programmazione: Lezione 19

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 il tutto (testo, layout, immagini, link, ecc.) e rispondere agli eventi prodotti dall'utente. JavaFX offre la componente WebView, nel package javafx.scene.web, che è una specie di finestra su la vera e propria 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(). Iniziamo quindi a realizzare il nostro browser. La finestra principale contiene per ora solamente un campo di testo per poter digitare l'URL di una pagina web e una WebView che scaricherà le pagine e le renderà.

package mp.gui;

import javafx.application.Application;
import javafx.scene.*;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.scene.web.WebEngine;
import javafx.scene.web.WebView;
import javafx.stage.Stage;

/** 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 e ritorna la componente principale della UI.
     * @return la componente principale delle UI */
    private Parent createUI() {
        wView = new WebView();
        WebEngine we = wView.getEngine();
        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 della web view
        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 asincrono, vedremo fra poco come si può 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.

Binding

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, possiamo ottenere la history e la lista dei suoi elementi una volta per tutte

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

Possiamo stare 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 cambieranno nel tempo. Adesso possiamo creare 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 precedente a 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. Quindi dobbiamo legare l'indice della pagina corrente alla disabilitazione del bottone back. Più precisamente, 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 e la disabilitazione è una proprietà generale di Node che si può ottenere con il metodo BooleanProperty disableProperty(). Possiamo allora usare il metodo void bind(ObservableValue<? extends T> observable) dell'interfaccia Property<T> implementata da tutti i tipi di proprietà mutabili. Il metodo bind prende in input un valore observable che è appunto osservabile, quando observable cambia anche il valore della proprietà è modificato allo stesso modo. Nel nostro caso abbiamo una proprietà i cui valori sono interi mentre la proprietà che vogliamo legare a questa ha valori booleani. Possiamo usare il metodo BooleanBinding isEqualTo(int other) che è uno dei tanti metodi di utilità che le varie proprietà mettono a disposizione per trasformare un osservabile in un altro osservabile.

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

Possiamo creare analogamente 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. Internamente l'intero framework dei binding è implementato tramite listener. 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 all'HBox dei controlli,

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

Eseguendo la nuova versione,

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

Menu contestuali

I web browser non hanno solamente i bottoni per la navigazione ma possono mostrare la lista delle pagine precedenti e quella delle pagine successive. Potremmo quindi creare 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. Un qualsiasi nodo può avere un menu contestuale. Ed è sufficiente usare un solo metodo void setOnContextMenuRequested(EventHandler<? super ContextMenuEvent> value) di Node. Il metodo imposta un gestore di eventi value che sarà invocato ogni volta che l'utente chiede il menu contestuale su quel nodo. Tale gestore tipicamente crea (o lo prende da qualche parte) un appropriato menu contestuale e lo rende visibile. Ci manca di sapere come creare un menu contestuale. 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 -> {
    int curr = h.getCurrentIndex();
    if (curr < 1) return;
    ContextMenu cm = new ContextMenu();
    for (int i = curr - 1; i >= 0; 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(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 -> {
    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 corrispondentemente modificato. Per poterlo fare dobbiamo essere avvertiti di quando un 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());
    } else if (nv == Worker.State.FAILED || nv == Worker.State.CANCELLED) { }
});

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

L'implementazione del mini Web Browser continua nella prossima lezione.

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 WebView.

[Zoom]    Aggiungere un controllo al mini Web Browser che permette di fare lo zoom in e out della pagina visualizzata. Ad esempio può essere usato Spinner o ChoiceBox<T>.

15 Mag 2015