TableView ja databinding
Osassa 7 tehtävät näytettiin VBox-säiliöissä CheckBox-komponentteja. Tämä on
oikein hyvä tapa opetella käyttöliittymän perusidea: luodaan komponentteja ja
lisätään ne näkymään. Huomasimme edellisessä osassa, että mallin ja
käyttöliittymän erottaminen toisistaan vaatii paivitaNakyma()-metodia, joka
kutsutaan aina, kun malli muuttuu. Näkymän päivittäminen mallin muuttuessa voi
kuitenkin osoittautua pullonkaulaksi sovelluksen koon kasvaessa, kun näkymän
päivittämisen optimointi on itsessään hankala ongelma, johon ei tämän kurssin
puitteissa pureuduta.
Tässä kohtaa onkin parempi nojautua JavaFX:n valmiin näkymäkomponentteihin,
jotka osaavat tehokkaasti esittää olioita ja reagoida niiden muutoksiin. Otamme
tässä osassa käyttöön TableView-komponentin. TableView on JavaFX:n valmis
komponentti, jolla olioita voidaan esittää riveinä ja olioiden ominaisuuksia
sarakkeina. TableView-komponenttia voi ajatella taulukkona, jossa jokainen
rivi on yksi tehtävä ja sarakkeet ovat ominaisuuksia, joita tehtävästä halutaan
näyttää, kuten otsikko, prioriteetti ja se, onko tehtävä tehty vai ei.
Esivalmistelu: Tehtävä-luokka havaittavaksi
JavaFX:n TableView osaa reagoida olioiden määrän muutosten lisäksi olioiden
sisällä tapahtuneeseen muutokseen. Esimerkiksi jos jonkun tehtävän otsikkoa
muutetaan, TableView osaa havaita muutoksen automaattisesti. Tätä varten
kuitenkaan pelkästään ObservableList-listan käyttö ei riitä, koska se osaa
ilmoittaa kuuntelijoille vain tehtävien lisäämistä ja poistamista. Sen sijaan
meidän tulee tehdä itse tehtävä-olio ja myös sen ominaisuudet havaittaviksi.
Olion yksittäisiä ominaisuuksia voidaan muuttaa havaittaviksi käyttäen JavaFX:n
niin kutsuttuja property-tyyppejä. Property-tyypit "käärivät" tavalliset arvot,
kuten Boolean tai String, ja tarjoavat mekanismin ilmoittaa, kun niiden arvo
muuttuu. Esimerkiksi StringProperty on havaittava versio String-tyypistä,
BooleanProperty vastaavasti Boolean-tyypistä ja niin edelleen.
Tarvitsemme Tehtava-luokkaamme siis merkkijono- ja totuusarvotyyppien
havaittavat versiot. Kun aiemmin tehtävällä oli tavallinen boolean tehty
-muuttuja, joka oli piilotettu ohjelman uumeniin, muutamme sen nyt
observable-tyyppiseksi. Vastaava muutos tehdään myös tehtävän tekstille.
Näille löytyy JavaFX:stä valmiit toteutukset: SimpleStringProperty ja
SimpleBooleanProperty.
import javafx.beans.property.*;
public class Tehtava {
// Alkuperäiset attribuutit on korvattu Property-kääreillä
private final StringProperty teksti = new SimpleStringProperty("");
private final BooleanProperty tehty = new SimpleBooleanProperty(false);
@SuppressWarnings("unused")
public Tehtava() {}
public Tehtava(String teksti, boolean tehty) {
setTeksti(teksti);
setTehty(tehty);
}
// --- Property-setterit ja getterit ---
// Huomaa, että JavaFX-tyylissä on tapana tarjota kolme metodia per property:
// 1. Tavallinen get-metodi (palauttaa esim. boolean)
// 2. Tavallinen set-metodi (ottaa esim. boolean)
// 3. property-metodi (palauttaa itse Property-olion, esim. BooleanProperty)
public boolean getTehty() { return this.tehty.get(); }
public void setTehty(boolean tehty) { this.tehty.set(tehty); }
public BooleanProperty tehtyProperty() { return this.tehty; }
public String getTeksti() { return this.teksti.get(); }
public void setTeksti(String teksti) { this.teksti.set(teksti); }
public StringProperty tekstiProperty() { return this.teksti; }
@Override
public String toString() {
return getTeksti() + ": " + (getTehty() ? "TEHTY" : "EI TEHTY");
}
}
Nyt yksittäisen tehtävän ominaisuudet ovat observable eli havaittavia. Tämä mahdollistaa käyttöliittymän näkymän sitomisen (engl. binding) dataan, ja näkymä päivittyy kun arvo muuttuu datassa. Toisaalta kun käyttäjä muuttaa arvoa käyttöliitymässä, muutos päivittyy samaan propertyyn. Palaamme tähän ajatukseen seuraavaksi tarkemmin.
TableView-komponentin lisääminen ja käyttöliittymän siistiminen
Aloitetaan näkymästä: avaa main.fxml SceneBuilderissa.
Poista käyttöliittymästä tehdyt ja tekemattomat VBox-komponentit sekä
niihin liittyvät nimiöt. Poistamisen jälkeen hierarkiaan jää vain
HBox-komponentti, jossa on syötekenttä ja painike:
Sen jälkeen etsi Library-näkymästä TableView-komponentti ja lisää se
syötekentän yläpuolelle. Ole tarkkana: valitse nimenomaan TableView, ei
TreeTableView.
Valitse lisätty TableView ja aseta sen Vgrow-asetukseksi ALWAYS, jotta se
täyttää kaiken vapaan tilan. Anna vielä TableView-komponentille fx:id-arvoksi
tehtavaTaulu.
Lopuksi, valitse ja poista Hierarchy-paneelista kummatkin TableColumn-komponentit. Lopullinen hierarkia näyttää siis täältä:
Tallenna FXML-tiedosto.
Vielä muutama sana ennen kuin jatketaan. TableView on itse taulukko. Se näyttää rivejä, mutta ei vielä tiedä, millaisia olioita rivit ovat. Se tieto annetaan kontrollerin puolella Java-koodissa – teemme tämän kohta. TableView sisältää useita TableColumn-komponentteja, jotka esittävät olion ominaisuuksia sarakkeina. Tieto siitä, mitä sarakkeessa näytetään, annetaan myös Java-koodissa. Tämänkin teemme ihan pian.
Siistitään nyt MainController-luokka. Ensiksi, poista vanhat VBox tekemattomat- ja VBox tehdyt-attribuutit ja lisää niiden tilalle
TableView<Tehtava> tehtavaTaulu-attribuutti:
// HIGHLIGHT_RED_BEGIN
@FXML
private VBox tekemattomat;
@FXML
private VBox tehdyt;
// HIGHLIGHT_RED_END
// HIGHLIGHT_GREEN_BEGIN
@FXML
private TableView<Tehtava> tehtavaTaulu;
// HIGHLIGHT_GREEN_END
Huomaa, että tehtavaTaulu-attribuutin tyyppi on TableView<Tehtava>.
TableView on geneerinen luokka, joka ottaa tyyppiparametrina sen olion tyypin,
jota taulukossa on tarkoitus näyttää. Meidän tapauksessamme taulukossa on
Tehtava-olioiden tietoja.
Muutoksen myötä näkymän päivittämisen tarkoitettu paivitaNakyma()-metodi
muuttuu turhaksi, sillä jatkossa TableView hoitaa näkymän päivittämisen
automaattisesti itse. Poistetaan siis paivitaNakyma()-metodi:
// Poista koko metodi; TableView hoitaa näkymän päivittämisen itse
// HIGHLIGHT_RED_BEGIN
private void paivitaNakyma() {
// ...
// Tyhjennetään nykyiset listat
tekemattomat.getChildren().clear();
tehdyt.getChildren().clear();
// Rakennetaan näkymä uudestaan tehtävien perusteella.
// Metodi luoCheckBox(tehtava) saa nyt koko olion parametrina.
for (Tehtava tehtava : tehtavat) {
CheckBox cb = luoCheckBox(tehtava);
if (tehtava.getTehty()) {
tehdyt.getChildren().add(cb);
} else {
tekemattomat.getChildren().add(cb);
}
}
}
// HIGHLIGHT_RED_END
Vastaavasti initialize()-metodissa oleva tehtavat-listan kuuntelijasta
voidaan poistaa paivitaNakyma()-kutsu:
public void initialize(URL url, ResourceBundle resourceBundle) {
tehtavat.addListener((ListChangeListener<Tehtava>) change -> {
// HIGHLIGHT_RED_BEGIN
paivitaNakyma();
// HIGHLIGHT_RED_END
tallenna();
});
// metodin loppuosa piilotettu...
lataa();
uusiTehtavaNimi.setOnAction(event -> lisaaTehtava());
lisaaUusiTehtavaPainike.setOnAction(event -> lisaaTehtava());
}
Tehtävien ja ominaisuuksien sitominen taulukkoon
Kun FXML-rakenne on määritelty, sidomme taulukon näkymän ja mallin yhteen.
Ensiksi sidomme tehtavat-listan taulukkoon käyttäen setItems()-metodia:
public void initialize(URL url, ResourceBundle resourceBundle) {
// HIGHLIGHT_GREEN_BEGIN
tehtavaTaulu.setItems(tehtavat);
// HIGHLIGHT_GREEN_END
// metodin loppuosa piilotettu...
tehtavat.addListener((ListChangeListener<Tehtava>) change -> {
tallenna();
});
lataa();
uusiTehtavaNimi.setOnAction(event -> lisaaTehtava());
lisaaUusiTehtavaPainike.setOnAction(event -> lisaaTehtava());
}
Kuten edellisessä osassa ListView-komponentin esimerkissä, tässä sidomme
tehtävälistan datan taulukkonäkymään. JavaFX lisää setItems()-kutsun myötä
listalle havaitsijan ja päivittää taulukon rivit aina, kun tehtävälistassa
olevia tehtäviä poistetaan tai lisätään.
Seuraavaksi määrittelemme taulukon sarakkeet ja sidomme tehtävän yksittäiset
attribuutit sarakkeisiin. Luodaan aluksi tehty-attribuutille sarake:
public void initialize(URL url, ResourceBundle resourceBundle) {
tehtavaTaulu.setItems(tehtavat);
// HIGHLIGHT_GREEN_BEGIN
/* 1 */ TableColumn<Tehtava, Boolean> tehtySarake = new TableColumn<>("Tehty");
/* 2 */ tehtySarake.setCellValueFactory(cd -> cd.getValue().tehtyProperty());
/* 3 */ tehtavaTaulu.getColumns().add(tehtySarake);
// HIGHLIGHT_GREEN_END
// metodin loppuosa piilotettu...
tehtavat.addListener((ListChangeListener<Tehtava>) change -> {
tallenna();
});
lataa();
uusiTehtavaNimi.setOnAction(event -> lisaaTehtava());
lisaaUusiTehtavaPainike.setOnAction(event -> lisaaTehtava());
}
Tämä näyttää hieman hurjalta, joten palastellaan vihreällä merkitty koodi rivi riviltä.
- Luodaan uusi sarake alustamalla
TableColumn<Tehtava, Boolean>-olio. Ensimmäinen tyyppiparametriTehtavakertoo, minkä typpisiä olioita taulukon riveillä on. Toinen tyyppiparametriBooleankertoo, minkä tyyppisiä arvoja sarakkeessa näytetään. Merkkijono "Tehty" on sarakkeen otsikko, joka näkyy taulukossa. - Sidotaan
tehtySarake-saraketehtyProperty-ominaisuuden arvoon.setCellValueFactory()määrittää, mistä oliosta tai ominaisuudesta sarakkeen näytettävä arvo otetaan. JavaFX kutsuu tässä annettua lambdalauseketta aina, kun se tarvitsee juuri tässä sarakkeessa näytettävän arvon. Lambdan parametricd("cell data") sisältää tiedon, minkä rivin tietoja ollaan käsittelemässä, jacd.getValue()palauttaa kyseisen rivinTehtava-olion. EdelleenTehtava-oliontehtyProperty()sisältääObservableValue<Boolean>-olion, eli juurikin sellaisen, jotaTableViewpystyy seuraamaan. - Lisätään sarake näkyviin tauluun.
Tehdään sarake myös tehtävän tekstille.
public void initialize(URL url, ResourceBundle resourceBundle) {
tehtavaTaulu.setItems(tehtavat);
TableColumn<Tehtava, Boolean> tehtySarake = new TableColumn<>("Tehty");
tehtySarake.setCellValueFactory(cd -> cd.getValue().tehtyProperty());
tehtavaTaulu.getColumns().add(tehtySarake);
// HIGHLIGHT_GREEN_BEGIN
TableColumn<Tehtava, String> tekstiSarake = new TableColumn<>("Tehtävä");
tekstiSarake.setCellValueFactory(cd -> cd.getValue().tekstiProperty());
tehtavaTaulu.getColumns().add(tekstiSarake);
// HIGHLIGHT_GREEN_END
// metodin loppuosa piilotettu...
tehtavat.addListener((ListChangeListener<Tehtava>) change -> {
tallenna();
});
lataa();
uusiTehtavaNimi.setOnAction(event -> lisaaTehtava());
lisaaUusiTehtavaPainike.setOnAction(event -> lisaaTehtava());
}
Valinnaista lisätietoa: (1) Tehdasmetodi eli factory method -malli. (2) "Luo ensin, konfiguroi sitten".
Sana factory (esim. setVellValueFactory) viittaa tehdasmetodi-malliin, joka on olio-ohjelmoinnin
suunnittelumalli. Tehdasmetodissa olioiden luominen on eriytetty erilliseen
metodiin, joka toimii ikään kuin tehtaan tapaan. Tehdasmetodi tarjoaa tavan
luoda olioita ilman, että kutsujan tarvitsee tietää tarkalleen, miten olio
luodaan tai mitä parametreja tarvitaan.
Yksinkertainen esimerkki:
public class Car {
private static int carCount = 0;
private Car() {
// Yksityinen konstruktori estää suoran olioiden luomisen
}
public static Car createCar() {
carCount++;
return new Car();
}
public static int getCarCount() {
return carCount;
}
}
Tällöin olioita luodaan näin:
public class Main {
public static void main(String[] args) {
Car car1 = Car.createCar();
Car car2 = Car.createCar();
System.out.println("Total cars created: " + Car.getCarCount()); // Tulostaa: Total cars created: 2
}
}
Tässä createCar() on tehdasmetodi: se luo auton ja voi samalla tehdä muuta
hyödyllistä työtä, kuten kasvattaa laskuria.
JavaFX:n TableColumn-tapauksessa kiinnostavampi ajatus ei kuitenkaan ole aivan
tehdasmetodi, vaan API-suunnittelun malli, jossa olio luodaan ensin ja sitä
konfiguroidaan vasta sen jälkeen erillisillä metodeilla.
Tällainen malli ei ole aina hyvä tavallisille sovellusolioille, kuten autoille tai pankkitileille, koska niiden olisi usein hyvä olla heti konstruktorin jälkeen käyttökelpoisessa tilassa. Käyttöliittymäkirjastojen komponenteissa tilanne on toinen: ne ovat usein tarkoituksella vaiheittain konfiguroitavia olioita.
Juuri tämä muistuttaa sitä, miten JavaFX:n TableColumn-API on suunniteltu.
Ensin luodaan sarake:
TableColumn<Tehtava, Boolean> tehtySarake = new TableColumn<>("Tehty");
Sen jälkeen sarakkeelle asetetaan erikseen sen toimintaan liittyviä asioita, kuten:
- mistä solujen arvot haetaan (
setCellValueFactory()) - miten solut piirretään (
setCellFactory()) - voiko saraketta muokata
- miten leveä sarake on
- voiko saraketta lajitella
- millaista tyyliä sarake käyttää
Tämä on Java-kirjastoissa hyvin tavallinen tapa suunnitella rajapintoja:
olio luodaan ensin, ja sen jälkeen sitä konfiguroidaan askel askeleelta.
Ratkaisu tekee API:sta joustavan, koska kaikkia asetuksia ei tarvitse tunkea
yhteen pitkään konstruktoriin. Jos TableColumn-konstruktorissa olisi jo
otsikko, arvonhakija, solutehdas, leveys, lajittelu, muokattavuus ja muita
asetuksia, siitä tulisi nopeasti raskas ja hankala käyttää.
Juuri tästä syystä JavaFX erottaa nämä asiat toisistaan:
- konstruktori luo sarakeolion
setCellValueFactory()kertoo, miten datamallista haetaan sarakkeen arvosetCellFactory()kertoo, millainen käyttöliittymäsolu tuon arvon näyttää
Siksi saraketta ei tässä API:ssa luoda muodossa
new TableColumn<>("Tehty", cd -> ...). Konstruktori ei ota vastuulleen
arvonhakua, vaan se tehdään erikseen setCellValueFactory()-metodilla.
On myös tärkeää erottaa setCellValueFactory() ja setCellFactory()
toisistaan. setCellValueFactory() ei luo soluja, vaan määrittää, mistä kunkin
rivin arvo haetaan. setCellFactory() puolestaan määrittää, millainen solu
(esimerkiksi teksti, valintaruutu, kuva) sarakkeeseen luodaan ja miten se
esittää arvonsa. Esimerkiksi Boolean-arvo voidaan näyttää tavallisena tekstinä
(true tai false) tai kätevämpänä valintaruutuna. Vastaavasti String-arvo
voidaan näyttää tavallisena tekstinä tai jossain villissä tapauksessa vaikkapa
kuvana, joka kuvaa sanan merkitystä.
Mainittakoon, että sarakkeet voidaan määritellä myös SceneBuilderissa FXML:ään. Monimutkaisemmissa taulukoissa voi olla kätevää määritellä sarakkeita etukäteen SceneBuilderissa ja ainoastaan käyttää kontrolleria sitoakseen data ja tietty sarake toisiinsa.
Kokeile käynnistää sovellus. Nyt tehtävät näkyvät taulukkonäkymässä, ja tehtävien lisääminen lisää ne taulukkonäkymään. Lisäksi taulukkonäkymä tarjoaa tavan lajitella tehtäviä ja siirtää sarakkeita haluamallaan tavalla:
Huomaa, että meidän ei tarvinnut enää muokata yhtään tehtävän lisäämiseen, lataamiseen tai tallentamiseen liittyvää toimintoa, sillä mallin hallinta on erotettu näkymän esittämisestä.
Datan sitominen eli ns. databinding on taulukkonäkymän toiminnan ytimessä.
Jos tekisimme taulukon ilman propertyjä ja TableView:ta, joutuisimme usein
itse luomaan jokaiselle riville komponentit, täyttämään ne arvoilla sekä
päivittämään näkymän erikseen, kun data muuttuu. TableView yhdessä propertyjen
kanssa vähentää tätä käsityötä merkittävästi. Koodi kertoo enemmän siitä, mitä
halutaan näyttää ja JavaFX:n harteille jätetään itse käyttöliittymän
näyttäminen.
Tehty-sarake valintaruuduksi
Oletuksena taulukossa näytetään arvojen merkkijonoesityksiä, minkä takia
tehty-tila näytetään false ja true -teksteinä. On kuitenkin aika oleellista,
että tehtävän tila olisi edelleen valintaruutu, joka voisi merkata tehdyksi.
Tätä varten taulukkojen TableColumn-sarakekomponenteissa on olemassa
setCellFactory()-metodi, jonka avulla sarakkeessa näytetävien solujen ulkoasua
voi muuttaa. Lisäksi JavaFX tarjoaa valmiita rakentajia yleisimmille
saraketyypeille. Esimerkiksi valintaruutuja voi luoda käyttäen
CheckBoxTableCell-luokkaa (JavaDoc).
Luokassa oleva forTableColumn-luokkametodi palauttaa oikeanmuotoisen
rakentajan, jonka voi antaa setCellFactory()-metodille.
Lisäksi meidän tulee sallia taulukon arvojen muokkaamisen
setEditable()-metodilla, jotta valintaruudut olisivat klikattavissa.
public void initialize(URL url, ResourceBundle resourceBundle) {
tehtavaTaulu.setItems(tehtavat);
// HIGHLIGHT_GREEN_BEGIN
tehtavaTaulu.setEditable(true);
// HIGHLIGHT_GREEN_END
TableColumn<Tehtava, Boolean> tehtySarake = new TableColumn<>("Tehty");
tehtySarake.setCellValueFactory(cd -> cd.getValue().tehtyProperty());
// HIGHLIGHT_GREEN_BEGIN
tehtySarake.setCellFactory(CheckBoxTableCell.forTableColumn(tehtySarake));
// HIGHLIGHT_GREEN_END
tehtavaTaulu.getColumns().add(tehtySarake);
// metodin loppuosa piilotettu...
TableColumn<Tehtava, String> tekstiSarake = new TableColumn<>("Tehtävä");
tekstiSarake.setCellValueFactory(cd -> cd.getValue().tekstiProperty());
tehtavaTaulu.getColumns().add(tekstiSarake);
tehtavat.addListener((ListChangeListener<Tehtava>) change -> {
tallenna();
});
lataa();
uusiTehtavaNimi.setOnAction(event -> lisaaTehtava());
lisaaUusiTehtavaPainike.setOnAction(event -> lisaaTehtava());
}
Kun sovelluksen käynnistää, Tehty-sarake esittää tehtävien tilan valintaruutuina, joita voi klikata:
Erityisesti aiempi tehty datan sitominen takaa, että valintaruudun tila ja tehtäväolion tila pysyvät ajan tasalla: valintaruudun klikkaaminen muuttaa olion tilaa, ja olion tilan muutos vaikuttaa taulukon näkymään.
Nyt kun valintaruutujen luominen on TableView-komponentin vastuulla, voimme
poistaa oman luoCheckBox()-metodin:
// Ei tarvita enää; TableView tekee valintaruudut itse
// HIGHLIGHT_RED_BEGIN
private CheckBox luoCheckBox(Tehtava t) {
// metodin toteutus piilotettu...
CheckBox cb = new CheckBox(t.getTeksti());
cb.setSelected(t.getTehty());
cb.setOnAction(event -> {
tehtavat.remove(t);
tehtavat.add(new Tehtava(t.getTeksti(), !t.getTehty()));
});
return cb;
}
// HIGHLIGHT_RED_END
Kuitenkin huomaamme nopeasti ongelman: valintaruudun klikkaaminen ei enää
tallenna muuttunutta tilaa tiedostoon. Tämä on osin odotettua: vanhassa
toteutuksessa valintaruudun klikkaaminen pakotti muutoksen tehtavat-listaan
käyttäen remove() ja add()-metodeja, jotka puolestaan ilmoittivat
muutoksesta initialize()-metodissa olevalla havaitsijalle. Valintaruutu
ainoastaan muuttaa datan arvoa, jolloin tehtavat-lista ei muutu eikä listan
havaitsijassa olevaa tallenna()-metodia ikinä suoriteta.
Tallennus tehtävän tilan muutoksesta
Yksi ratkaisu olisi sellainen, että kytkisimme Tehtava-olion tehtyProperty:n
muutokseen havaitsijan, joka kutsuu tallennusta. Tämä onnistuisi vaikkapa
lisaaTehtava()-metodissa. Alla on esimerkki, miten tämä voitaisiin tehdä
– älä kuitenkaan tee tätä nyt.
private void lisaaTehtava() {
// metodin alkuosa piilotettu...
String teksti = uusiTehtavaNimi.getText();
if (teksti == null || teksti.isBlank()) {
uusiTehtavaNimi.requestFocus();
return;
}
teksti = teksti.trim();
// Näin **voitaisiin** tehdä, mutta älä tee tätä nyt!
// HIGHLIGHT_GREEN_BEGIN
Tehtava tehtava = new Tehtava(teksti, false);
tehtava.tehtyProperty().addListener((obs, vanhaArvo, uusiArvo) -> tallenna());
// HIGHLIGHT_GREEN_END
tehtavat.add(tehtava);
// metodin loppuosa piilotettu...
uusiTehtavaNimi.clear();
uusiTehtavaNimi.requestFocus();
}
Tämä tarkoittaisi, että aina kun tehtyProperty muuttuu (esimerkiksi valintaruutua
klikataan tai muutetaan setTehty()-metodilla), tallennettaisiin kaikki tehtävät.
Tämä olisi sinänsä kätevää, mutta pieneksi ongelmaksi muodostuu, että
Jackson-kirjaston kautta ladatut Tehtava-oliot eivät tätä kuuntelijaa saa. Jos
nyt muutamme UI:ssa tehtavat.json-tiedostosta ladatun tehtävän tilaa,
tallennus ei tapahdu, koska kuuntelijaa ei ole koskaan lisätty. Joutuisimme siis
lisäämään saman havaitsijan myös lataa()-metodiin sekä muutenkin kaikkiin
paikkoihin, jossa Tehtava-olioita saatetaan luoda.
Mietitään hetki tilannetta. Voisimme kyllä ratkaista yllä olevaa tekemällä
apumetodin, joka alustaa Tehtava-olion ja tarvittavan havaitsijan. Tosaalta
voisimme hyödyntää ObservableList-listan addListener()-metodia: lisätään
initialize()-metodissa tehtavat-listalle uusi havaitsija, joka toimisi
luvun 8.1 alun esimerkin
tapaisesti.
Havaitsija kävisi läpi kaikki lisättävät tehtävät ja lisäisi niiden
tehtyProperty-ominaisuudelle havaitsijan automaattisesti. Alla on esimerkki,
miten tämä voitaisiin tehdä – älä silti tee tätäkään nyt.
public void initialize(URL url, ResourceBundle resourceBundle) {
// Parempi, mutta älä tee tätäkään pelkästään tallentamista varten!
tehtavat.addListener((ListChangeListener<Tehtava>) change -> {
while (change.next()) {
if (change.wasAdded()) {
for (Tehtava tehtava : change.getAddedSubList()) {
tehtava.tehtyProperty().addListener((obs, vanhaArvo, uusiArvo) -> tallenna());
}
}
}
});
// metodin loppuosa piilotettu...
}
Nyt aina, kun tehtavat-listaan lisätään uusi tehtävä, seuraisimme tehtävän
tehty-tilaa ja tallentaisimme tehtävät muutoksen jälkeen. Tämä pätisi sekä
käyttöliittymän että tiedoston kautta lisätyille tehtäville.
Tässä ratkaisussa kuitenkin edelleen jokaisen tehtäväolion tehty-ominaisuus
saa oman havaitsijan, mikä on hieman outoa. Yllä olevassa esimerkissä kaikkien
tehtävien tallentaminen sidotaan suoraan yksittäisen tehtävän tehty-tilan
muutokseen. Sen sijaan olisi loogisempaa, että kaikkien tehtävien tallennus
olisi sidottu kaikkia tehtäviä sisältävään tehtavat-listaan.
Tähän ongelmaan on olemassa toinenkin, aavistuksen elegantimpi ratkaisu.
ObservableList-oliolle voi sen luomisen yhteydessä määritellä niin sanottu
ekstraktori (engl. extractor), joka kertoo listalle, mitä kunkin olion
propertyjä tulee seurata. Muuta ObservableList-kokoelman luonti seuraavasti:
// Parempi tapa, käytä tätä!
private final ObservableList<Tehtava> tehtavat
= FXCollections.observableArrayList(tehtava -> new Observable[] {tehtava.tehtyProperty()});
Tässä tehtava -> new Observable[] { ... } on ekstraktori. Se palauttaa
taulukon niistä Observable-olioista, joita listan halutaan seuraavan
jokaisessa Tehtava-oliossa. Tällöin ObservableList ilmoittaa listan
muutosten (olio lisätty/poistettu) lisäksi listan alkioiden ominaisuuksien
muutoksista. Toisin sanoen, addListener()-metodilla olevat havaitsijat saavat
nyt tiedon aina, kun listaan lisätään tehtävä, listasta poistetaan tehtävä (tätä
tosin emme ole vielä toteuttaneet) ja kun listassa olevien tehtävien
tehtyProperty-ominaisuuden arvo vaihtuu.
public void initialize(URL url, ResourceBundle resourceBundle) {
// metodin alkuosa piilotettu...
tehtavaTaulu.setItems(tehtavat);
tehtavaTaulu.setEditable(true);
TableColumn<Tehtava, Boolean> tehtySarake = new TableColumn<>("Tehty");
tehtySarake.setCellValueFactory(cd -> cd.getValue().tehtyProperty());
tehtySarake.setCellFactory(CheckBoxTableCell.forTableColumn(tehtySarake));
tehtavaTaulu.getColumns().add(tehtySarake);
TableColumn<Tehtava, String> tekstiSarake = new TableColumn<>("Tehtävä");
tekstiSarake.setCellValueFactory(cd -> cd.getValue().tekstiProperty());
tehtavaTaulu.getColumns().add(tekstiSarake);
// Tämä on jo olemassa!
// Nyt tätä suoritetaan aina, kun
// - Tehtävä lisätään
// - Tehtävä poistetaan
// - Jonkun tehtävän tehty-ominaisuus muuttuu (esim. taulukon kautta)
tehtavat.addListener((ListChangeListener<Tehtava>) change -> {
tallenna();
});
// metodin loppuosa piilotettu...
lataa();
uusiTehtavaNimi.setOnAction(event -> lisaaTehtava());
lisaaUusiTehtavaPainike.setOnAction(event -> lisaaTehtava());
}
Kokeile nyt ajaa sovellus. Huomaat, että tehtävien merkkaaminen tehdyksi tallentaa muutokset tiedostoon.
Tehdyt tehtävät taulukon loppuun
Toteutetaan nyt aiemmasta VBox-pohjaisesta ratkaisusta se ominaisuus, jossa tehdyt tehtävät asetetaan aina listan loppuun.
Tämä periaatteessa onnistuu jo nyt käyttöliittymästä, sillä TableView tukee
lajittelua klikkaamalla sarakkeen otsikosta. Tehdään kuitenkin tämä
kontrollerissa, jotta käyttäjän ei tarvitse kytkeä lajittelua päälle käsin.
JavaFX tarjoaa useamman tavan hoitaa lajittelu. Eräs tapa tehdä pysyvä
lajittelu on käyttää ObservableList-listan sorted()-metodia, joka
ottaa parametriksi Comparator-vertailijan ja palauttaa SortedList-listan
(ks.
JavaDoc).
SortedList-listan voi puolestaan sitoa TableView-näkymään:
public void initialize(URL url, ResourceBundle resourceBundle) {
// HIGHLIGHT_GREEN_BEGIN
SortedList<Tehtava> tehtavatLajiteltu = tehtavat.sorted(Comparator.comparing(Tehtava::getTehty));
// HIGHLIGHT_GREEN_END
// HIGHLIGHT_YELLOW_BEGIN
tehtavaTaulu.setItems(tehtavatLajiteltu);
// HIGHLIGHT_YELLOW_END
// metodin loppuosa piilotettu...
tehtavaTaulu.setEditable(true);
TableColumn<Tehtava, Boolean> tehtySarake = new TableColumn<>("Tehty");
tehtySarake.setCellValueFactory(cd -> cd.getValue().tehtyProperty());
tehtySarake.setCellFactory(CheckBoxTableCell.forTableColumn(tehtySarake));
tehtavaTaulu.getColumns().add(tehtySarake);
TableColumn<Tehtava, String> tekstiSarake = new TableColumn<>("Tehtävä");
tekstiSarake.setCellValueFactory(cd -> cd.getValue().tekstiProperty());
tehtavaTaulu.getColumns().add(tekstiSarake);
tehtavat.addListener((ListChangeListener<Tehtava>) change -> {
tallenna();
});
lataa();
uusiTehtavaNimi.setOnAction(event -> lisaaTehtava());
lisaaUusiTehtavaPainike.setOnAction(event -> lisaaTehtava());
}
SortedList-lista sisältää alkuperäisen listan alkiot järjestettynä annetun
vertailijan mukaan. Kummatkin listat ovat sidottuja toisiinsa: alkion lisääminen
järjestettyyn listaan lisää alkion alkuperäiseen listaan ja toisinpäin.
Tässäkin korostuu, kuinka databinding-periaate suoraviivaistaa näkymän
päivittämistä.
Kokeile ajaa sovellus. Nyt tekemättömät tehtävät
näkyvät taulukossa ensin ja tehdyt lopussa, sillä boolean-tyypin
oletusvertailija asettaa false-arvot ennen true-arvoja.
Tehtävän poistaminen
Nyt kun meillä on taulukko, voimme käyttää sitä tehtävän poistamisen toteuttamiseen.
Avaa main.fxml SceneBuilderssa ja lisää uusi Button-painikekomponentti
HBox-komponentin alapuolelle. Aseta painikkeen tekstiksi "Poista tehtävä" ja
anna painikkeelle fx:id-tunnisteeksi poistaValittuPainike:
Tallenna FXML-tiedosto. Lisää sitten MainController-luokkaan painiketta
vastaava attribuutti:
@FXML
private Button poistaValittuPainike;
Lisätään sitten poistaValittu()-metodi, joka hoitaa poistamisen. Jotta poisto
toimii oikein, käyttöliittymän on ensin tiedettävä, mikä rivi taulukosta on
valittuna. TableView pitää kirjaa valituista riveistä erillisessä
SelectionModel-oliossa, johon pääsee käsiksi getSelectionModel()-metodilla.
Saamme valitun Tehtava-olion edelleen getSelectedItem()-metodilla.
Tämän jälkeen voimme poistaa tehtävän tehtavat-listasta.
private void poistaValittu() {
// 1. Hae valittu tehtävä taulukon valintamallista
Tehtava valittuTehtava = tehtavaTaulu.getSelectionModel().getSelectedItem();
// 2. Jos mitään ei ole valittu, ei tehdä mitään
if (valittuTehtava == null) {
return;
}
// 3. Poistetaan tehtävä mallilistasta
tehtavat.remove(valittuTehtava);
}
Lopuksi lisätään poistaValittuPainike-painikkeelle tapahtumakäsittelijä, joka
kutsuu tätä uutta metodia:
public void initialize(URL url, ResourceBundle resourceBundle) {
SortedList<Tehtava> tehtavatLajiteltu = tehtavat.sorted(Comparator.comparing(Tehtava::getTehty));
tehtavaTaulu.setItems(tehtavatLajiteltu);
tehtavaTaulu.setEditable(true);
TableColumn<Tehtava, Boolean> tehtySarake = new TableColumn<>("Tehty");
tehtySarake.setCellValueFactory(cd -> cd.getValue().tehtyProperty());
tehtySarake.setCellFactory(CheckBoxTableCell.forTableColumn(tehtySarake));
tehtavaTaulu.getColumns().add(tehtySarake);
TableColumn<Tehtava, String> tekstiSarake = new TableColumn<>("Tehtävä");
tekstiSarake.setCellValueFactory(cd -> cd.getValue().tekstiProperty());
tehtavaTaulu.getColumns().add(tekstiSarake);
tehtavat.addListener((ListChangeListener<Tehtava>) change -> {
tallenna();
});
lataa();
uusiTehtavaNimi.setOnAction(event -> lisaaTehtava());
lisaaUusiTehtavaPainike.setOnAction(event -> lisaaTehtava());
// metodin alkuosa piilotettu...
poistaValittuPainike.setOnAction(event -> poistaValittu());
}
Kokeile ajaa sovellus. Kun valitset tehtävän taulukosta ja painat "Poista
tehtävä", tehtävän pitäisi poistua taulukosta. Tämäkin toimii datan sidonnan
takia: painike poistaa tehtävän tehtavat-listasta, mikä automaattisesti
muuttaa lajitellun tehtavatLajiteltu-listan. Puolestaan muutos
tehtavatLajiteltu-listassa aiheuttaa TableView-näkymän päivittymisen ilman
erillistä toimintaa. Tässäkin siis mallin muokkaus ja näkymän päivitys ovat vain
löyhästi kytkettyjä toisiinsa observable-rakenteiden avustuksella.
Bonus: Painikkeen klikkaamisen estäminen jos tehtävää ei valittu
Nyt "Poista tehtävä" -painike on aika klikattavissa vaikka tehtävää ei ole valittu. Hieman käyttäjäystävällisemmin olisi, että painike olisi klikattavissa vain, jos taulukossa on ylipäätään valittuna jokin tehtävä.
Button-komponentissa on olemassa setDisable()-metodi sekä sitä vastaava
havaittava disableProperty(), joiden avulla painike voidaan kytkeä pois
päältä.
Vastaavasti taulukon SelectionModel-oliolla on olemassa
selectedItemProperty(), joka on havaittava ominaisuus tällä hetkellä valitusta
tehtävästä.
Koska selectedItemProperty() on havaittava ominaisuus, voimme toteuttaa
painikkeen kytkemisen päälle ja pois lisäämällä havaitsija:
poistaValittuPainike.setDisable(true);
tehtavaTaulu.getSelectionModel().selectedItemProperty().addListener((observable, oldValue, newValue) -> {
if (newValue == null) {
poistaValittuPainike.setDisable(true);
} else {
poistaValittuPainike.setDisable(false);
}
});
TODO: Bindings-luokka ja bind-metodi
Bonus: Painikkeen ilmestyminen vain, jos tehtävää on valittu
Toinen vaihtoehto olisi, että "Poista tehtävä" -painike näkyisi vain, jos
taulukossa on valittuna jokin tehtävä. Tällöin painikkeen näkyvyyttä voisi
ohjata setVisible()-metodilla, joka on myös havaittava ominaisuus
visibleProperty(). Toteutus olisi muuten sama kuin edellisessä kohdassa, mutta
setDisable()-kutsut korvattaisiin setVisible()-kutsuilla.
Palauta osan 8.2 perusteella edistetty projekti. Kertaus tämän osan vaiheista:
- Korvaa tehtävien
VBox+CheckBox-listausTableView-komponentilla. - Lisää taulukkoon vähintään sarakkeet: tehtävä (otsikko), tehty-tila.
- Kytke taulukon data
ObservableList<Tehtava>-listaan. - Mahdollista tehtävän valinta ja poisto valitulta riviltä.
Kun vaihe on valmis, tee git add muuttuneille tiedostoille ja git commit.
Palauta projektisi tiedostot.