Instagram-like Expandable Text — Android Jetpack Compose

SEONGJU KIM
8 min readMar 19, 2024

In the first tab of Instagram, each post combines media (images or videos) with text. When the text content surpasses two lines, only the first two are displayed, followed by ‘… more’ to signal additional content. Tapping this expands the text.

This article names this UI pattern ‘ExpandableText’. Such a component enhances UI efficiency by summarizing lengthy text, facilitating quick information access and boosting user engagement. Using ‘… more’ effectively guides users, offering controlled content interaction and improved app usability.

We’ll delve into crafting ‘ExpandableText’ with Jetpack Compose, aiming for the following result:

This article reflects my personal implementation experience. If you know a better or more accurate method, I welcome your insights in the comments.

Let’s dive into the code. We’ll start by setting up a straightforward UI, defining models, and creating dummy data. For image rendering, we’re utilizing the Coil library.

Here’s how we implement it for each post:

import android.net.Uri
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import coil.compose.AsyncImage
import coil.request.ImageRequest

@Stable
data class Post(
val id: String,
val uri: Uri,
val content: String
)

@Composable
fun Post(
item: Post,
expanded: Boolean,
modifier: Modifier = Modifier,
onMoreClick: (String) -> Unit
) {
Column(modifier = modifier) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(item.uri)
.crossfade(true)
.build(),
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
contentScale = ContentScale.Crop
)

ExpandableText(
text = item.content,
expanded = expanded,
modifier = Modifier.fillMaxWidth()
) {
onMoreClick(item.id)
}
}
}

The implementation for the feed is below:

import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier

data class Feed(private val list: List<Post>) : List<Post> by list

@Composable
fun Feed(
feed: Feed,
modifier: Modifier = Modifier
) {
val expandedPostIds = remember {
mutableStateListOf<String>()
}

LazyColumn(modifier = modifier) {
items(
items = feed,
key = {
it.id
}
) { item ->
Post(
item = item,
expanded = expandedPostIds.contains(item.id)
) {
if (!expandedPostIds.remove(it)) {
expandedPostIds.add(it)
}
}
}
}
}

Implement dummy data and MainActivity as shown below:

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import instagram.like.expandable.text.ui.theme.InstagramLikeExpandableTextTheme

private val contents = listOf(
"""
Generating long and coherent text is an important but challenging task, particularly for open-ended language generation tasks such as story generation. Despite the success in modeling intra-sentence coherence, existing generation models (e.g., BART) still struggle to maintain a coherent event sequence throughout the generated text. We conjecture that this is because of the difficulty for the decoder to capture the high-level semantics and discourse structures in the context beyond token-level co-occurrence. In this paper, we propose a long text generation model, which can represent the prefix sentences at sentence level and discourse level in the decoding process. To this end, we propose two pretraining objectives to learn the representations by predicting inter-sentence semantic similarity and distinguishing between normal and shuffled sentence orders. Extensive experiments show that our model can generate more coherent texts than state-of-the-art baselines.
""".trimIndent(),
"Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. In enim justo, rhoncus ut, imperdiet a, venenatis vitae, justo. Nullam dictum felis eu pede mollis pretium. Integer tincidunt. Cras dapibus. Vivamus elementum semper nisi. Aenean vulputate eleifend tellus. Aenean leo ligula, porttitor eu, consequat vitae, eleifend ac, enim. Aliquam lorem ante, dapibus in, viverra quis, feugiat a, tellus. Phasellus viverra nulla ut metus varius laoreet. Quisque rutrum. Aenean imperdiet. Etiam ultricies nisi vel augue. Curabitur ullamcorper ultricies nisi. Nam eget dui. Etiam rhoncus. Maecenas tempus, tellus eget condimentum rhoncus, sem quam semper libero, sit amet adipiscing sem neque sed ipsum. Nam quam nunc, blandit vel, luctus pulvinar, hendrerit id, lorem. Maecenas nec odio et ante tincidunt tempus. Donec vitae sapien ut libero venenatis faucibus. Nullam quis ante. Etiam sit amet orci eget eros faucibus tincidunt. Duis leo. Sed fringilla mauris sit amet nibh. Donec sodales sagittis magna. Sed consequat, leo eget bibendum sodales, augue velit cursus nunc,",
"short one",
"Just Line Breaks \n..\n..\n..",
"" // blank case
)

private val uriStrings = listOf(
"https://cdn.pixabay.com/photo/2023/08/03/09/57/ai-generated-8166706_1280.png",
"https://cdn.pixabay.com/photo/2023/08/20/08/18/ai-generated-8201922_1280.png",
"https://cdn.pixabay.com/photo/2023/08/05/14/24/twilight-8171206_1280.jpg",
"https://cdn.pixabay.com/photo/2023/11/20/13/21/mountain-8401084_1280.png",
"https://cdn.pixabay.com/photo/2017/11/09/21/41/cat-2934720_1280.jpg"
)

private val dummyFeed = Feed(
contents.zip(uriStrings).mapIndexed { index, (content, uriString) ->
Post(
id = "$index",
uri = Uri.parse(uriString),
content = content
)
}
)

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
InstagramLikeExpandableTextTheme {
Feed(
feed = dummyFeed,
modifier = Modifier.fillMaxSize()
)
}
}
}
}

Now, let’s delve into implementing ExpandableText.

First, examining the UI of such a component, you’ll notice the structure seems to integrate ‘… more’ within the Text component itself, executing a specific action when clicked. One might simplify by separating this trailing text into another component, like a text button, but it appears that wouldn’t sit well with our designers.

So, how do we embed this action within the Text? Thankfully, Compose offers a solution with ClickableText This component extends the basic Text composable by incorporating an onClick lambda parameter, enabling us to receive the index of the click point.

For applying varied styles, leveraging AnnotatedString makes it straightforward.

Implementing click actions and applying styles can be achieved with ease.

So, what remains is to ensure that when collapsed, the ‘…more’ text is displayed at the appropriate position.

First, we need to define up to which line the content should be shown in its collapsed state. If the content exceeds this line, the more text should be displayed; for shorter texts that do not exceed this limit, it is reasonable to show the entire content.

Let’s define the necessary attributes first.

@Composable
fun ExpandableText(
text: String,
expanded: Boolean,
modifier: Modifier = Modifier,
style: TextStyle = TextStyle.Default,
softWrap: Boolean = true,
overflow: TextOverflow = TextOverflow.Clip,
maxLines: Int = Int.MAX_VALUE,
trailingTextSpanStyle: SpanStyle = SpanStyle(color = Color.LightGray),
collapsedMaxLines: Int = 2,
onClick: () -> Unit
) {
val ellipsisStyle = trailingTextSpanStyle.copy(
letterSpacing = 0.06.em.unaryMinus()
)
val ellipsis = " ... "
val trailingText = "more"
val trailingAnnotatedString = buildAnnotatedString {
withStyle(ellipsisStyle) {
append(ellipsis)
}

withStyle(trailingTextSpanStyle) {
append(trailingText)
}
}

val textMeasurer = rememberTextMeasurer()
// ...
}

Most parameters are taken from ClickableText. The additional parameters include expanded to control the expansion state, collapsedMaxLines representing the maximum number of text lines to show in the collapsed state, and trailingTextSpanStyle for the styling of the trailing text.

Typically, the style of the ‘more’ text is less emphasized than the main content. For this, we accept a trailingTextSpanStyle parameter.

The onClick lambda is for managing the expanded state or performing additional actions when ClickableText is clicked.

To achieve our goal, we need to determine how much text to show and where to insert the ‘more’ text when in the collapsed state. This necessitates measuring the text length before rendering, hence the use of a textMeasurer for this purpose.

For accurate measurement, we need the component’s constraints, so we will use BoxWithConstraints.

BoxWithConstraints(modifier = modifier) {
var collapsedText by remember {
mutableStateOf<String?>(null)
}

val shouldShowMore: Boolean
val defaultLineEnd: Int

with(textMeasurer.measure(text = text, style = style, constraints = constraints)) {
shouldShowMore = lineCount > collapsedMaxLines

defaultLineEnd = getLineEnd(
if (shouldShowMore) {
collapsedMaxLines.dec()
} else {
lineCount.dec()
}
)
.minus((ellipsis + trailingText).length)
.coerceAtLeast(0)
}
// ...
}

The MeasureResult returned from textMeasurer's measure provides the necessary information. Here, getLineEnd returns the index of the last character of a specific line.

Since each character has a different width, simply cutting the string by the length of the text might not yield the expected UI, as it could result in the following scenario:

Hello, Next Line
Hello, …more // Required
Hello, Next …more // If substring is done by index

I will explain defaultLineEnd later where it is used.

To identify the appropriate position for the text to be displayed correctly in the UI, we might need to resort to a brute force method. (If you know a better approach, please share it in the comments.)

We will write the following LaunchedEffect inside BoxWithConstraints.

LaunchedEffect(key1 = text) {
if (collapsedText != null) return@LaunchedEffect

var tempCollapsedText = text
var i = 0

while (true) {
val textLayoutResult = textMeasurer.measure(
buildAnnotatedString {
append(tempCollapsedText)
append(trailingAnnotatedString)
},
style = style,
constraints = constraints
)

if (textLayoutResult.lineCount > collapsedMaxLines) {
tempCollapsedText = tempCollapsedText.substring(
0,
textLayoutResult
.getLineEnd(collapsedMaxLines.dec())
.coerceAtMost(tempCollapsedText.length)
.minus(i++)
)
} else {
collapsedText = tempCollapsedText
break
}
}
}

When collapsedText is not null, it signifies that an appropriate collapsed text has been configured. For precise UI rendering, a while loop iterates to measure tempCollapsedText, removing one character at a time from the end of the string specified by collapsedMaxLines.

Once the maximum line limit is reached, the loop assigns the actual text to display in the collapsed state and ends the iteration.

Now, with the formulated collapsedText, we can construct the ‘… more’ text.

If there’s no need to show the ‘… more’ text or the state is expanded, we display the original text.

If the collapsed state text is not yet configured, We show a substring by using the defaultLineEnd defined above to estimate the value.

Otherwise, we add the ‘… more’ text to display as follows:

val displayText by remember(collapsedText, expanded) {
derivedStateOf {
when {
!shouldShowMore -> AnnotatedString(text)
expanded -> AnnotatedString(text)
collapsedText == null -> AnnotatedString(text.substring(0, defaultLineEnd))
else -> buildAnnotatedString {
append(collapsedText)

if (shouldShowMore) {
append(trailingAnnotatedString)
}
}
}
}
}

ClickableText(
text = displayText,
modifier = Modifier
.fillMaxSize()
.animateContentSize(),
style = style,
softWrap = softWrap,
overflow = overflow,
maxLines = maxLines,
onClick = {
onClick()
}
)

The complete code for the ExpandableText component is as follows:

import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.text.ClickableText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.em

@Composable
fun ExpandableText(
text: String,
expanded: Boolean,
modifier: Modifier = Modifier,
style: TextStyle = TextStyle.Default,
softWrap: Boolean = true,
overflow: TextOverflow = TextOverflow.Clip,
maxLines: Int = Int.MAX_VALUE,
trailingTextSpanStyle: SpanStyle = SpanStyle(color = Color.LightGray),
collapsedMaxLines: Int = 2,
onClick: () -> Unit
) {
val ellipsisStyle = trailingTextSpanStyle.copy(
letterSpacing = 0.06.em.unaryMinus()
)
val ellipsis = " ... "
val trailingText = "more"
val trailingAnnotatedString = buildAnnotatedString {
withStyle(ellipsisStyle) {
append(ellipsis)
}

withStyle(trailingTextSpanStyle) {
append(trailingText)
}
}

val textMeasurer = rememberTextMeasurer()

BoxWithConstraints(modifier = modifier) {
var collapsedText by remember {
mutableStateOf<String?>(null)
}

val shouldShowMore: Boolean
val defaultLineEnd: Int

with(textMeasurer.measure(text = text, style = style, constraints = constraints)) {
shouldShowMore = lineCount > collapsedMaxLines

defaultLineEnd = getLineEnd(
if (shouldShowMore) {
collapsedMaxLines.dec()
} else {
lineCount.dec()
}
)
.minus((ellipsis + trailingText).length)
.coerceAtLeast(0)
}

LaunchedEffect(key1 = text) {
if (collapsedText != null) return@LaunchedEffect

var tempCollapsedText = text
var i = 0

while (true) {
val textLayoutResult = textMeasurer.measure(
buildAnnotatedString {
append(tempCollapsedText)
append(trailingAnnotatedString)
},
style = style,
constraints = constraints
)

if (textLayoutResult.lineCount > collapsedMaxLines) {
tempCollapsedText = tempCollapsedText.substring(
0,
textLayoutResult
.getLineEnd(collapsedMaxLines.dec())
.coerceAtMost(tempCollapsedText.length)
.minus(i++)
)
} else {
collapsedText = tempCollapsedText
break
}
}
}

val displayText by remember(collapsedText, expanded) {
derivedStateOf {
when {
!shouldShowMore -> AnnotatedString(text)
expanded -> AnnotatedString(text)
collapsedText == null -> AnnotatedString(text.substring(0, defaultLineEnd))
else -> buildAnnotatedString {
append(collapsedText)

if (shouldShowMore) {
append(trailingAnnotatedString)
}
}
}
}
}

ClickableText(
text = displayText,
modifier = Modifier
.fillMaxSize()
.animateContentSize(),
style = style,
softWrap = softWrap,
overflow = overflow,
maxLines = maxLines,
onClick = {
onClick()
}
)
}
}

Thanks for reading. I hope you found it helpful. If you have questions or feedback, please leave a comment. Stay tuned for more in our next post!

--

--