
In this article, we’ll learn how to implement expandable card animation in Jetpack Compose.
Our Final Output Looks Like This:

Let’s start coding.
For this article, create an empty Jetpack Compose project and open MainActivity.kt. Create a MyUI() composable and call it from the onCreate() method. We’ll write our code in it.
MainActivity for Material 3 Jetpack Compose:
// add the following packages
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
YourProjectNameTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
MyUI()
}
}
}
}
}
}
@Composable
fun MyUI() {
}
For the Material 2 version:
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
YourProjectNameTheme(darkTheme = false) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
MyUI()
}
}
}
}
}
}
@Composable
fun MyUI() {
}
Note: I am using the Material 3 Jetpack Compose. If you are working with Material 2, you may see a slightly different appearance, but the code and animation will still work as expected.
In the Android Studio, open res > values > strings.xml file and add the following string.
<string name="random_text">It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout. The point of using Lorem Ipsum is that it has a more-or-less normal distribution of letters, as opposed to using Content here, content here, making it look like readable English.</string>
First, let’s create a simple card.
@Composable
fun MyUI() {
val randomText = stringResource(id = R.string.random_text)
Card(
modifier = Modifier
.padding(all = 8.dp) // add margin
.fillMaxWidth(), // set width
shape = RoundedCornerShape(size = 8.dp) // set corner size
) {
Column(
modifier = Modifier
.padding(all = 8.dp) // inner padding
) {
Text(
text = randomText
)
}
}
}
Output:

In Jetpack Compose, the stringResource() method is used to access the string resources. We are using it to get our random_text and assigning it to the variable randomText.
Next, we created a Card. We have set the margin, width, and corner size. Inside the card, we created a Column layout and added Text() to it. Let’s expand the card on a click.
@Composable
fun MyUI() {
val randomText = stringResource(id = R.string.random_text)
// remember the state of the card
var expanded by remember {
mutableStateOf(false) // initial value
}
Card(
modifier = Modifier
.padding(all = 8.dp)
.fillMaxWidth(),
shape = RoundedCornerShape(size = 8.dp)
) {
Column(
modifier = Modifier
.clickable {
expanded = !expanded // toggle the state
}
.padding(all = 8.dp)
) {
Text(
text = randomText,
maxLines = if (expanded) Int.MAX_VALUE else 1 // change the text based on the state
)
}
}
}
Output:

We added a state variable called expanded. It is used to store if the card is expanded or collapsed.
Next, we applied the clickable modifier on the Column layout. Inside the block, we are changing the card state.
In the Text() composable, we added maxLines parameter. If the card is expanded, it shows the entire text. Otherwise, it displays only the first line.
Currently, the card appears and disappears instantly. Let’s add an animation using the AnimatedVisibility API. In the final output, the first line is visible regardless of the card state. So, let’s display it separately.
@Composable
fun MyUI() {
val randomText = stringResource(id = R.string.random_text)
var expanded by remember {
mutableStateOf(false)
}
Card(
modifier = Modifier
.padding(all = 8.dp)
.fillMaxWidth(),
shape = RoundedCornerShape(size = 8.dp)
) {
Column(
modifier = Modifier
.clickable {
expanded = !expanded
}
.padding(all = 8.dp)
) {
// first line
Text(
text = randomText,
maxLines = 1,
overflow = if (expanded) TextOverflow.Clip else TextOverflow.Ellipsis // to show the 3 dots at the end
)
// animate the text
AnimatedVisibility(
visible = expanded,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
// remaining text
Text(
text = randomText,
maxLines = Int.MAX_VALUE // show the full text
)
}
}
}
}
Output:

In the Column layout, we added Text() and passed 1 to the maxLines parameter. As a result, only the first line is displayed. After that, we put the remaining text inside the AnimatedVisibility composable.
The problem is that we are seeing the first line two times. To fix this, calculate the last index of the first line and display the remaining text from that index.
@Composable
fun MyUI() {
val randomText = stringResource(id = R.string.random_text)
var expanded by remember {
mutableStateOf(false)
}
// last index of the first line
var lastIndex = 0
Card(
modifier = Modifier
.padding(all = 8.dp)
.fillMaxWidth(),
shape = RoundedCornerShape(size = 8.dp)
) {
Column(
modifier = Modifier
.clickable {
expanded = !expanded
}
.padding(all = 8.dp)
) {
// first line
Text(
text = randomText,
maxLines = 1,
overflow = if (expanded) TextOverflow.Clip else TextOverflow.Ellipsis,
onTextLayout = { textLayoutResult ->
// get the last index
lastIndex = textLayoutResult.getLineEnd(
lineIndex = 0,
visibleEnd = true
)
}
)
// animate the text
AnimatedVisibility(
visible = expanded,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
// remaining text
Text(
text = randomText.substring(lastIndex + 1), // display the remaining text
maxLines = Int.MAX_VALUE
)
}
}
}
}
Output:

The lastIndex variable stores the last index of the first line. To calculate the index, we are using the onTextLayout parameter. It provides a getLineEnd() method. If we set visibleEnd = true, it returns the last index value.
Next, we are showing the remaining text using the substring() method. Our final code looks like this:
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
YourProjectNameTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
MyUI()
}
}
}
}
}
}
@Composable
fun MyUI() {
val randomText = stringResource(id = R.string.random_text)
// remember the state of the card
var expanded by remember {
mutableStateOf(false) // initial value
}
// last index of the first line
var lastIndex = 0
Card(
modifier = Modifier
.padding(all = 8.dp)
.fillMaxWidth(),
shape = RoundedCornerShape(size = 8.dp)
) {
Column(
modifier = Modifier
.clickable {
expanded = !expanded // toggle the state
}
.padding(all = 8.dp)
) {
// first line
Text(
text = randomText,
maxLines = 1,
overflow = if (expanded) TextOverflow.Clip else TextOverflow.Ellipsis,
onTextLayout = { textLayoutResult ->
// get the last index
lastIndex = textLayoutResult.getLineEnd(
lineIndex = 0,
visibleEnd = true
)
}
)
// animate the text
AnimatedVisibility(
visible = expanded,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
// remaining text
Text(
text = randomText.substring(lastIndex + 1), // display the remaining text
maxLines = Int.MAX_VALUE // display the whole text
)
}
}
}
}
Output:

This is all about the expandable card animation in Jetpack Compose. I hope you enjoyed it. If you have any doubts, leave a comment below.
Related Articles: