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 MainControlleru novou instanční konstantu
typu IChatService a hned ji vytvoříme instanci:
private final IChatService chatService = new ChatService(communicator);
V metodě handleConnect() nastavíme
ConnectControlleru 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
MainControlleru 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 VBoxem. Do
VBoxu 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 VBoxu 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
