KINTO Tech Blog
Jetpack Compose

InlineContent in Jetpack Compose: The Hidden Gem for Complex Text UI

Cover Image for InlineContent in Jetpack Compose: The Hidden Gem for Complex Text UI

Introduction

Hello! I'm Rasel, and today I want to share something that most Android developers overlook - the inlineContent property of Jetpack Compose's Text composable.

Recently, while working on the my route app at KINTO Technologies Corporation, we encountered a UI challenge that seemed simple but turned out to be quite complex: displaying colored benefit labels inline with ticket names in our ticket usage history screen.

Figma design showing inline label and ticket name

The tickets shown are samples. Please check the app for tickets that are actually on sale.

As you can see in the design, we needed a pink labeled text with rounded corners that flows naturally with the ticket name text. Most developers would reach for Row or FlowRow to solve this, but these approaches have significant limitations when dealing with text that needs to wrap and flow naturally.

The Problem: When Row and FlowRow Fall Short

Consider this UI pattern from our actual design:

[With coupon] One-day Pass for all Kagoshima City buses, trams and ferry routes.

Where [With coupon] is a pink label with rounded corners that must appear seamlessly inline with the text.

Approach 1: Using Row (The Naive Solution)

@Composable
fun TicketNameWithRow(name: String, benefitLabel: String) {
    Row(
        horizontalArrangement = Arrangement.spacedBy(4.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Box(
            modifier = Modifier
                .background(
                    color = MaterialTheme.colors.subPink, // Design system color
                    shape = RoundedCornerShape(2.dp),
                )
                .padding(horizontal = 4.dp),
            contentAlignment = Alignment.Center,
        ) {
            Text(
                text = benefitLabel,
                style = MaterialTheme.typography.body3Bold,
                color = MaterialTheme.colors.onPrimaryHighEmphasis,
            )
        }

        Text(
            text = name,
            style = MaterialTheme.typography.body2Bold,
        )
    }
}

Result:
Result of Row approach: label and text misaligned

Problems with Row:

  • Label doesn't align perfectly with text baseline
  • Inconsistent spacing when text wraps
  • Breaks the natural text flow

Approach 2: Using FlowRow (Better, But Still Limited)

@Composable
fun TicketNameWithFlowRow(name: String, benefitLabel: String) {
    FlowRow(
        horizontalArrangement = Arrangement.spacedBy(4.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Box(
            modifier = Modifier
                .background(
                    color = MaterialTheme.colors.subPink,
                    shape = RoundedCornerShape(2.dp),
                )
                .padding(horizontal = 4.dp),
            contentAlignment = Alignment.Center,
        ) {
            Text(
                text = benefitLabel,
                style = MaterialTheme.typography.body3Bold,
                color = MaterialTheme.colors.onPrimaryHighEmphasis,
            )
        }

        Text(
            text = name,
            style = MaterialTheme.typography.body2Bold,
        )
    }
}

Result:
Result of FlowRow approach: label and text still not aligned

As shown above, the UI still doesn't align perfectly with our design. The label and text do not flow naturally when wrapping.

Another Approach: Using AnnotatedString (SpanStyle)

Before Jetpack Compose supported true inline composables, many developers tried to achieve similar effects using only AnnotatedString and SpanStyle. This approach uses background color and rounded corners via SpanStyle to style part of the text as a label.

Approach 3: AnnotatedString with SpanStyle

This method is limited compared to inlineContent, but can be useful for simple cases where you only need background color and text styling (not custom composables or padding).

@Composable
fun TicketNameWithAnnotatedString(name: String, benefitLabel: String) {
    val cornerRadius = with(LocalDensity.current) { 2.dp.toPx() }
    val drawStyle = Stroke(pathEffect = PathEffect.cornerPathEffect(cornerRadius))
    val nameStyle = MaterialTheme.typography.body2Bold.toSpanStyle()
    val benefitStyle = MaterialTheme.typography.body3Bold.copy(
        color = MaterialTheme.colors.onPrimaryHighEmphasis,
        background = MaterialTheme.colors.subPink,
        drawStyle = drawStyle,
    ).toSpanStyle()

    val annotatedString =
        remember(name, benefitLabel) {
            buildAnnotatedString {
                withStyle(style = benefitStyle) {
                    append(" $benefitLabel ") // Add spaces for padding effect
                }
                append(" ")
                withStyle(style = nameStyle) {
                    append(name)
                }
            }
        }

    Text(text = annotatedString)
}

Result:
Result of AnnotatedString approach: label with background color, no rounded corners

Limitations:

  • Rounded corners are not achieved properly while using proper value from design (background is mostly a rectangle, and needs to adjust value to make it rounded, still proper rounding is not achieved)
  • No custom composable or icon support
  • Padding is simulated with spaces, not true padding
  • Baseline alignment is good, but label may not look as polished as with inlineContent
  • Text styling also gets changed from design

When to use:

  • When you only need a colored background and simple text styling
  • When you want a dependency-free, simple solution for basic badges

The Solution: InlineContent - The Proper Way

InlineContent allows you to embed custom composable directly within text, treating them as characters in the text flow. Here's how we implemented it in the my route app:

Step 1: Understanding the Production Implementation

Below is a simplified version of our production code, showing how to use inlineContent to embed a custom label inside text:

@Composable
private fun TicketNameWithBenefit(
    name: String,
    benefitLabel: String,
) {
    val nameStyle = MaterialTheme.typography.body2Bold.toSpanStyle()

    val annotatedString = remember(name, benefitLabel) {
        buildAnnotatedString {
            appendInlineContent(benefitLabel) // Use label text as unique key
            append(" ")
            withStyle(style = nameStyle) {
                append(name)
            }
        }
    }

    // ... (inline content implementation)

    Text(
        text = annotatedString,
        inlineContent = inlineContent,
    )
}

Step 2: Dynamic Width Calculation (The Critical Part)

Calculating the exact width for the label background is essential for a seamless look. Here is how we do it:

The most challenging aspect is calculating the exact width needed for the label background:

val density = LocalDensity.current
val textMeasurer = rememberTextMeasurer()
val horizontalPadding = 4.dp

val benefitLabelBgWidthSp = remember(benefitLabel) {
    val textLayoutMeasure = textMeasurer.measure(
        text = benefitLabel,
        style = benefitStyle,
    )
    with(density) {
        (textLayoutMeasure.size.width.toDp() + (horizontalPadding * 2)).toSp()
    }
}

Why this is complex:

  1. Text Measurement: We need to measure the text before rendering
  2. Unit Conversion: Convert between pixels, dp, and sp correctly
  3. Padding Calculation: Include padding in the total width
  4. Density Awareness: Handle different screen densities

Step 3: Creating the Inline Content Mapping

Now, we map the label to a composable using InlineTextContent, ensuring it aligns and sizes perfectly:

val inlineContent = remember(benefitLabel, benefitLabelBgWidthSp) {
    mapOf(
        benefitLabel to InlineTextContent(
            placeholder = Placeholder(
                width = benefitLabelBgWidthSp,
                height = benefitStyle.lineHeight,
                placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
            ),
        ) {
            Box(
                modifier = Modifier
                    .background(
                        color = MaterialTheme.colors.subPink,
                        shape = RoundedCornerShape(2.dp)
                    )
                    .padding(horizontal = horizontalPadding),
                contentAlignment = Alignment.Center,
            ) {
                Text(
                    text = benefitLabel,
                    style = benefitStyle,
                    color = MaterialTheme.colors.onPrimaryHighEmphasis,
                )
            }
        }
    )
}

Key implementation details:

  • placeholderVerticalAlign = PlaceholderVerticalAlign.Center ensures perfect alignment
  • height = benefitStyle.lineHeight matches the text height exactly
  • Dynamic width calculation ensures perfect fit for any label text

The Complete Production Implementation

Here's our actual implementation from the my route app:

@Composable
private fun TicketNameWithBenefit(
    name: String,
    benefitLabel: String,
) {
    val nameStyle = MaterialTheme.typography.body2Bold.toSpanStyle()
    val benefitStyle = MaterialTheme.typography.body3Bold
    val annotatedString =
        remember(name, benefitLabel) {
            buildAnnotatedString {
                appendInlineContent(benefitLabel)
                append(" ")
                withStyle(style = nameStyle) {
                    append(name)
                }
            }
        }

    val density = LocalDensity.current
    val textMeasurer = rememberTextMeasurer()
    val horizontalPadding = 4.dp

    val benefitLabelBgWidthSp =
        remember(benefitLabel) {
            val textLayoutMeasure = textMeasurer.measure(text = benefitLabel, style = benefitStyle)
            with(density) {
                (textLayoutMeasure.size.width.toDp() + (horizontalPadding * 2)).toSp()
            }
        }

    val inlineContent =
        remember(benefitLabel, benefitLabelBgWidthSp) {
            mapOf(
                benefitLabel to
                    InlineTextContent(
                        placeholder =
                            Placeholder(
                                width = benefitLabelBgWidthSp,
                                height = benefitStyle.lineHeight,
                                placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
                            ),
                    ) {
                        Box(
                            modifier =
                                Modifier
                                    .background(
                                        color = MaterialTheme.colors.subPink,
                                        shape = RoundedCornerShape(2.dp),
                                    )
                                    .padding(horizontal = horizontalPadding),
                            contentAlignment = Alignment.Center,
                        ) {
                            Text(
                                text = benefitLabel,
                                style = benefitStyle,
                                color = MaterialTheme.colors.onPrimaryHighEmphasis,
                            )
                        }
                    },
            )
        }

    Text(
        text = annotatedString,
        inlineContent = inlineContent,
    )
}

Result:
Result of inlineContent approach: label and text perfectly aligned

As you can see, this implementation perfectly aligns with our design goal.

Why We Switched from External Library

When we couldn't achieve our design using Row, FlowRow and even AnnotatedString approach, we tried using an third party library:

val extendedSpans = remember {
  ExtendedSpans(
    RoundedCornerSpanPainter(),
  )
}

Text(
  modifier = Modifier.drawBehind(extendedSpans),
  text = remember(text) {
    extendedSpans.extend(text)
  },
  onTextLayout = { result ->
    extendedSpans.onTextLayout(result)
  }
)

By using a third-party library, we finally achieved our design goal. But we don't want to ship another library with our app just for this single Text UI item and reconsidered other approaches.

Problems we encountered:

  • Additional dependency for a simple feature
  • Less control over styling and behavior
  • Potential compatibility issues with future Compose versions

The native solution proved to be better:

  • No external dependencies
  • Full control over styling and behavior
  • Better performance
  • Future-proof with Compose updates

Advantages of InlineContent Approach

  1. Natural Text Flow: Labels wrap with text naturally, maintaining proper line breaks
  2. Perfect Alignment: Consistent baseline alignment with surrounding text
  3. Flexible Styling: Full support for complex text formatting (bold, colors, sizes)
  4. Dynamic Sizing: Adapts to content automatically while maintaining design consistency
  5. Performance: Efficient rendering as part of the text layout engine
  6. Accessibility: Screen readers handle it as part of the text flow

Common Pitfalls and Solutions

Pitfall 1: Hardcoded Dimensions

Wrong:

Placeholder(width = 60.sp, height = 16.sp) // Fixed dimensions

Correct:

Placeholder(
    width = calculatedWidth, // Dynamic based on content
    height = textStyle.lineHeight, // Match text height
)

Pitfall 2: Incorrect Unit Conversion

Wrong:

textLayout.size.width.toSp() // Direct conversion loses density information

Correct:

with(density) {
    textLayout.size.width.toDp().toSp() // Proper density-aware conversion
}

Pitfall 3: Missing Performance Optimization

Wrong:

// Recalculated on every composition
val inlineContent = mapOf(...)
val annotatedString = buildAnnotatedString { ... }

Correct:

val inlineContent = remember(dependencies) { mapOf(...) }
val annotatedString = remember(dependencies) { buildAnnotatedString { ... } }

Use Cases Beyond Benefit Labels

This technique works excellently for:

  1. Status badges in lists
  2. Rating stars within reviews
  3. Currency symbols with special styling
  4. Icon indicators in text
  5. Highlighted keywords in search results

Performance Considerations

The remember usage is crucial for performance:

// Expensive operations cached properly
val benefitLabelBgWidthSp = remember(benefitLabel) { /* text measurement */ }
val inlineContent = remember(benefitLabel, width) { /* composable creation */ }
val annotatedString = remember(name, benefitLabel) { /* string building */ }

This ensures calculations only happen when dependencies change, not on every composition.

Conclusion

InlineContent is a powerful but underutilized feature of Jetpack Compose. When you need to embed custom UI within text flow, it's the superior solution compared to layout-based approaches like Row or FlowRow.

The dynamic width calculation technique we've implemented solves the common problem of perfectly fitting background elements to text content. While the implementation requires understanding text measurement and unit conversion, the result is a professional, performant UI that scales well across different devices and text sizes.

Our experience transitioning from an external library to this native solution proved that sometimes the built-in tools, when used correctly, provide the best balance of performance, maintainability, and design flexibility.

Next time you encounter inline UI challenges, remember: inlineContent is your friend!


Key Takeaways:

  • Use InlineContent for true inline UI elements that need to flow with text
  • Implement dynamic width calculation using TextMeasurer for perfect fitting
  • Optimize with remember for performance, especially text measurements
  • Prefer native solutions over external libraries when built-in APIs suffice
  • Pay attention to unit conversion and density handling

Happy coding!


Facebook

関連記事 | Related Posts

We are hiring!

【UI/UXデザイナー】クリエイティブ室/東京・大阪・福岡

クリエイティブ室についてKINTOやトヨタが抱えている課題やサービスの状況に応じて、色々なプロジェクトが発生しそれにクリエイティブ力で応えるグループです。所属しているメンバーはそれぞれ異なる技術や経験を持っているので、クリエイティブの側面からサービスの改善案を出し、周りを巻き込みながらプロジェクトを進めています。

【プロジェクトマネージャー(iOS/Android/Flutter)】モバイルアプリ開発G/東京

モバイルアプリ開発GについてKINTOテクノロジーズにおける、モバイルアプリ開発のスペシャリストが集まっているグループです。KINTOやmy routeなどのサービスを開発・運用しているグループと協調しながら、品質の高いモバイルアプリを開発し、サービスの発展に貢献することを目標としています。

イベント情報

Appium Meetup Tokyo #3