Lekce 23 - Java chat - Klient - Dokončení 1. část
V předchozím kvízu, Kvíz - Správa uživatelů, chat service a plugin v Javě, jsme si ověřili nabyté zkušenosti z předchozích lekcí.
V první části dnešního Java tutoriálu zobrazíme přihlášené uživatele v GUI. V druhé části vytvoříme widgety reprezentující konverzaci a její obsah.
Zobrazení přihlášených uživatelů
Uživatele budeme zobrazovat v ListView
, které se nachází v
souboru main.fxml
a jeho reference je uložena v kontroleru
MainController
. Nejdříve toto ListView
natypujeme na
třídu ChatContact
:
@FXML
private ListView<ChatContact> lvContactList;
Dále vytvoříme v MainController
u novou instanční konstantu
typu IChatService
a hned ji vytvoříme instanci:
private final IChatService chatService = new ChatService(communicator);
V metodě handleConnect()
nastavíme
ConnectController
u chatService
:
controller.setChatService(chatService);
Dále vyplníme tělo metody initialize()
:
@Override public void initialize(URL url, ResourceBundle resourceBundle) { lvContactList.setCellFactory(param -> new ChatEntryCell()); chatService.getClients().addListener(this.chatClientListener); }
V metodě zatím děláme dvě věci:
- nastavíme továrnu záznamů pro
ListView
- přidáme posluchače klientů v
chatService
Nyní vytvoříme konstantu, která bude obsahovat anonymní funkci, která
se bude spouštět při změně klientů v chatService
:
private final MapChangeListener <<? super String, ? super ChatContact> chatClientListener = change -> { if (change.wasAdded()) { lvContactList.getItems().addAll(change.getValueAdded()); } if (change.wasRemoved()) { lvContactList.getItems().removeAll(change.getValueRemoved()); } };
ChatEntryCell
V balíčku widget
založíme novou třídu
ChatEntryCell
, která bude reprezentovat jeden záznam v
ListView
:
package cz.stechy.chat.widget; public class ChatEntryCell extends ListCell <ChatContact> { private final Circle circle = new Circle(); private final Label lblName = new Label(); private final Region spacer = new Region(); private final Label lblUnreadedMessages = new Label(); private final HBox container = new HBox(circle, lblName, spacer, lblUnreadedMessages); { circle.setRadius(15); HBox.setHgrow(spacer, Priority.ALWAYS); container.setAlignment(Pos.CENTER_LEFT); container.setSpacing(8); } private void bind(ChatContact item) { circle.fillProperty().bind(item.contactColorProperty()); lblName.textProperty().bind(item.nameProperty()); lblUnreadedMessages.textProperty().bind(item.unreadedMessagesProperty().asString()); lblUnreadedMessages.visibleProperty().bind(item.unreadedMessagesProperty().greaterThan(0)); } private void unbind() { circle.fillProperty().unbind(); lblName.textProperty().unbind(); lblUnreadedMessages.textProperty().unbind(); } @Override protected void updateItem(ChatContact item, boolean empty) { super.updateItem(item, empty); setText(null); if (empty) { unbind(); setGraphic(null); } else { bind(item); setGraphic(container); } } }
Každý záznam bude obsahovat kolečko s náhodně vygenerovanou barvou, dále jméno uživatele a vpravo se bude zobrazovat počet nepřečtených zpráv od daného uživatele:

ChatTabContent
Jednotlivé zprávy budou reprezentovány třídou
ChatTabContent
. Tyto zprávy mají definované vlastní view v
souborech:
/fxml/chat/message_incomming.fxml
/fxml/chat/message_outcomming.fxml
Jediný rozdíl v souborech je rozmístění prvků, jinak jsou totožné. Každá zpráva bude obsahovat kolečko reprezentující uživatele, jméno uživatele a samotný obsah zprávy.
V balíčku widget
založíme novou třídu
ChatTabContent
:
package cz.stechy.chat.widget; public class ChatTabContent { @FXML private Circle circle; @FXML private Label lblFrom; @FXML private TextArea areaMessage; @FXML private ImageView imgLoading; private void enableArea() { imgLoading.setVisible(false); areaMessage.setDisable(false); } void setColor(Color color) { circle.setFill(color); } void setContactName(String name) { lblFrom.setText(name); } void setMessage(String message) { areaMessage.setText(message); } void askForResizeTextArea() { if (areaMessage.getLength() <= 58) { enableArea(); return; } CompletableFuture.runAsync(() -> { try { Thread.sleep(1000); } catch (InterruptedException ignored) {} }, ThreadPool.COMMON_EXECUTOR) .thenAcceptAsync(ignored -> { final Node text = areaMessage.lookup(".text"); if (text == null) { return; } areaMessage.prefHeightProperty().bind(Bindings.createDoubleBinding( () -> text.getBoundsInLocal().getHeight(), text.boundsInLocalProperty()).add(20)); enableArea(); }, ThreadPool.JAVAFX_EXECUTOR); } }
Obsah zprávy budeme zobrazovat se zpožděním, aby se velikost
TextArea
nastavila správně a my nemuseli používat
ScrollBar
pro přečtení jednotlivé zprávy. Zpoždění je opět
vyřešeno za pomocí CompletableFuture
, kde na začátku
jednoduše počkáme jednu vteřinu (v pracovním vlákně). Po uplynutí této
doby vyhledáme v TextArea
metodou lookup()
text
a podle délky textu nastavíme výšku. Nakonec schováme
obrázek s načítací animací.
ChatTab
Nyní se dostáváme k samotnému chatovacímu oknu. V
MainController
u máme TabPane
, ve kterém budeme
zobrazovat jednotlivé konverzace. Každá konverzace bude v jednom tabu. V
balíčku widget
založíme novou třídu ChatTab
:
public class ChatTab extends Tab { }
Nejdříve vytvoříme konstanty, do kterých uložíme cesty k důležitým souborům.
private static final URL PATH_CONTENT_INCOMING = ChatTab.class.getResource("/fxml/chat/chat_tab_content_incoming.fxml"); private static final URL PATH_CONTENT_OUTCOMING = ChatTab.class.getResource("/fxml/chat/chat_tab_content_outcoming.fxml"); private static final String PATH_IMG_TYPING = ChatTab.class.getResource("/img/typing.gif").toExternalForm(); private static final String PATH_IMG_LOADING = ChatTab.class.getResource("/img/loading.gif").toExternalForm();
Obrázky pro načítání a indikátor psaní jsou přiloženy ve zdrojových na konci článku.
Dále vytvoříme instanční konstanty:
private final ScrollPane container = new ScrollPane(); private final VBox messagesContiainer = new VBox(); private final ImageView imgTyping = new ImageView(new Image(PATH_IMG_TYPING)); private final StackPane imageContainer = new StackPane(); private final Circle circle = new Circle(); private final ChatContact chatContact;
Každý tab bude obsahovat ScrollPane
s VBox
em. Do
VBox
u se budou vkládat jednotlivé zprávy, tedy widgety
ChatTabContent
. Konstanta imageContainer
se vloží
jako grafický prvek do tabu a bude obsahovat buď imgTyping
, pokud
klient píše, jinak circle
. Konstantu chatContact
iniciaizujeme v konstruktoru z parametru:
ChatTab(ChatContact chatContact) { super(); this.chatContact = chatContact; this.chatContact.getMessages().addListener(this.messagesListener); loadMessagesAsync(); final ImageView loadingImage = new ImageView(); loadingImage.setImage(new Image(PATH_IMG_LOADING)); container.setContent(loadingImage); container.setHbarPolicy(ScrollBarPolicy.NEVER); container.setFitToWidth(true); setContent(container); messagesContiainer.heightProperty().addListener((observable, oldValue, newValue) -> { container.setVvalue(newValue.doubleValue()); }); this.container.focusedProperty().addListener((observable, oldValue, newValue) -> { chatContact.resetUnreadedMessages(); }); chatContact.resetUnreadedMessages(); circle.setFill(chatContact.getColor()); setGraphic(buildTabGraphic(chatContact.getName())); chatContact.typingProperty().addListener((observable, oldValue, newValue) -> { if (newValue) { imageContainer.getChildren().setAll(imgTyping); } else { imageContainer.getChildren().setAll(circle); } }); }
V konstruktoru se toho děje mnohem více. Nejdříve se nastaví listener na
přijaté zprávy od klienta. V tomto listeneru budeme transformovat jednotlivé
zprávy na widgety typu ChatTabContent
. Metodou
loadMessagesAsync()
načteme asynchronně všechny dosud přijaté
a odeslané zprávy. Než se načtou všechny zprávy, tak by bylo dobré,
abychom informovali uživatele, že se něco děje. K tomu slouží další
řádky kódu, kde vytvoříme obrázek s animací načítání a vložíme ho
jako jediný obsah do ScrollPane
.
Dále nastavíme listener na výšku kontejneru zpráv:
messagesContiainer.heightProperty().addListener((observable, oldValue, newValue) -> { container.setVvalue(newValue.doubleValue()); });
Vždy, když se do VBox
u vloží nová zpráva, zavolá se tento
listener a upraví výšku i ScrollPane
.
Druhý listener:
this.container.focusedProperty().addListener((observable, oldValue, newValue) -> {
chatContact.resetUnreadedMessages();
});
Pokaždé, když klikneme do tabu, tak vyresetujeme indikátor nepřečtených zpráv.
Dále necháme vyresetovat všechny nepřečtené zprávy a kolečku
circle
nastavíme barvu dle příslušného kontaktu.
Voláním metody setGraphic()
nastavíme tabu vlastní grafickou
reprezentaci. Nakonec přidáme listener
na vlastnost
typingProperty
. Podle stavu buď budeme zobrazovat animaci
imgTyping
, nebo kolečko circle
.
V metodě setGraphic()
voláme pomocnou metodu
buildTabGraphic()
k sestavení vlastní grafické reprezentace:
private HBox buildTabGraphic(String contactName) { final Label lblName = new Label(contactName); imageContainer.getChildren().setAll(circle); imageContainer.setPrefWidth(16); imageContainer.setPrefHeight(16); final HBox graphicContainer = new HBox(imageContainer, lblName); graphicContainer.setAlignment(Pos.CENTER_LEFT); graphicContainer.setSpacing(8); graphicContainer.setPrefHeight(32); HBox.setHgrow(lblName, Priority.ALWAYS); circle.setRadius(8); return graphicContainer; }
Dále si vytvoříme soukromou metodu getPath()
, která nám
vrátí cestu ke správnému view podle kontaktu:
private URL getPath(ChatContact from) { return from == this.chatContact ? PATH_CONTENT_INCOMING : PATH_CONTENT_OUTCOMING; }
Metodou addMessage()
budeme tvořit nové widgety
ChatTabContent
:
private ChatTabContent addMessage(ChatMessageEntry chatMessage) { final ChatContact contact = chatMessage.getChatContact(); final String message = chatMessage.getMessage(); final FXMLLoader loader = new FXMLLoader(getPath(contact)); ChatTabContent controller = null; try { final Parent parent = loader.load(); controller = loader.getController(); controller.setColor(contact.getColor()); controller.setContactName(contact.getName()); controller.setMessage(message); parent.setUserData(controller); mess agesContiainer.getChildren().add(parent); } catch (IOException e) { e.printStackTrace(); } return controller; }
V konstruktoru jsme volali metodu loadMessageAsync()
. Nyní ji
implementujeme:
private void loadMessagesAsync() { CompletableFuture.runAsync(() -> { try { Thread.sleep(1000); } catch (InterruptedException ignored) {} this.chatContact.getMessages().forEach(this::addMessage); }, ThreadPool.COMMON_EXECUTOR) .thenAcceptAsync(ignored -> { container.setContent(messagesContiainer); messagesContiainer.getChildren() .stream() .map(node -> (ChatTabContent) node.getUserData()) .filter(Objects::nonNull) .forEach(ChatTabContent::askForResizeTextArea); }, ThreadPool.JAVAFX_EXECUTOR); }
Na začátku opět chvíli počkáme, poté projdeme všechny zprávy a
vizualizujeme je. To vše v "pracovním" vlákně. V hlavním vlákně pak
nastavíme do ScrollPane
kontejner se zprávami, tedy
VBox
. Nakonec projdeme všechny zprávy a požádáme je o
automatické nastavení velikosti.
Nakonec přidáme třídní konstantu, která bude obsahovat anonymní funkci, která se bude starat o přidávání nových zpráv:
private final ListChangeListener <? super ChatMessageEntry> messagesListener = c -> { while (c.next()) { if (c.wasAdded()) { for (ChatMessageEntry chatMessageEntry: c.getAddedSubList()) { final ChatTabContent chatTabContent = addMessage(chatMessageEntry); if (chatTabContent != null) { chatTabContent.askForResizeTextArea(); } } } } };
Úprava view pro zprávy
Je potřeba upravit soubory /fxml/chat/message_incomming.fxml
a
/fxml/chat/message_outcomming.fxml
. Kořenovému prvku
AnchorPane
přiřadíme kontroler. Dále přidáme novou kontrolku
ImageView
, ve které budeme zobrazovat načítací animaci.
Soubor message_incomming.fxml
bude po úpravách vypadat
takto:
<?xml version="1.0" encoding="UTF-8"?> <?import javafx.geometry.Insets?> <?import javafx.scene.control.Label?> <?import javafx.scene.control.TextArea?> <?import javafx.scene.image.Image?> <?import javafx.scene.image.ImageView?> <?import javafx.scene.layout.AnchorPane?> <?import javafx.scene.layout.VBox?> <?import javafx.scene.shape.Circle?> <AnchorPane VBox.vgrow="NEVER" xmlns="http://javafx.com/javafx/8.0.60" xmlns:fx="http://javafx.com/fxml/1" fx:controller="cz.stechy.chat.widget.ChatTabContent"> <Circle fx:id="circle" fill="DODGERBLUE" layoutX="43.0" layoutY="38.0" radius="29.0" stroke="BLACK" strokeType="INSIDE" AnchorPane.bottomAnchor="8.0" AnchorPane.leftAnchor="8.0" AnchorPane.topAnchor="8.0" /> <Label fx:id="lblFrom" layoutX="79.0" layoutY="5.0" AnchorPane.leftAnchor="80.0" /> <TextArea fx:id="areaMessage" disable="true" editable="false" layoutX="69.0" layoutY="25.0" maxWidth="300.0" prefColumnCount="15" prefRowCount="1" wrapText="true" AnchorPane.bottomAnchor="8.0" AnchorPane.leftAnchor="80.0" AnchorPane.topAnchor="22.0" /> <ImageView fx:id="imgLoading" fitHeight="32.0" fitWidth="32.0" layoutX="137.0" layoutY="21.0" pickOnBounds="true" preserveRatio="true"> <Image url="@../../img/loading.gif" /> </ImageView> <padding> <Insets right="8.0" /> </padding> </AnchorPane>
A jeho grafická reprezentace:

Soubor message_outcomming.fxml
bude po úpravách vypadat
takto:
<?xml version="1.0" encoding="UTF-8"?> <?import javafx.geometry.Insets?> <?import javafx.scene.control.Label?> <?import javafx.scene.control.TextArea?> <?import javafx.scene.image.Image?> <?import javafx.scene.image.ImageView?> <?import javafx.scene.layout.AnchorPane?> <?import javafx.scene.layout.VBox?> <?import javafx.scene.shape.Circle?> <AnchorPane VBox.vgrow="NEVER" xmlns="http://javafx.com/javafx/8.0.60" xmlns:fx="http://javafx.com/fxml/1" fx:controller="cz.stechy.chat.widget.ChatTabContent"> <Circle fx:id="circle" fill="DODGERBLUE" layoutX="557.0" layoutY="38.0" radius="29.0" stroke="BLACK" strokeType="INSIDE" AnchorPane.bottomAnchor="8.0" AnchorPane.rightAnchor="8.0" AnchorPane.topAnchor="8.0" /> <Label fx:id="lblFrom" layoutX="483.0" layoutY="5.0" AnchorPane.rightAnchor="80.0" /> <TextArea fx:id="areaMessage" disable="true" editable="false" layoutX="69.0" layoutY="25.0" maxWidth="300.0" prefColumnCount="15" prefRowCount="1" wrapText="true" AnchorPane.bottomAnchor="8.0" AnchorPane.rightAnchor="80.0" AnchorPane.topAnchor="22.0" /> <ImageView fx:id="imgLoading" fitHeight="32.0" fitWidth="32.0" layoutX="137.0" layoutY="21.0" pickOnBounds="true" preserveRatio="true"> <Image url="@../../img/loading.gif" /> </ImageView> <padding> <Insets left="8.0" /> </padding> </AnchorPane>
A jeho grafická reprezentace:

To by bylo pro dnešní lekci vše.
Příště, v lekci Java chat - Klient - Dokončení 2. část, propojíme ChatTab
s hlavním
kontrolerem.
Měl jsi s čímkoli problém? Stáhni si vzorovou aplikaci níže a porovnej ji se svým projektem, chybu tak snadno najdeš.
Stáhnout
Stažením následujícího souboru souhlasíš s licenčními podmínkami
Staženo 16x (141.02 kB)
Aplikace je včetně zdrojových kódů v jazyce Java