Logo Mikołaj Kąkol
Single-pass Text Expansion

Single-pass Text Expansion

April 24, 2025
6 min read
No tags available
Table of Contents
index

Intro

Displaying potentially long blocks of text often requires a nice UI pattern: show the first few lines and provide a “show more” button if the text exceeds this limit. Implementing this efficiently in Jetpack Compose can seem tricky. How do you know if the text actually overflows its container before deciding whether to render the “show more” button, ideally without causing unnecessary recompositions or multiple layout passes? Let’s dive in SubcomposeLayout function in Compose that allows you to measure parts of your UI (sub-compositions) during the layout phase itself, make decisions based on those measurements, and then measure and place other parts accordingly—all within a single layout pass. Let’s explore how we can combine SubcomposeLayout with the onTextLayout callback provided by the Text composable to build a highly efficient ExpandableComment component.

The Requirement

We want to display a comment text. If the text fits within a predefined maximum number of lines (let’s say MAX_LINES), we show just the text. However, if the text requires more than MAX_LINES, we truncate it and display a “show more” button bellow. Clicking this button expands the text fully and changes the button text to “show less”. This needs to happen efficiently, avoiding extra recompositions just to check the text size.

The Solution SubcomposeLayout and onTextLayout combo

Straight into code, here’s the composable function that achieves this:

const val MAX_LINES = 5 // Define the collapsed line limit
@Composable
private fun ExpandableComment(lines: Int) {
// State to track whether the comment is expanded or not
var isExpanded by remember { mutableStateOf(false) }
// State variable to capture if the text overflows the max lines
var hasOverflow by remember { mutableStateOf(false) }
// SubcomposeLayout is the core of this solution
SubcomposeLayout(Modifier.padding(12.dp)) { constraints ->
// Determine the max lines based on the expanded state
val maxLines = if (isExpanded) Int.MAX_VALUE else MAX_LINES
// --- First Sub-composition: Measure the Text ---
// We measure the text first to see if it overflows.
val textPlaceable = subcompose("text") {
Text(
text = prepareText(lines),
modifier = Modifier
.animateContentSize()
.background(Color.LightGray),
maxLines = maxLines,
onTextLayout = { textLayoutResult ->
// This callback is crucial! It gives us layout info
// *after* measurement but *before* this SubcomposeLayout finishes.
hasOverflow = textLayoutResult.hasVisualOverflow
}
)
}.first().measure(constraints)
// --- Second Sub-composition (Conditional): Measure the Button ---
// We *only* measure the button if the text overflowed OR if it's already expanded.
val buttonPlaceable = if (hasOverflow || isExpanded) {
subcompose("button") {
Button(onClick = {
isExpanded = !isExpanded
}) {
Text(if (isExpanded) "show less" else "show more")
}
}.first().measure(constraints)
} else {
null
}
// --- Layout Phase ---
// Now we know the size of the text and the button (if it exists).
// Calculate the total height needed.
val totalHeight = textPlaceable.height + (buttonPlaceable?.height ?: 0)
// The layout() block defines the size of the SubcomposeLayout
// and places the measured children (Placeables).
layout(textPlaceable.width, totalHeight) {
// Place the text at the top-left (0, 0)
textPlaceable.placeRelative(0, 0)
// Place the button (if it exists) below the text
buttonPlaceable?.placeRelative(0, textPlaceable.height)
}
}
}

Code Breakdown

  1. State: isExpanded tracks the expansion state, while hasOverflow captures the text overflow status derived from onTextLayout.
  2. SubcomposeLayout: Orchestrates the measurement process.
  3. First subcompose("text"): Measures the Text composable using maxLines determined by isExpanded. Crucially, the onTextLayout callback updates hasOverflow based on textLayoutResult.hasVisualOverflow during this measurement phase. The animateContentSize() modifier handles the smooth transition.
  4. Conditional subcompose("button"): Measures the Button only if hasOverflow is true (text was truncated) or isExpanded is true (needs “show less”). This avoids measuring the button when unnecessary.
  5. layout { ... }: Defines the SubcomposeLayout’s final size based on the measured text and button (if present), then places the textPlaceable and buttonPlaceable accordingly.

Key Advantages of this Approach

  • Single Layout Pass: The decision to show the button and the measurement of both text and button (if needed) happen within a single execution of the SubcomposeLayout lambda.
  • No Unnecessary Recomposition: The component doesn’t need to recompose just to check if the text overflowed after an initial measurement. The check happens during layout via onTextLayout. Recomposition only happens when the isExpanded state actually changes (due to the button click).
  • Efficiency: Measuring the button only when necessary avoids wasted computation.

Demo

Full Source Code

Full code is available here: https://github.com/MikolajKakol/sublayout-textresult/blob/master/composeApp/src/commonMain/kotlin/me/mikolaj_kakol/sublayout_text/App.kt

package me.mikolaj_kakol.sublayout_text
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Button
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.unit.dp
private const val MAX_LINES = 5
@Composable
fun App() {
MaterialTheme {
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Text("Comment is less than $MAX_LINES lines")
ExpandableComment(4)
Text("Comment is more than $MAX_LINES lines")
ExpandableComment(8)
}
}
}
@Composable
private fun ExpandableComment(lines: Int) {
var isExpanded by remember { mutableStateOf(false) }
SubcomposeLayout(Modifier.padding(12.dp)) { constraints ->
var hasOverflow = false
val maxLines = if (isExpanded)
Int.MAX_VALUE
else
MAX_LINES
val reg = subcompose("reg") {
Text(
prepareText(lines),
modifier = Modifier
.animateContentSize()
.background(Color.LightGray),
maxLines = maxLines,
onTextLayout = {
hasOverflow = it.hasVisualOverflow
}
)
}.first().measure(constraints)
val button = if (hasOverflow || isExpanded) {
subcompose("button") {
Button(onClick = {
isExpanded = !isExpanded
}) {
if (isExpanded) {
Text("show less")
} else {
Text("show more")
}
}
}.first().measure(constraints)
} else null
layout(reg.width, reg.measuredHeight + (button?.height ?: 0)) {
reg.placeRelative(0, 0)
button?.placeRelative(0, reg.measuredHeight)
}
}
}
private fun prepareText(lines: Int): String {
return buildString {
repeat(lines) { this.appendLine("Comment line ${it + 1}") }
}.trim()
}

Summary

SubcomposeLayout is an incredibly powerful tool in the Jetpack Compose layout system. By allowing measurement and layout decisions within the layout pass, it enables the creation of complex, adaptive, and highly efficient UI components like this ExpandableComment, solving challenges that might otherwise require less performant workarounds. Combined with callbacks like onTextLayout, you gain fine-grained control over layout behaviour based on the actual measured results of your composables.