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
@Composableprivate 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
- State:
isExpanded
tracks the expansion state, whilehasOverflow
captures the text overflow status derived fromonTextLayout
. SubcomposeLayout
: Orchestrates the measurement process.- First
subcompose("text")
: Measures theText
composable usingmaxLines
determined byisExpanded
. Crucially, theonTextLayout
callback updateshasOverflow
based ontextLayoutResult.hasVisualOverflow
during this measurement phase. TheanimateContentSize()
modifier handles the smooth transition. - Conditional
subcompose("button")
: Measures theButton
only ifhasOverflow
is true (text was truncated) orisExpanded
is true (needs “show less”). This avoids measuring the button when unnecessary. layout { ... }
: Defines theSubcomposeLayout
’s final size based on the measured text and button (if present), then places thetextPlaceable
andbuttonPlaceable
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 theisExpanded
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.animateContentSizeimport androidx.compose.foundation.backgroundimport androidx.compose.foundation.layout.Columnimport androidx.compose.foundation.layout.fillMaxWidthimport androidx.compose.foundation.layout.paddingimport androidx.compose.material.Buttonimport androidx.compose.material.MaterialThemeimport androidx.compose.material.Textimport androidx.compose.runtime.*import androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.graphics.Colorimport androidx.compose.ui.layout.SubcomposeLayoutimport androidx.compose.ui.unit.dp
private const val MAX_LINES = 5
@Composablefun 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) } }}
@Composableprivate 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.