Get up to 80 % extra points for free! More info:

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:

List of registered clients - Server for Client Applications in Java

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:

Graphical representation of incoming message in a Java chat client - Server for Client Applications in Java

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:

Graphical representation of sent message - Server for Client Applications in Java

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.


 

Previous article
Java Chat - Server - Chat Plugin
All articles in this section
Server for Client Applications in Java
Skip article
(not recommended)
Java Chat - Client - Finishing Part 2
Article has been written for you by Petr Štechmüller
Avatar
User rating:
No one has rated this quite yet, be the first one!
Activities