wtorek, 21 stycznia 2014

JavaFX - tutorial #02 - kontrolki i obsługa zdarzeń


W moim drugim tutorialu poświęconym technologi / bibliotece JavaFX chciałbym przede wszystkim pokazać jak dodawać do aplikacji różne kontrolki i obsługiwać zdarzenia, które wywołują. Oprócz tego przedstawię parę sposobów zarządzania kompozycją interfejsu oraz stylizacji jej elementów. Pokażę również jak grupować kontrolki i dodawać do nich ikony.



Aby kurs był uporządkowany i łatwo było w nim znaleźć tylko wycinek całego tematu, podzieliłem go na sektory opisane krótko poniżej.

Tematy zawarte w tym kursie:

  1. Tworzenie szkieletu aplikacji JavaFX.
  2. Konstruowanie kompozycji. Tworzenie i osadzanie paneli kompozycji (layoutu).
  3. Dodawanie kontrolek: radio-buttony, check-boxy i choice-box. Grupowanie kontrolek.
  4. Stylizacja komponentów programu.
  5. Obsługa zdarzeń.


1. Tworzenie szkieletu aplikacji JavaFX


Oto on:

public class BasicControls extends Application
{
      
       private Scene scene; // scena
       private BorderPane rootPane; // główny kontener
       private VBox rightContainer, leftContainer; // kontenery boczne
       private Pane centerContainer; // kontener centralny
       private RadioButton rB1, rB2, rB3;
       private CheckBox cB1, cB2, cB3;
       private ChoiceBox<Object> choiceBox;
       private final ToggleGroup toggleGroup = new ToggleGroup();
       private Image icon, icon_un;
       private ImageView iconView;

       // =============================================================================
      
       private void prepareScene(Stage primaryStage)
       {

       }
      
       // =============================================================================
      
       public void start(Stage primaryStage)
       {
             prepareScene(primaryStage);

             primaryStage.setTitle("Basic controls example");
             primaryStage.setScene(scene);
             primaryStage.show();
       }
      
       // =============================================================================

       public static void main(String[] args)
       {
             launch(args);
       }
      
       // =============================================================================
}

Wewnątrz klasy, na samej górze pozwoliłem sobie zadeklarować większość jej atrybutów. Oczywiście podczas pisania struktury klasy, zazwyczaj nie jesteśmy w stanie już na starcie przewidzieć wszystkich potrzebnych później zmiennych i deklarujemy je na bieżąco. Ja zrobiłem inaczej aby nie musieć co chwila o tym pisać :) .

Niżej wstawiamy na razie pustą metodę private void prepareScene(Stage primaryStage), która posłuży jako konstruktor i przygotuje scenę. Dzięki temu nie będziemy musieli wszystkiego ładować do metody start(...), co pozwoli zachować większą kontrolę nad kodem.

Dalej, wspomniana już metoda public void start(Stage primaryStage) - obowiązkowa, jeżeli chcemy w ogóle uruchomić później nasz program.

Na końcu metoda public static void main(String[] args) - nieobowiązkowa, ale na wszelki wypadek wstawiamy.


2. Konstruowanie kompozycji. Tworzenie i osadzanie paneli kompozycji (layoutu).


Podczas projektowania interfejsu użytkownika, niezwykle wygodne jest korzystanie z tzw. "Built-in Layout Panes" czyli wbudowanych paneli kompozycji.  Oszczędzają one programiście wiele pracy i pozwalają zachować płynność i dynamikę interfejsu, dlatego też z nich skorzystamy.
Zanim jednak napiszemy odpowiedni kod, powinniśmy najpierw zastanowić się jaki układ elementów chcemy uzyskać. W tej aplikacji umieścimy: radio-buttony, check-boxy i choice-boxa. Powiedzmy, że chcielibyśmy, aby te pierwsze znajdowały się jeden pod drugim i trzymały prawego marginesu, check-boxy podobnie, tyle że z lewej strony, natomiast ostatni z elementów umieścimy gdzieś po środku.

Teraz, kiedy mamy już konkretną wizję, możemy skorzystać z konkretnych komponentów. Aby uzyskać pożądany efekt wypełniamy nieco metodę prepareScene(Stage primaryStage), która wygląda teraz tak:

private void prepareScene(Stage primaryStage)
       {
             String cssPath;
            
             primaryStage.setMinWidth(450);
             rootPane = new BorderPane();
             rightContainer = new VBox(10);
             rightContainer.setId("rightContainer");
             leftContainer = new VBox(10);
             leftContainer.setId("leftContainer");
             centerContainer = new Pane();
             centerContainer.setId("centerContainer");
            
             // ==============================
            
             cssPath = this.getClass().getResource("application.css").toExternalForm();
             rootPane.setLeft(leftContainer);
             rootPane.setRight(rightContainer);
             rootPane.setCenter(centerContainer);
             scene = new Scene(rootPane, 700, 550);
             scene.getStylesheets().add(cssPath);
       }
Narazie jeszcze nie będziemy zajmować się stylizacją, ale możemy już umieścić zmienną cssPath, odpowiedzialną za ścieżkę do arkusza css. Dalej mamy ustawienie minimalnego rozmiaru okna, a poniżej to co nas najbardziej interesuje:

rootPane = new BorderPane(); - nasz nadrzędny panel - rootPane staje się instancją klasy BorderPane. Klasa ta jest odpowiedzialna za odpowiedni rozkład elementów interfejsu. Konkretnie umożliwia ona podział kompozycji na następujące obszary: lewo, prawo, środek, góra i dół. Czyli dokładnie to czego potrzebowaliśmy. Nie należy martwić się tym, że np. nie byliśmy zainteresowani zagospodarowaniem górnego obszaru, bo jeżeli tego nie zrobimy, to po prostu go nie będzie.

Zauważmy, że zarówno po lewej jak i prawej stronie rootPane'a chcieliśmy, aby elementy tam umieszczone układały się wierszami, jeden pod drugim. Taką funkcjonalność daje nam następny panel kompozycji: VBox.
Stąd w kolejnych linijkach inicjalizujemy lewy i prawy kontener jako własnie VBox:

             rightContainer = new VBox(10);
             rightContainer.setId("rightContainer");
             leftContainer = new VBox(10);
             leftContainer.setId("leftContainer");
Podana w konstruktorach liczba 10 wyznacza odstępy między wierszami. Panelom dodajemy też od razu identyfikator, który będzie nam później potrzebny przy css-ie. Następnie tworzymy centralny kontener korzystając z klasy Pane, która nie ma żadnych magicznych właściwości i po prostu umieścimy tam choice-boxa "na sztywno". Dalej nadajemy panelom ID, podajemy ścieżkę do arkusza css, przypisujemy do rootPane'a wewnętrzne kontenery, ustawiamy wielkość sceny i kończymy przypisując jej wskazany arkusz styli.

Gdybyśmy chcieli teraz uruchomić program to naszym oczom nie ukaże się nic interesującego, ponieważ aby zobaczyć jak działają panele kompozycji, musimy coś w nich umieścić i zrobimy to w kolejnym punkcie.


3. Dodawanie kontrolek: radio-buttony, check-boxy i choice-box. Grupowanie kontrolek.


Kolejnym krokiem będzie dodanie kontrolek do naszej aplikacji. Jednej z nich przypiszemy również ikonkę. W tym celu zaraz po linijce String cssPath; dopisujemy następujące, tak aby początek naszej metody wyglądał w ten sposób:

private void prepareScene(Stage primaryStage)
       {
             String cssPath;
             icon = new Image(getClass().getResourceAsStream("ok.png"));
             icon_un = new Image(getClass().getResourceAsStream("ok_unselected.png"));

             iconView = new ImageView(icon_un);
Oczywiście w źródle (tam gdzie znajduje się plik naszej klasy) musimy dodać wskazane ikonki. W Eclipsie możemy to zrobić albo za pomocą importu albo możemy ręcznie wrzucić je do folderu, a w Package Explorerze na źródle PPM -> refresh. Obrazki, których ja użyłem znajdziecie do pobrania na końcu wpisu w sekcji "źródła" :) . Jak pewnie zauważyliście, użyłem obiektów klasy Image oraz ImageView - dlaczego tak? Otóż, klasa Image nie udostępnia metody pozwalającej na bezpośrednią podmianę obrazka na inny. Poza tym kontrolki i tak przyjmują jako argument obiekt klasy ImageView, dzięki której możemy wyświetlić dowolny obiekt klasy Image.

Następnie, przed komentarzem oddzielającym: // ============================== umieszczamy kod dodający nasze kontrolki:

             // ========= RadioButtons =========

             rB1 = new RadioButton("1 radio button");
             rB1.setToggleGroup(toggleGroup);
             rB2 = new RadioButton("2 radio button");
             rB2.setToggleGroup(toggleGroup);
             rB3 = new RadioButton();
             rB3.setGraphic(iconView);
             rB3.setToggleGroup(toggleGroup);
             rightContainer.getChildren().addAll(rB1, rB2, rB3);

             // ========= CheckBoxes =========

             cB1 = new CheckBox("1 check-box");
             cB2 = new CheckBox("2 check-box");
             cB3 = new CheckBox("3 check-box");
             leftContainer.getChildren().addAll(cB1, cB2, cB3);

             // ========= Choice Box =========

             choiceBox = new ChoiceBox<>(FXCollections.observableArrayList("lemon", "apple", "orange",
                           new Separator(), "chair"));
             choiceBox.setValue("lemon");

             // Sztywne ustawienie Lokalizacji
             choiceBox.setLayoutY(100);
             choiceBox.setLayoutX(50);

             centerContainer.getChildren().add(choiceBox);
             rootPane.setCenter(centerContainer);
Jako argument w konstruktorach radio-buttonów podajemy łańcuch znakowy, który będzie wyświetlany obok kontrolki. Następnie za pomocą metody setToggleGroup dodajemy każdy element do grupy toggleGroup. Dzięki zgrupowaniu, radio-buttony będą działać tak, jak jesteśmy do tego przyzwyczajeni, czyli będzie możliwe zaznaczenie naraz tylko jednego elementu z grupy. Oczywiście można tego nie robić i używać przycisków radiowych jak checkboxów, lub ręcznie zaprogramować podobne ograniczenie.
Za pomocą metody setGraphic ustawiamy grafikę dla kontrolki, a na koniec dodajemy wszystkie radio-buttony do stworzonego wcześniej prawego kontenera.

Przy check-boxach mamy podobną sytuację, nic nowego się tam nie dzieje. Trochę dziwnie i odpychająco wygląda natomiast konstruktor choice-boxa, jednak po krótkiej analizie nie będzie on już taki straszny. Operatory <> sugerują, że jest to konstruktor generyczny, co oznacza, że nasze "pudło wyboru" może przechowywać różne obiekty. Jako argument przyjmuje strukturę observableArrayList z klasy FXCollections. Jest to po prostu generyczna, dynamiczna stryktura danych, która pozwala nam przechować różne elementy w klasie ChoiceBox. Prefiks observable informuje, że konstrukcja może dynamicznie reagować na wprowadzane zmiany. Jako parametry podałem łańcuchy znakowe odpowiadające kilku owocom oraz jeden oznaczający krzesło. Jako, że ten ostatni nie pasuje do poprzednich, umieściłem obiekt klasy Separator, który wizualnie oddzieli te obiekty. Dalej, za pomocą setValue ustawiamy wartość na "lemon" (czy cokolwiek co wpisaliście), dzięki czemu, po uruchomieniu programu, coś (lemon) będzie już wybrane. Następnie na sztywno ustawiamy lokalizację kontrolki i dodajemy ją do centralnego kontenera, którego z kolei wstawiamy do rootPane'a.

Uruchommy teraz naszą aplikację. Oczywiście z zawodem stwierdzimy, że wygląda jak Fiat 126p przy najnowszym Lamborgini, czyli co najmniej biednie. Możemy już jednak zaobserwować jak działają panele kompozycji. Zmieniając rozmiar okna, zauważymy, że elementy które miały być po lewej, trzymają się lewej strony, przyciski radiowe przylegają do prawej, a "pudło wyboru" trzyma się jednego punktu wewnątrz centralnego kontenera. U mnie wygląda to tak:


Jak już poklikamy w kontrolki i sprawdzimy czy wszystko działa, zabierzemy się do podrasowania layoutu.


4. Stylizacja komponentów programu.


Czas podmalować nieco naszą aplikację. W tym celu otwieramy arkusz css (jeżeli IDE nie stworzyło go automatycznie należy to zrobić ręcznie) i wpisujemy:

#rightContainer
{
       -fx-background-color: rgb(54,54,54);
       -fx-padding: 10 20 10 20;
}

#leftContainer
{
       -fx-background-color: rgb(54,54,54);
       -fx-padding: 10 20 10 20;
}

#centerContainer
{
       -fx-background-color: linear-gradient(#A0C0F0, #D0D0D0);
}

.radio-button
{
       -fx-text-fill: rgb(211,211,211);
}

.check-box
{
       -fx-text-fill: rgb(211,211,211);

}
Po pierwszym tutorialu, wszystko co znalazło się w arkuszu powinno być jasne, ale dla formalności krótko przypomnę:
Znakiem '#' poprzedziliśmy wszystkie elementy o danym ID, natomiast '.' obiekty należące do danej rodziny / klasy.
Właściwości:
-fx-background-color odpowiada za kolorowanie tła.
-fx-padding wstawia wewnętrzne marginesy.
-fx-text-fill ustawia kolor tekstu.

Po tych niewielu linijkach wstawionych do arkusza, aplikacja powinna prezentować się już znacznie lepiej:



5. Obsługa zdarzeń.


Kiedy nasz program już jakoś wygląda, pozostało nam tylko dodać do niego obsługę zdarzeń. Nie miałem pomysłu na to, co ciekawego mógłby robić, więc ograniczyłem się do tego, by po jakiejś akcji został wyświetlony komunikat informujący co było jej źródłem (mam nadzieję, że w kolejnych kursach uda mi się przedstawić bardziej funkcjonalne aplikacje). Dodatkowo sprawimy aby radio-button z ikonką zmieniał swój obrazek kiedy jest zaznaczony.

Ponieważ chcemy wyświetlić komunikat, musimy go mieć gdzie wyświetlić. W tym celu na samej górze w miejscu gdzie deklarowaliśmy wszystkie zmienne dopiszmy:

private Label eventLabel;

oraz jako Pane dostawmy jeszcze dolny kontener:

private Pane centerContainer, bottomContainer; // kontener centralny i dolny

Dalej w metodzie prepareScene(...), po ustawieniach dla choice-boxa wstawiamy fragment tworzący naszą strefę komunikatów:

             // ========= eventLabel ==========

             eventLabel = new Label();
             eventLabel.setStyle("-fx-text-fill: #0AF;");
             bottomContainer = new Pane();
             bottomContainer.getChildren().add(eventLabel);
             bottomContainer.setStyle("-fx-background-color: linear-gradient(rgb(54,54,54) 25%, rgb(30,30,30))");

             rootPane.setBottom(bottomContainer);
Kod jest analogiczny jak przy wstawianiu kontrolek, ale zwróćcie uwagę, że nie użyliśmy tutaj arkusza css, tylko ustawiliśmy wygląd metodą 'inline'. Przy użyciu setStyle, możemy również w ten właśnie sposób, bezpośrednio w kodzie stylizować elementy aplikacji. Funkcja ta przyjmuje jako argument łańcuch znakowy, wewnątrz którego wpisujemy właściwości w tej samej formie jak byśmy to robili w osobnym pliku css. Ogólnie rzecz biorąc, nie polecam jednak tego sposobu. Przyda się, kiedy szybko chcemy nadać styl niewielu elementom w małym projekcie, ale przy większości aplikacji znacznie lepiej sprawdza się całkowite odizolowanie layoutu od funkcjonalności.

Kolejnym krokiem będzie implementacja interfejsów: EventHandler<> oraz ChangeListener<>, które będą bezpośrednio odpowiedzialne za wychwytywanie i obsługę zdarzeń.
Tak więc dostawiamy klauzulę informującą o tym:

public class BasicControlsExample extends Application implements EventHandler<Event>, ChangeListener<Toggle>

Oraz dopisujemy chwilowo puste szkielety metod, które musimy sami zaimplementować:

// =============================================================================

public void handle(Event event)
{
      
}

// =============================================================================

public void changed(ObservableValue<? extends Toggle> ov, Toggle oldRB, Toggle currentRB)
{
      
}

// =============================================================================
Dlaczego potrzebujemy aż dwóch interfejsów? Cóż, właściwie to nie potrzebujemy :) . Moglibyśmy wszystko załatwić tylko EventHandlerem, ale korzystając z tego, że zgrupowaliśmy radio-buttony, chciałbym pokazać jak można niektóre rzeczy zrobić łatwiej za pomocą ChangeListenera.

Zacznijmy od tego ostatniego. Oczywiście argumenty metody wyglądają przerażająco, ale spokojnie, da się to okiełznać.
ObservableValue<? extends Toggle> ov - Ogólnie mówiąc jest to zmienna referencyjna generycznego interfejsu ObservableValue (omg !) - obserwator, który wywołuje metodę changed(...) gdy jego wartość ulegnie zmianie. Mówiąc po ludzku, jeżeli cokolwiek zmieni się w grupie radio-buttonów - toggleGroup, wówczas zostanie wywołana metoda changed(...).
oldRB - wcześniej zaznaczona kontrolka,
currentRB - aktualnie zaznaczona kontrolka.
My będziemy korzystać tylko z currentRB. Wypełniamy tę piekielną metodę:

public void changed(ObservableValue<? extends Toggle> ov, Toggle oldRB, Toggle currentRB)
       {
             RadioButton rB = (RadioButton) currentRB;

             eventLabel.setText(rB.getText());

             if (rB.getGraphic() != null)
             {
                    iconView.setImage(icon);
                    eventLabel.setText("OK");
             }
             else
                    iconView.setImage(icon_un);

       }
I tyle, nic skomplikowanego. Na początku, dla wygody deklarujemy zmienną rB, którą inicjujemy aktualnie zaznaczoną kontrolką rzutowaną na RadioButton. Wypisujemy komunikat ustawiając tekst zmiennej eventLabel na tekst wyświetlany przy kontrolkach. Następnie, przy pomocy instrukcji warunkowych ustawiamy, aby w sytuacji gdy mamy do czynienia z kontrolką z ikoną, zmieniała się ona na podświetloną. Ustawiamy ręcznie tekst etykiety, gdyż graficznej kontrolce nie nadaliśmy wcześniej żadnego, więc nic by się nie wypisało. Na koniec, kontrolce rB3 (pośrednio, zmieniając jedynie wartość iconView) ustawiamy wygaszony obrazek, gdy zaznaczony jest inny radiowy przycisk. Gdybyśmy nie zgrupowali swoich radio-buttonów i korzystali z EventHandlera, musielibyśmy dla każdego przycisku, osobno ustawiać akcję, powtarzając podobny kod. My korzystamy jedynie z currentRB co w standardowych sytuacjach oznacza, że nigdy nie odwołamy się do pustego obiektu - null'a. Należy jednak pamiętać, że bezpośrednie wywołanie metody changed(...), lub odwołanie się (pierwszy raz) do oldRB spowodowałoby błąd programu i wyjątek NullPointerException.

Możemy teraz uruchomić nasz program i sprawdzić co się będzie działo podczas klikania radio-buttonów. Nic się nie będzie działo, a to dlatego, że nie przypisaliśmy im żadnego słuchacza zdarzeń. Czyli nasza klasa implementująca changeListenera, nie nasłuchuje co się dzieje z przyciskami i w rezultacie metoda change(...) nigdy nie jest wywoływana. Dzięki zgrupowaniu nie musimy jednak przypisywać słuchacza do każdego przycisku osobno, ale wystarczy dodać go do toggleGroup. W związku z tym, w miejscu gdzie ustawialiśmy radio-buttony (może być na początku), wpisujemy:

toggleGroup.selectedToggleProperty().addListener(this);

Oczywiście za pomocą słówka this, jako słuchacza ustawiamy naszą klasę BasicControls, ponieważ to właśnie ona implementuje potrzebny interfejs i udostępnia niezbędną metodę. Teraz po odpaleniu aplikacji Wszystko powinno działać tak jak chcieliśmy:


Zanim przejdziemy do implementacji metody handle(...), przypiszmy od razu wszystkim pozostałym kontrolkom 'asystenta zdarzeń'. W sekcji check-boxów wstawiamy:

       cB1.addEventHandler(ActionEvent.ACTION, this);
       cB2.addEventHandler(ActionEvent.ACTION, this);
       cB3.addEventHandler(ActionEvent.ACTION, this);
Natomiast tam gdzie ustawialiśmy choice-boxa dopisujemy:

choiceBox.addEventHandler(ActionEvent.ACTION, this);

Jak widać EventHandler'a ustawiamy setterem o mało zaskakującej nazwie: setEventHandler(...). Pierwszym parametrem tej metody jest typ zdarzeń, który chcemy obsługiwać, dzięki któremu możemy ograniczyć się tylko do wyłapywania konkretnych akcji, lub też możemy chcieć nasłuchiwać wszystkiego co bezpośrednio wpływa na kontrolkę. Drugi parametr to oczywiście klasa implementująca interfejs EventHandler. Gdybyśmy jako pierwszy argument podali np. ActionEvent.ANY, wówczas metoda handle byłaby wywoływana również wtedy, gdy kursor tylko najechałby na przycisk. Nam w zupełności wystarczy typ ActionEvent.ACTION, aby nasłuchiwać czy jakaś kontrolka została wciśnięta lub odznaczona.

Do zrobienia została nam już tylko implementacja metody handle(...). Oto ona:

       public void handle(Event event)
       {
             Object source = event.getSource();

             if (source == cB1)
             {
                    if (cB1.isSelected())
                           eventLabel.setText("check-box 1 - checked");
                    else
                           eventLabel.setText("check-box 1 - clear");
             }
             else if (source == cB2)
             {
                    if (cB2.isSelected())
                           eventLabel.setText("check-box 2 - checked");
                    else
                           eventLabel.setText("check-box 2 - clear");
             }
             else if (source == cB3)
             {
                    if (cB3.isSelected())
                           eventLabel.setText("check-box 3 - checked");
                    else
                           eventLabel.setText("check-box 3 - clear");
             }

             else if (source == choiceBox)
                    eventLabel.setText(choiceBox.getValue().toString());

       }
Jako argument metoda przyjmuje jedynie obiekt odpowiadający wyłapanemu zdarzeniu. Następnie dla wygody i przejrzystości kodu stworzyliśmy obiekt będący referencją źródła akcji. Przeanalizujmy pierwszą instrukcję warunkową:

if (source == cB1) - jeżeli źródłem zdarzenia był przycisk cB1, wówczas:
  if (cB1.isSelected()) - jeżeli jest zaznaczony:
    eventLabel.setText("check-box 1 - checked"); - ustaw tekst w etykiecie zdarzeń (informujący o wciśnięciu).
  W przeciwnym wypadku:
    eventLabel.setText("check-box 1 - clear"); - ustaw tekst w etykiecie zdarzeń (informujący o zwolnieniu).

Kolejne instrukcje działają analogicznie. Warto zwrócić uwagę, że przy większych projektach, lepiej by było zamiast pisać "check-box X", napisać cBX.getText(), lub ustawiać jakieś id. i je sczytywać. Wówczas, kiedy zechcemy zmienić tekst przypisany do jakiegoś przycisku, nie będziemy musieli robić tego w paru miejscach w kodzie, a tylko raz.

Jeżeli wszystko zrobiliśmy dobrze to nasz program kursowy jest już całkowicie sprawny :) .




Źródło:
Cały kod aplikacji, arkusz css + użyte ikonki: źródło

Linki:
Strona główna firmy Oracle dot. JavaFX: JavaFX
Jak pracować z róznymi panelami kompozycji: Layouts Panes
Używanie kontrolek interfejsu: Using UI Controls
Obsługa zdarzeń: Events Handling

1 komentarz:

  1. Dzięki wielkie za tutorial. Bardzo mi się przydał jako jedna z pierwszych lekcji JavaFX.

    OdpowiedzUsuń