Metodologie di Programmazione: Lezione 18
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.
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à.
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 (unBooleanBinding
) che assume valoretrue
quando la stringa osservabileop1
è maggiore della stringa costanteop2
.
NumberBinding min(ObservableNumberValue op1, int op2)
crea e ritorna un osservabile numerico (unNumberBinding
) che assume valore pari al minimo tra il numero osservabileop1
e l'intero costanteop2
.
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.
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
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.
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:
name
inizia con '/'
, il nome assoluto è la porzione di name
che segue '/'
.mod_package/name
dove mod_package
è il package della classe relativamente alla quale è stato invocato getResource
con i caratteri '.'
sostituiti da '/'
.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:
[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
Se non fosse così, la GUI rimarrebbe bloccata finché il downloading non termina.↩
C'è anche il metodo bindBidirectional(Property<T> other)
per definire binding bidirezionali.↩