Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Sovelluslogiikan ja käyttöliittymän yhdistäminen

Sovelluksemme voisi jo nyt toimia eräänlaisena Todo-listana. Palautetaan kuitenkin vielä mieleen, mitä ominaisuuksia suunnittelimme tämän osan alussa:

  • Käyttäjä voi lisätä uuden tehtävän
  • Käyttäjä näkee listan kaikista tehtävistä
  • Käyttäjä voi merkitä tehtävän tehdyksi
  • Käyttäjä voi poistaa tehtävän
  • Käyttäjä voi palauttaa tehdyn tehtävän takaisin tekemättömäksi
  • Tehtävät tallennetaan tiedostoon, jotta ne säilyvät sovelluksen sulkemisen jälkeen
  • Tehtävät haetaan tiedostosta sovelluksen käynnistyessä

Näitä huomioon ottaen sovelluksemme ei ole vielä kovin käytettävä: tehtäviä voidaan lisätä, ja pystymme näkemään kaikki tehtävät, mutta tehtäviä ei voi poistaa eikä merkitä tehdyksi.

Muutetaan käyttöliittymä siten, että tehtävät näytetään valintaruutuina, jolloin ne voi merkitä tehdyksi tai tekemättömäksi. Lisäksi listataan tehdyt ja tekemättömät tehtävät omaan listaan. Piirretään alustava wireframe-suunnitelmakuva:

Yllä oleva kuva on piirretty käyttäen wireframe.cc -palvelua, mutta vastaavia suunnitelmakuvia voidaan piirtää millä tahansa piirtotyökalulla. Yleisesti ottaen käyttöliittymiä on hyvä suunnitella hieman etukäteen, jotta sen toteuttaminen olisi suoraviivaisempaa.

Komponenttien luominen dynaamisesti

Aloitetaan ensin muuttamalla painikkeen toimintaa niin, että uusi tehtävä lisätään käyttöliittymään valintaruutuna, eli ns. CheckBox-komponenttina. Koska käyttäjä voi lisätä uusia tehtäviä rajattomasti, emme voi lisätä valintaruutuja SceneBuilderin kautta. Sen sijaan lisäämme komponentteja suoraan kontrollerin koodissa.

Valmistellaan ensiksi käyttöliittymä. Mene SceneBuilderiin ja poista siellä oleva Label-nimiökomponentti. Koska nimiössä ei ole oletuksena tekstiä, sitä ei pysty klikkaamaan suunnittelunäkymässä. Sen sijaan valitse nimiö käyttäen vasemmalla puolella olevan Document-näkymän Hierarchy-paneelia:

Poista nimiö painamalla Delete (macOS: ⌫ delete). Saat varoituksen "This component has an fx:id. Do you really want to delete it?" sen merkiksi, että nimiökomponenttia käytetään kontrollerin koodissa. Valitse varoitusdialogissa Delete.

Tämän jälkeen etsi VBox-komponentti Library-näkymästä (Library Containers VBox ) ja raahaa se tekstikentän yläpuolelle:

VBox (Vertical Box) on ns. sisältökomponentti, joka on tarkoitettu muiden komponenttien ryhmittelyyn ja asetteluun. Sisältökomponentteihin voi lisätä muita elementteja, joita sisältökomponentti asettelee sille ominaisella tavalla. Esimerkiksi VBox asettelee kaikki sen sisällä olevat komponentit pystysuorasti ylhäältä alas.

Anna uudelle VBox-komponentille fx:id-tunnisteeksi tekemattomat, eli sama kuin poistetun nimiökomponentin. Tallenna FXML-tiedosto ja muokkaa sitten MainController-luokka niin, että tekemattomat-attribuutin tyyppi on jatkossa VBox:

// HIGHLIGHT_RED_BEGIN
@FXML
private Label tekemattomat;
// HIGHLIGHT_RED_END
// HIGHLIGHT_GREEN_BEGIN
@FXML
private VBox tekemattomat;
// HIGHLIGHT_GREEN_END

Nyt tapahtumankäsittelijä ei enää toimi, koska VBox-komponentti ei sisällä getText/setText-metodia. Sen sijaan VBox-komponentin oleellinen metodi on getChildren(), joka palauttaa listan kaikista sen sisältämistä komponenteista. Muokataankin painikkeen tapahtumakäsittelijä niin, että painikkeen painalluksesta alustetaan uusi CheckBox-olio ja lisätään se VBox-komponenttiin. Tällöin tapahtumakäsittelijästä tulee seuraavanlainen:

lisaaUusiTehtavaPainike.setOnAction(event -> {
    String teksti = uusiTehtavaNimi.getText();
    CheckBox tehtava = new CheckBox(teksti);
    tekemattomat.getChildren().add(tehtava);
});

Kokeile ajaa sovellus tässä vaiheessa. Huomaat, että "Lisää tehtävä" -painike luo uuden valintaruutukomponentin ja lisää sen syöttökentän yläpuolelle. Valintaruudut ovat klikattavissa ikään kuin merkiksi siitä, onko tehtävä tehty:

Parannetaan sovelluksen käytettävyyttä hieman tässä vaiheessa. Ensiksi, jos "Lisää tehtävä" -painiketta painaa ilman, että syöttökenttään kirjoittaa mitään, sovellukseen ilmestyy tyhjä valintaruutu. Lisäämme järkevyystarkistuksen: jos tekstikentästä haettu teksti on null-viite, ei sisällä mitään tekstiä tai sisältää vain välilyöntejä, lopetetaan tapahtumankäsittely kesken. Tämä onnistuu String-tyypin isBlank()-metodilla. Lisäksi, poistetaan tehtävän alusta ja lopusta turhia välilyöntejä, jos käyttäjä saattaa kirjoittaa ne vahingossa käyttäen trim()-metodia:

lisaaUusiTehtavaPainike.setOnAction(event -> {
    String teksti = uusiTehtavaNimi.getText();
    // HIGHLIGHT_GREEN_BEGIN
    if (teksti == null || teksti.isBlank()) {        
        return; 
    }
    teksti = teksti.trim();
    // HIGHLIGHT_GREEN_END
    CheckBox tehtava = new CheckBox(teksti);
    tekemattomat.getChildren().add(tehtava);
});

Toiseksi, tehtävän lisääminen jättää tehtävätekstin syöttökenttään, jolloin uuden tehtävän lisäämistä varten joudumme kumittamaan pois vanhan tekstin. Käytetään sitä varten TextField-komponentin clear()-metodia, jolla me tyhjennämme tekstikentän sisällön aina tehtävän lisäämisen lopuksi:

lisaaUusiTehtavaPainike.setOnAction(event -> {
    String teksti = uusiTehtavaNimi.getText();
    if (teksti == null || teksti.isBlank()) {        
        return; 
    }
    teksti = teksti.trim();
    CheckBox tehtava = new CheckBox(teksti);
    tekemattomat.getChildren().add(tehtava);
    // HIGHLIGHT_GREEN_BEGIN
    uusiTehtavaNimi.clear();
    // HIGHLIGHT_GREEN_END
});

Kolmanneksi, tehtävän lisäämisen jälkeen joudumme klikkaamaan syöttökentästä ennen kuin seuraavan tehtävän kirjoittamista. Tehdään tämä klikkaus ohjelmallisesti käyttäen requestFocus()-metodia, joka siirtää fokuksen eli ikään kuin simuloi komponentin valintaa. Huomaa, että metodi on lisättävä kaikkiin kohtiin, jossa tapahtumankäsittely päättyy:

lisaaUusiTehtavaPainike.setOnAction(event -> {
    String teksti = uusiTehtavaNimi.getText();
    if (teksti == null || teksti.isBlank()) {        
        // HIGHLIGHT_GREEN_BEGIN
        uusiTehtavaNimi.requestFocus(); 
        // HIGHLIGHT_GREEN_END
        return; 
    }
    teksti = teksti.trim();
    CheckBox tehtava = new CheckBox(teksti);
    tekemattomat.getChildren().add(tehtava);
    uusiTehtavaNimi.clear();
    // HIGHLIGHT_GREEN_BEGIN
    uusiTehtavaNimi.requestFocus(); 
    // HIGHLIGHT_GREEN_END
});
Bonus: Fokuksen automaattinen asettaminen tapahtuman lopuksi

Nyt hieman ärsyttävästi joudumme asettamaan fokuksen kahteen kohtaan.

Eräs JavaFX-tyylinen tapa ratkaista ongelma on käyttää Platform.runLater()-metodia (JavaDoc), joka ajaa sille annettavan koodin myöhemmin sovelluksen aikana (mutta aikaisintaan sen tapahtuman jälkeen, jona metodia kutsuttiin). Metodi ottaa parametrina Runnable-rajapintaa toteuttavan olion. Koska Runnable on funktionaalinen (ks. Luku 6.1), voimme antaa parametrina lambdalausekkeen tai funktioviitteen metodiin, joka ei ota mitään parametreja eikä palauta mitään. Koska requestFocus()-metodi täsmää parametrien ja palautusarvon kannalta Runnable-rajapinnan kanssa, voimme käyttää funktioviitettä suoraan. Tällöin tapahtumankäsittely yksinkertaistuu muotoon:

lisaaUusiTehtavaPainike.setOnAction(event -> {
    Platform.runLater(uusiTehtavaNimi::requestFocus);
    String teksti = uusiTehtavaNimi.getText();
    if (teksti == null || teksti.isBlank()) {        
        return; 
    }
    teksti = teksti.trim();
    CheckBox tehtava = new CheckBox(teksti);
    tekemattomat.getChildren().add(tehtava);
    uusiTehtavaNimi.clear();
});

Toinen tapa on soveltaa geneerisia metodeja (ks. luku 4.4) sekä funktionaalisia rajapintoja (ks. luku 6.1). Koska lambdalausekkeita voidaan ottaa parametrina ja toisaalta palauttaa arvona, voimme tehdä apumetodin ajaJaFokusoi, joka ottaa parametrina tapahtumakäsittelijän ja palauttaa uuden tapahtumakäsittelijän, joka kutsuu requestFocus aina lopuksi:



static <T extends Event> EventHandler<T> ajaJaFokusoi(EventHandler<T> kasittelija, Node komponentti) {
    return e -> {
        kasittelija.handle(e);
        komponentti.requestFocus();
    };
}

Tällaista metodia, joka palauttaa parametrina annetun funktion pienellä muutoksella, kutsutaan yleensä ns. käärijämetodiksi tai wrapper-metodiksi. Nimensä mukaan metodi siis "käärii" alkuperäisen funktion toisen sisään.

Apumetodin avulla voimme yksinkertaistaa tapahtumankäsittelijän muotoon:

lisaaUusiTehtavaPainike.setOnAction(ajaJaFokusoi(event -> {
    String teksti = uusiTehtavaNimi.getText();
    if (teksti == null || teksti.isBlank()) {
        return;
    }
    teksti = teksti.trim();
    CheckBox tehtava = new CheckBox(teksti);
    tekemattomat.getChildren().add(tehtava);
    uusiTehtavaNimi.clear();
}, uusiTehtavaNimi));

Huomaa, että kaikki JavaFX-komponentin perivät Node-luokasta.

Lopuksi, tehtävän lisääminen vaatii aina "Lisää tehtävä" -painikkeen painamista. Tehokäyttäjälle voisi olla kätevämpi lisätä tehtäviä myös Enter-painiketta käyttäen, jolloin uusia tehtäviä voi lisätä paljon nopeammin. Huomaamme, että TextField-komponentin onAction-tapahtuma laukeaa, kun käyttäjä painaa Enter-painiketta kentässä (ks. JavaDoc). Lisätään siis myös uusiTehtavaNimi-syöttökentälle tapahtumankäsittelijä käyttäen setOnAction-metodia. Koska haluamme käsitellä näppäimen painallusta ja tekstikentän Enter-painiketta samalla tavalla, refaktoroidaan samalla tapahtumankäsittely omaksi metodiksi:

public void initialize(URL url, ResourceBundle resourceBundle) {
    lisaaUusiTehtavaPainike.setOnAction(event -> lisaaTehtava());
    uusiTehtavaNimi.setOnAction(event -> lisaaTehtava());
}

private void lisaaTehtava() {
    String teksti = uusiTehtavaNimi.getText();
    if (teksti == null || teksti.isBlank()) {
        uusiTehtavaNimi.requestFocus();
        return;
    }
    teksti = teksti.trim();
    CheckBox tehtava = new CheckBox(teksti);
    tekemattomat.getChildren().add(tehtava);
    uusiTehtavaNimi.clear();
    uusiTehtavaNimi.requestFocus();
}

Kokeillaan nyt vielä ajaa sovellus ja testataan, että kaikki toimii. Muutosten myötä sovellus on nyt hieman käytettävämpi:

  • Tehtävien lisääminen ei onnistu jos tekstikenttä on tyhjä
  • Tehtävän lisääminen tyhjentää tekstikentän ja palauttaa fokuksen siihen nopeampaa kirjoittamista varten
  • Tehtäviä voidaan lisätä myös painamalla Enter-painiketta

Käsitellyt tehtävät

Jatketaan sovelluksen toimintojen edistämistä. Muutetaan sovellusta niin, että käsitellyt tehtävät ovat aina erillään tekemättömistä, jolloin tehtävien tekemistä on helpompaa seurata.

Palaa SceneBuilderiin ja lisää uusi VBox-komponentti tekemättömien tehtävien VBox-komponentin alle. Anna samalla uudelle VBox-komponentin fx:id-tunnisteeksi tehdyt:

Tallenna FXML-tiedosto, ja määrittele kontrolleriluokkaan vastaava VBox tehdyt -attribuutti:

@FXML
private VBox tehdyt;

Tehdään niin, että aina, kun valintaruutua klikataan, tehtävä siirtyy tekemättömästä tehdyksi ja samalla siirtyy ylemmästä alempaan VBox-komponenttiin. Huomaamme, että CheckBox-komponentti perii ButtonBase-luokan, jolloin onAction-tapahtuma laukeaa aina, kun valintaruutupainiketta klikataan (ks. JavaDoc). Muutetaan lisaaTehtava()-metodia niin, että valintaruudun onAction-tapahtumalle asetetaan käsittelijä. Tapahtumankäsittelijässä ensiksi poistamme valintaruudun tekemattomat-säiliökomponentista ja lisätään se tehdyt-säiliökomponenttiin:

private void lisaaTehtava() {
    // metodin alku piilotettu...
    String teksti = uusiTehtavaNimi.getText();
    if (teksti == null || teksti.isBlank()) {
        uusiTehtavaNimi.requestFocus();
        return;
    }
    teksti = teksti.trim();
    CheckBox tehtava = new CheckBox(teksti);
    // HIGHLIGHT_GREEN_BEGIN
    tehtava.setOnAction(event -> {
        tekemattomat.getChildren().remove(tehtava);
        tehdyt.getChildren().add(tehtava);
    });
    // HIGHLIGHT_GREEN_END
    tekemattomat.getChildren().add(tehtava);
    // metodin loppu piilotettu...
    uusiTehtavaNimi.clear();
    uusiTehtavaNimi.requestFocus();
}

Kokeile ajaa sovellusta. Nyt tehtävän klikkaaminen siirtää sen alempaan VBox-säiliöön. Klikkaamalla jo tehtyä tehtävää se ei kuitenkaan siirry takaisin tekemättömiin. Lisäksi, jos katsot IDEAssa konsoliin, näet poikkeuksen:

java.lang.IllegalArgumentException: Children: duplicate children added: parent = VBox[id=tehdyt]

Poikkeus kertoo, että yritämme lisätä samaa CheckBox-komponenttia uudestaan tehdyt-säiliöön, vaikka se on jo siellä. Muokataan logiikkaa niin, että jos tehtävä merkittiin tehdyksi, siirretään se tehtyihin ja jos tehtävä merkittiin tekemättömäksi, siirretään se takaisin tekemättömiin. Voimme käyttää tässä CheckBox-komponentin isSelected()-metodia, joka kertoo, onko valintaruutu valittu tai ei (ks. JavaDoc). Tällöin tapahtumakäsittely muuttuu seuraavaan muotoon:

tehtava.setOnAction(event -> {
    if (tehtava.isSelected()) { // Tehtävä valittu --> Siirretään tehtyjen joukkoon
        tekemattomat.getChildren().remove(tehtava);
        tehdyt.getChildren().add(tehtava);
    } else { // Tehtävä ei-valittu--> Siirretään takaisin tekemättömien joukkoon
        tehdyt.getChildren().remove(tehtava);
        tekemattomat.getChildren().add(tehtava);
    }
});

Kokeile nyt ajaa sovellus. Tehtävien merkkaaminen tehdyksi pitäisi nyt siirtää ne alempaan säiliöön. Vastaavasti tehtävien merkkaaminen tekemättömäksi siirtää ne takaisin ylös:

Huomaa, että isSelected()-metodilla on jo tiedossaan "uusi" arvo, onko komponentti valittuna vai ei. Tilan päivitys tapahtuu ennen setOnAction-tapahtuman laukeamista. Kun käyttäjä klikkaa valintaruutua, tapahtumaketju on karkeasti seuraava:

  • Hiiren painallus rekisteröityy käyttöjärjestelmään.
  • JavaFX päivittää sisäisen selected-ominaisuuden (esim. false -> true).
  • ActionEvent luodaan ja setOnAction-käsittelijä suoritetaan.
  • Käsittelijän suoritus: Kun kutsut tässä vaiheessa isSelected(), saat jo uuden, päivitetyn tilan.

Nimiöt säiliöille

Tehdään vielä pieni muutos parantaakseen käyttäjäystävällisyyttä. Lisää yksi nimiökomponentti (Label) tekemättömien tehtävien säiliön yläpuolelle ja aseta sen Text-attribuutiksi TODO. Sen jälkeen lisää vielä yksi nimiökomponentti tehtyjen tehtävien säiliön yläpuolelle ja aseta sen Text-attribuutiksi TEHTY:

Tallenna FXML-tiedosto ja kokeile vielä ajaa sovellus IDEA:sta varmistaksesi, että kaikki vieläkin toimii.

Tehtävä 7.4: Todo-sovellus, vaihe 4. 1 p.

Palauta tässä osan 7.4 perusteella edistetty projekti.

Kertaus tämän osan vaiheista:

  • Tee kaksi VBox-komponenttia tekemättömille ja tehdyille tehtäville.
  • Kun käyttäjä syöttää tehtävän, lisää se tekemättömien tehtävien VBox-säiliöön CheckBox-komponenttina.
  • Kun käyttäjä merkitsee tehtävän tehdyksi klikkaamalla valintaruudusta, siirrä se tekemättömien VBox-säiliöstä tehtyjen säiliöön.
  • Kun käyttäjä merkitsee tehdyn tehtävän tekemättömäksi klikkaamalla valintaruudusta, siirrä se tehtyjen säiliöstä tekemättömien säiliöön.
  • Kun käyttäjä lisää tehtävän, fokuksen tulee palautua syöttökenttään.
  • Käyttäjän ei pidä pystyä lisäämään tehtävää ilman tekstiä tai tehtävää, jonka tekstinä on pelkästään välilyöntejä.
  • Käyttäjän tulee pystyä lisäämän tehtävän myös painamalla Enter-painiketta, kun fokus on syöttökentässä.

Kun vaihe on valmis, muista tehdä git add muuttuneille tiedostoille ja git commit. Palauta projektisi tiedostot. Ei haittaa jos TIMissä tulee jokin varoitus tai jopa käännösvirhe. TIMissä ei välttämättä ole kaikkia tehtävissä vaadittavia riippuvuuksia, eikä siten JavaFX-projekti välttämättä edes käänny. Pääasia on, että olet saanut projektin toimimaan paikallisessa ympäristössäsi.

Tee tehtävä TIMissä