Lesson 23 - Java Chat - Client - Finishing Part 1
In the last lesson, Java Chat - Server - Chat Plugin, we created a chat plugin for the server.
In the first part of today's Java tutorial we're going to display the logged in users in the GUI. In the second part we'll create widgets representing the conversation and its contents.
Displaying logged-in Users
We'll show the users in the ListView
located in the
main.fxml
file and a reference to it is stored in the
MainController
. First, we'll cast this ListView
to the
ChatContact
class:
@FXML
private ListView<ChatContact> lvContactList;
Next, we'll create a new instance constant of the IChatService
type in the MainController
initialize it:
private final IChatService chatService = new ChatService(communicator);
In the handleConnect()
method, we'll set the
chatService
of the ConnectController
:
controller.setChatService(chatService);
Next, we'll fill in the initialize()
method's body:
@Override public void initialize(URL url, ResourceBundle resourceBundle) { lvContactList.setCellFactory(param -> new ChatEntryCell()); chatService.getClients().addListener(this.chatClientListener); }
In the method we do two things so far:
- set the cell factory to the
ListView
- add a client listener in the
chatService
Now we'll create a constant with an anonymous function that will run when the
clients in chatService
are changed:
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
In the widget
package, we'll create a new
ChatEntryCell
class that will represent a single
ListView
entry:
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); } } }
Each entry will contain a circle of a randomly generated color, a user name, and the number of unread messages from that user will be displayed on the right:
ChatTabContent
Individual messages will be represented by a ChatTabContent
class. These messages have their own view defined in the files:
/fxml/chat/message_incomming.fxml
/fxml/chat/message_outcomming.fxml
The only difference between these files is the elements' layout, otherwise they are identical. Each message will contain a circle representing the user, the user name and the message contents itself.
In the widget
package we'll create a new
ChatTabContent
class:
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); } }
We display the message contents with a delay so that the
TextArea
size is set correctly and we won't have to use a
ScrollBar
to read the individual message. The delay is again
resolved with the help of CompletableFuture
, where at the beginning
we simply wait one second (in the working thread). After this time, we look for
text
in the TextArea
using the lookup()
method and set the height according to the length of the text. Finally, we hide
the image of the loading animation.
ChatTab
Now we get to the chat window itself. In MainController
, we have
a TabPane
in which we'll display the conversations. Each
conversation will be in a separate tab. We'll create a new ChatTab
class in the widget
package:
public class ChatTab extends Tab { }
First, we'll create constants with the paths to important files:
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();
The images for loading and the writing indicator are included in the archive below the article.
Next, we'll create instance constants:
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;
Each tab will contain a ScrollPane
with a VBox
. To
the VBox
we'll insert individual messages, thus
ChatTabContent
widgets. The imageContainer
constant is
inserted as a graphic into the tab and will contain either
imgTyping
if the client is typing, or circle
. We'll
initialize the chatContact
constant from a parameter in the
constructor:
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); } }); }
There's much more going on in the constructor. First, a listener for messages
received from the client is set. In this listener, we'll transform individual
messages into ChatTabContent
widgets. The
loadMessagesAsync()
method retrieves all received and sent messages
asynchronously. Before all messages are retrieved, we should inform the user
that something is happening. This is what the next lines do, create an image
with the loading animation and insert it into ScrollPane
as the
only content.
Next, we set the listener to the height of the message container:
messagesContiainer.heightProperty().addListener((observable, oldValue, newValue) -> { container.setVvalue(newValue.doubleValue()); });
Each time a new message is added in the VBox
, this listener is
called and the height and ScrollPane
updated.
The second listener:
this.container.focusedProperty().addListener((observable, oldValue, newValue) -> {
chatContact.resetUnreadedMessages();
});
Every time we click in the tab, we reset the unread message indicator.
Next, we reset all unread messages and set the circle
to the
corresponding contact's color.
We call the setGraphic()
method to set our own graphical
representation to the tab. Finally, we add a listener
to
typingProperty
. Depending on the status, we either display the
imgTyping
animation or the circle
.
In the setGraphic()
method we call the
buildTabGraphic()
helper method to build our custom graphical
representation:
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; }
Next, we'll create a private getPath()
method to return the path
to the correct view by contact:
private URL getPath(ChatContact from) { return from == this.chatContact ? PATH_CONTENT_INCOMING : PATH_CONTENT_OUTCOMING; }
An addMessage()
method will create new
ChatTabContent
widgets:
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; }
We called the loadMessageAsync()
method in the constructor. Now
we'll implement it:
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); }
At the beginning we wait for a while, then go through all the messages and
visualize them. All this in the "working" thread. In the main thread, we then
set the message container, i.e. VBox
, to the
ScrollPane
. Finally, we go through all the messages and ask them to
resize automatically.
Finally, we'll add a class constant with an anonymous function adding new messages:
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(); } } } } };
Editing the Messages View
We need to edit the /fxml/chat/message_incomming.fxml
and
/fxml/chat/message_outcomming.fxml
files. We'll assign a controller
to the AnchorPane
root element. Next, we'll add a new
ImageView
component to display the loading animation.
After editing, message_incomming.fxml
will look like this:
<?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>
And its graphical representation:
After editing, message_outcomming.fxml
will look like this:
<?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>
And its graphical representation:
That would be all for today's lesson.
Next time, in the lesson Java Chat - Client - Finishing Part 2, we'll wire ChatTab
to the main
controller.