Building a Simple Chat App with Jetpack Compose

Meyta Taliti
7 min readJul 23, 2023

--

Photo by Alexander Shatov on Unsplash

If you come across this article after knowing Jetchat sample you may be wondering “why should I read this”? I don’t know, why you?

Before going any further and wasting your time, I want you to know what you’re about to read.

Chatz Demo

So, I created this really simple Chat app that I’ve named Chatz (with a z, no reason) and in it I’m going to show you:

  • How do I make a bubble chat
  • How to manage layout using ConstraintLayout
  • and lastly, How to create fake conversation

Feel free to jump to whatever section you are into. Ready?

How do I make a bubble chat

If we create it in XML, we’ll like to create two shape resources (left & right bubbles) with different corner radius. And we’ll place it as a background in the item ViewHolder.

Chat bubbles

But with Jetpack Compose, we can easily create a Box and clip the box with RoundedCornerShape. No need to create a different shape, you can simply put condition if (message.isFromMe) then radius is 0 else 48f. And to place them in different position, you can play with Alignment.

Box(
modifier = Modifier
.align(if (message.isFromMe) Alignment.End else Alignment.Start)
.clip(
RoundedCornerShape(
topStart = 48f,
topEnd = 48f,
bottomStart = if (message.isFromMe) 48f else 0f,
bottomEnd = if (message.isFromMe) 0f else 48f
)
)
.background(PurpleGrey80)
.padding(16.dp)
) {
Text(text = message.text)
}

You might be wondering, where the message.isFromMe come from? Well, basically I made a UI model where we can differentiate whether the message is coming from me or other people, like this:

data class Message(
val text: String,
val author: Author,
) {
val isFromMe: Boolean
get() = author.id == MY_ID
}

ez pz. Next ~

How to manage layout using ConstraintLayout

You know Column and Row, but at some point, the UI gets complicated, and you don’t want to place them stacked. So, it’s time to learn how to use ConstraintLayout in Jetpack Compose. Don’t forget to include the ConstraintLayout dependency:

androidx.constraintlayout:constraintlayout-compose:1.0.1

Now, take a look at the image below. It’s not too complicated, but let’s attempt to implement ConstraintLayout.

We have the ChatScreen here, which contains a LazyColumn for the list of messages and the ChatBox Composable component, which serves as the place to send our messages.

To create reference between layout we can use createRefs() or createRefFor(). In this case, I use createRefs() for messages and chatBox layout. And you need to do it inside ConstraintLayout block, like this:

ConstraintLayout(modifier = modifier.fillMaxSize()) {
val (messages, chatBox) = createRefs()

LazyColumn {
items(model.messages) { item ->
ChatItem(item)
}
}
ChatBox()
}

Additional note: The maximum number of references created using createRefs() is limited to 16.

Afterward, you can utilize the constrainAs() modifier which takes the reference as a parameter. In this case, the references are messages or chatBox. Then, you can specify the constraints for the reference using linkTo(). Similar to the ConstraintLayout in XML, you can use parent to specify constraints towards the ConstraintLayout. Let’s better understand this with the following code example:

@Composable
fun ChatScreen(model: ChatUiModel, modifier: Modifier) {
ConstraintLayout(modifier = modifier.fillMaxSize()) {
val (messages, chatBox) = createRefs()

LazyColumn(
modifier = Modifier
.fillMaxWidth()
.constrainAs(messages) {
top.linkTo(parent.top)
bottom.linkTo(chatBox.top)
start.linkTo(parent.start)
end.linkTo(parent.end)
height = Dimension.fillToConstraints
}
) {
items(model.messages) { item ->
ChatItem(item)
}
}

ChatBox(
modifier = Modifier
.fillMaxWidth()
.constrainAs(chatBox) {
bottom.linkTo(parent.bottom)
start.linkTo(parent.start)
end.linkTo(parent.end)
}
)
}
}

There are still many things to explore within ConstraintLayout, such as Guidelines, Barriers, and Chains. For more examples, you can check out the official ConstraintLayout Guide.

How to create fake conversation

Now, you get to see what’s inside the fake conversation. Why is it fake? Well, because I made a bunch of questions and sent them randomly to the user after they send a message 🤪. Maybe in the future, we could consider creating a server to have realistic conversations or even integrate it with ChatGPT. With that in mind, the ViewModel class will be a great fit for future use cases.

We’ll create a class called MainViewModel that extends the ViewModel class. In this class, we’ll declare the random questions as mentioned before:

class MainViewModel : ViewModel() {

private val questions = mutableListOf(
"What about yesterday?",
"Can you tell me what inside your head?",
"Lately, I've been wondering if I can really do anything, do you?",
"You know fear is often just an illusion, have you ever experienced it?",
"If you were me, what would you do?"
)
}

Next, we need to create a variable that can hold the entire conversation and also notify the UI when there is a change in the property. This is where StateFlow comes into play and shines.

val conversation: StateFlow<List<ChatUiModel.Message>>
get() = _conversation

private val _conversation = MutableStateFlow(
listOf(ChatUiModel.Message.initConv)
)

// just show you how I initialize the conversation ;)
data class Message( .. ) {

companion object {
val initConv = Message(
text = "Hi there, how you doing?",
author = Author.bot
)
}
}

I have created two variables: one is the conversation StateFlow, which we can later call from the MainActivity class. The other is the _conversation MutableStateFlow, which will only be consumed within the ViewModel class. The reason for this is to prevent the UI level from modifying the flow directly.

Now, let’s move on to the logic part. We want to create a function that adds a chat to the conversation whenever there is a trigger to send a message. After that, we’ll reply to the chat using a random question.

fun sendChat(msg: String) {

// wrap the msg as ChatUiModel.Message and assign the author as 'me'
val myChat = ChatUiModel.Message(msg, ChatUiModel.Author.me)
viewModelScope.launch {

// add myChat to the conversation
_conversation.emit(_conversation.value + myChat)

// add 1s delay to make it seem more realistic
delay(1000)

// lastly, add a random question to conversation
_conversation.emit(_conversation.value + getRandomQuestion())
}
}

To avoid getting the same random question repeatedly, we need to remove that question from the list once it has been sent to the user.

private fun getRandomQuestion(): ChatUiModel.Message {

// throw a random question, and also define a default message to display when run out of questions.
val question = if (questions.isEmpty()) {
"no further questions, please leave me alone"
} else {
questions.random()
}

// remove the question when it is not empty
if (questions.isNotEmpty()) questions.remove(question)

// wrap the q as ChatUiModel.Message and assign the author as 'bot'
return ChatUiModel.Message(
text = question,
author = ChatUiModel.Author.bot
)
}

Okay, all set. Let’s wire these logic with the UI.

If you notice, the ChatScreen composable receives ChatUiModel as one of the parameters. Inside ChatUiModel there are:

  • the conversation val messages: List<Message>
  • and the person we’re talking to val addressee: Author
data class ChatUiModel(
val messages: List<Message>,
val addressee: Author,
)

@Composable
fun ChatScreen(model: ChatUiModel, modifier: Modifier) {
ConstraintLayout {

..
LazyColumn {
items(model.messages) { item ->
ChatItem(item)
}
}
..
}
}

To get the conversation from MainViewModel, we need to declare a variable inside MainActivity class and observe the conversation update using collectAsState().

import androidx.activity.viewModels
import androidx.compose.runtime.collectAsState

class MainActivity : ComponentActivity() {

private val viewModel: MainViewModel by viewModels()

override fun onCreate(savedInstanceState: Bundle?) {
..
setContent {
val conversation = viewModel.conversation.collectAsState()

ChatzTheme {
Surface {
ChatScreen(
model = ChatUiModel(
messages = conversation.value,
addressee = ChatUiModel.Author.bot
),
modifier = Modifier
)
}
}
}
}
}

Now, you can run the app and see that a default question appears. However, you still can’t send or receive anything. To make this work, we need to call the sendChat(msg) function, but where? Of course, inside the ChatBox component.

@Composable
fun ChatBox() {
var chatBoxValue by remember { mutableStateOf(TextFieldValue("")) }
Row {
TextField(
value = chatBoxValue,
onValueChange = { newText ->
chatBoxValue = newText
},
placeholder = {
Text(text = "Type something")
}
)
IconButton(
onClick = {
// TODO: send the message
}
)
}
}

We could pass the MainViewModel to the ChatBox composable Component, but it would restrict the usage of the ChatBox component to only the Activity with MainViewModel. However, we want to make the ChatBox component not only reusable but also testable. So, instead, we expose an event when the send icon is clicked. (*you might want to learn more about State Hoisting)

  • onSendChatClickListener: (String) -> Unit the listener passes the message from the TextField as a String
@Composable
fun ChatBox(onSendChatClickListener: (String) -> Unit) {
var chatBoxValue by remember { mutableStateOf(TextFieldValue("")) }
Row {
TextField(
value = chatBoxValue,
onValueChange = { newText ->
chatBoxValue = newText
},
placeholder = {
Text(text = "Type something")
}
)
IconButton(
onClick = {
onSendChatClickListener(chatBoxValue.text)
}
)
}
}

We also want the MainActivity to have the authority to send the message instead of the ChatScreen.

@Composable
fun ChatScreen(
model: ChatUiModel,
onSendChatClickListener: (String) -> Unit,
modifier: Modifier
) {
ConstraintLayout(modifier = modifier.fillMaxSize()) {
...
ChatBox(
onSendChatClickListener,
..
)
}
}

Okay, Let’s back to the MainActivity, now we can call viewModel.sendChat(msg) inside onSendChatClickListener { msg -> }:

class MainActivity : ComponentActivity() {

private val viewModel: MainViewModel by viewModels()

override fun onCreate(savedInstanceState: Bundle?) {
..
setContent {
val conversation = viewModel.conversation.collectAsState()

ChatzTheme {
Surface {
ChatScreen(
model = ChatUiModel(
messages = conversation.value,
addressee = ChatUiModel.Author.bot
),
onSendChatClickListener = { msg -> viewModel.sendChat(msg) },
modifier = Modifier
)
}
}
}
}
}

Compile and Run the app ▶️ (*anw, you can also find the full source code here: https://github.com/mzennis/Compose-Playground/tree/main/Chatz).

That’s all, thanks ~

Did this article help you gain new knowledge? If so, please give me a clap 🙇🏽‍♀️

--

--