Introduction

In this post, I will show you how to make an expandable text that is controlled by a button below it using Jetpack Compose. Additionally, we’ll introduce some simple animations to make the text state changes a bit nicer and add an ellipsis at the end of the text to indicate that it has overflowed.

That’s the final result we’ll be implementing:

Solution

Let’s start with the following snippet and build on top of it:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            MyAppTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    ExpandableText(
                        modifier = Modifier.padding(16.dp)
                    )
                }
            }
        }
    }

You get that snippet after creating a new project using Android Studio’s project wizard. The only change I introduced is that I added the ExpandableText composable that we’ll be building.

First, let’s add a Text and TextButton composables (wrapped in a Column) inside our new ExpandableText composable:

@Composable
private fun ExpandableText(modifier: Modifier) {
    Column(
        modifier = modifier
    ) {
        Text(
            text = "Hello World! ".repeat(50),
            maxLines = 5,
            overflow = TextOverflow.Ellipsis
        )
        
        TextButton(
            onClick = { },
            modifier = Modifier.fillMaxWidth(),
            shape = RectangleShape
        ) {
            Text(
                text = "Expand".uppercase()
            )
        }
    }
}

I am passing a modifier as a parameter to the ExpandableText to be able to control it from the outside.

Inside, I am setting the text to “Hello World! " repeated 50 times to make sure it overflows. Also, I’m limiting the number of lines to 5 and passing TextOverflow.Ellipsis to the overflow parameter to use an ellipsis when the text overflows (by default, it just clips the text).

The TextButton below is set to fill the entire width and has a RectangleShape because it looks more appropriate in this case than the default, rounded shape.

This is what we have so far:

Next, let’s implement the onClick for our TextButton. When clicking, we want to remove the limit of 5 lines for the text (so that it expands) and change the text for the button from “Expand” to “Collapse”.

For that, we will introduce a mutable state variable called isExpanded that will be toggled inside the onClick and control the parameters mentioned above like this:

@Composable
private fun ExpandableText(modifier: Modifier) {
    var isExpanded by remember { mutableStateOf(false) }

    // ...
    Text(
        // ...
        maxLines = if (isExpanded) Int.MAX_VALUE else 5,
    )

    TextButton(
        // ...
        onClick = { isExpanded = !isExpanded },
    ) {
        Text(
            text = (if (isExpanded) "Collapse" else "Expand").uppercase()
        )
    }
}

After those changes, expanding and collapsing works properly:

But there is one problem with our implementation. What happens when the text is shorter than 5 lines?

We don’t want to show the button when the text has not overflowed. To solve this, we need to find a way to be notified when the overflow happens and store it as a state.

Luckily, with Compose, it’s easy to do. The Text composable exposes a onTextLayout parameter which is a callback that is executed when a new text layout is calculated. That callback will receive a TextLayoutResult object, from which we will be able to get all the necessary information:

@Composable
private fun ExpandableText(modifier: Modifier) {
    var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
    val isExpandable by remember { derivedStateOf { textLayoutResult?.didOverflowHeight ?: false } }

    // ...
    Text(
        // ...
        onTextLayout = { textLayoutResult = it }
    )
}

We pass a lambda to the onTextLayout parameter, which will save a new TextLayoutResult object every time it changes into our textLayoutResult state.

Additionally, we define a derived state called isExpandable that will be recalculated every time the textLayoutResult?.didOverflowHeight changes. When it returns true, it means the text occupies more than 5 lines.

We used derived state here because the isExpandable is never modified on its own. This state is always calculated based on the value of the textLayoutResult state. To learn more about derived states, check the documentation.

Now, since we know when the text overflows, we can add another derived state that will control the visibility of the button:

@Composable
private fun ExpandableText(modifier: Modifier) {
    val isButtonShown by remember { derivedStateOf { isExpandable || isExpanded } }

    // ...
    if (isButtonShown) {
        TextButton(
            // ...
        ) {
            // ...
        }
    }
}

This logic basically means that we only want to show the button when the text has overflowed (to be able to expand it) or when it’s already expanded (to be able to collapse it).

Finally, the only thing that’s missing is the animation, which we can add using a single modifier on our Text - animateContentSize():

@Composable
private fun ExpandableText(modifier: Modifier) {
    // ...
    Text(
        // ...
        modifier = Modifier.animateContentSize(),
    )
}

And with that change, we get the desired effect after running the app!

Here’s the full code:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            ExpandableTextTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    ExpandableText(
                        modifier = Modifier.padding(16.dp)
                    )
                }
            }
        }
    }
}

@Composable
private fun ExpandableText(modifier: Modifier) {
    var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
    val isExpandable by remember { derivedStateOf { textLayoutResult?.didOverflowHeight ?: false } }
    var isExpanded by remember { mutableStateOf(false) }
    val isButtonShown by remember { derivedStateOf { isExpandable || isExpanded } }

    Column(
        modifier = modifier
    ) {
        Text(
            text = "Hello World! ".repeat(50),
            modifier = Modifier.animateContentSize(),
            maxLines = if (isExpanded) Int.MAX_VALUE else 5,
            overflow = TextOverflow.Ellipsis,
            onTextLayout = { textLayoutResult = it }
        )

        if (isButtonShown) {
            TextButton(
                onClick = { isExpanded = !isExpanded },
                modifier = Modifier.fillMaxWidth(),
                shape = RectangleShape
            ) {
                Text(
                    text = (if (isExpanded) "Collapse" else "Expand").uppercase()
                )
            }
        }
    }
}

Summary

That’s it for this post. I hope you found it helpful and learned something from it. If you have any questions, feel free to leave a comment here or reach out to me on Twitter.