Unpacking Jetpack Compose


Jetpack Compose has been a buzz in the Android world for several years now. Described as a modern toolkit for building native UI, Compose is Android’s recommended approach for creating the future of Android UI: faster to build, easier to maintain, and more performant.
Sounds promising, right? But having worked with a legacy codebase, I haven’t had the chance to really dive into it. Some of its mechanisms are unclear, and many things just seem like magic.
Last year, I finally had some time to start learning Compose and wrap my head around how it actually works. Better late than never, this post is just me jotting down what I’ve learned so far while figuring out Jetpack Compose.
This article is divided into 3 main parts:
Why Compose?
Explains the drawbacks of the traditional View system and the problems Jetpack Compose aims to solve.
How Compose Works
Focuses on two key performance improvements over the traditional View-based approach:
Single Layout Pass during measuring and layout
Smart Recomposition (doing less, achieving more)
Integrating Compose into an existing project
Considers how to start migrating to Compose and what problems might occur.
What challenges does Compose address?
At Google I’O 2019, and in this podcast (Compose performance - Android Developers Backstage), the Android UI Toolkit and Compose teams shared some of the problems with the existing View system and the motivation behind creating Jetpack Compose.
They have spent 15 years optimizing the View system and have reached the point where the only fixes left to do were architectural.
So what are these problems?
Bundled into the Android Framework
The Android View system is part of the Android Framework, which is bundled into the Android OS. This means that any new features or bug fixes require an OS update.
➜ Jetpack Compose is delivered as a standalone library through Jetpack (AndroidX). You can get the latest updates and performance improvements just by updating the Compose library version.
Everything extends View
Every View that draws on the screen requires memory allocations, explicit state tracking, and various callbacks to support a wide range of use cases. A basic View can end up using a lot of memory, since it's built to handle all possible concerns a view might have, whether you need them or not.
Moreover, the View system uses an inheritance-based model, which makes it difficult to combine different behaviors across view types.
➜ In Compose, UIs are built from small, focused Composables. Each Composable does one thing well and can be easily combined with others. You can build complex UIs by simply putting together tiny building blocks.
Multiple sources of truth
In the traditional Android View system, UI state often exists in multiple places:
The View itself (View owns state and makes its own changes)
The backing data model (in a ViewModel or Repository)
And sometimes in event callbacks
Let’s imagine this scenario:
We have an EditText defined in XML, which manages its own state. The user can type into it and immediately see their input on the screen.
Suppose we want to implement an auto-complete feature, we take the user’s current input and find suggested characters to complete a meaningful word. These suggestions need to be displayed in the same EditText, possibly with a different color.
To achieve this, we’ll need to maintain some state within the ViewModel. Once the suggestion words are ready, we want to notify the UI to update the text.
But what if the user continues typing while the suggestions are still being processed? Two questions arise:
Which text should be displayed?
How do we sync the user's input with the suggestions?
➜ Compose solves this problem by using a single source of truth, where state lives in just one place (often a UiState in the ViewModel).
The data flow in Compose looks like this: State → UI → Events
State is held in one central place
UI reads from this state and automatically recomposes when it changes
User interactions trigger events, which update the state
The updated state causes the UI to re-render with the new data
Limitations of the Imperative UI
Consider another example with a chat app.
If there are no messages, the app renders a blank envelope. If there are some messages, we render some paper in the envelope, and if there are 100 messages, we render the icon as if it were on fire.
In the View system, this is how we’d usually handle it:
fun updateCount(count: Int) {
val state = when {
count == 0 -> EnvelopeState.Empty
count in 1..99 -> EnvelopeState.Normal
else -> EnvelopeState.OnFire
}
when (state) {
EnvelopeState.Empty -> {
badgeView.visibility = View.GONE
fireIcon.visibility = View.GONE
paperView.visibility = View.GONE
}
EnvelopeState.Normal -> {
badgeView.visibility = View.VISIBLE
fireIcon.visibility = View.GONE
paperView.visibility = View.VISIBLE
badgeView.text = count.toString()
}
EnvelopeState.OnFire -> {
badgeView.visibility = View.VISIBLE
fireIcon.visibility = View.VISIBLE
paperView.visibility = View.VISIBLE
badgeView.text = "99+"
}
}
}
In an imperative UI, we tell the system how to update the UI step by step. You write code that explicitly changes the UI state: setting visibility, updating text, or showing/hiding elements based on conditions.
Now imagine in the OnFire state we need to add another UI element. That means we also have to go back to the other states and make sure that the new element is hidden there too. This leads to more boilerplate and a higher risk of bugs.
Preferably, we just want to describe what the UI should look like for a given state, rather than how to update it step by step. This is the basic idea behind the declarative UI approach.
Declarative UI describes what the UI looks like, not how to transition into that state. The framework controls how to get from one state to another.
In Compose, this is the code to render that UI
@Composable
fun BadgedEnvelope(count: Int) {
Envelope(fire = count > 99, paper = count > 0) {
if (count > 0) {
Badge(text = "$count")
}
}
}
Here we say:
If the count is over 99, show fire.
If the count is over 0, show paper,
If the count is over 0, render a count badge.
We have discussed the problems with the traditional View system and how Compose addresses these concerns. Below is a summary of why Compose is a better approach than the View system:
Declarative UI: the UI automatically updates with state changes, ensuring a single source of truth and eliminating manual View updates.
Reusability and Modularity: build UIs from small, testable, composable pieces.
Less Boilerplate: concise Kotlin code replaces verbose XML.
Better Performance: one layout pass, smart recomposition, and no XML inflation or View startup cost.
Cross-Platform: Compose Multiplatform shares core principles, compiler, runtime, and APIs with Jetpack Compose.
How Compose works?
Now that we understand why Compose was created, let’s dive into how it actually works.
When rendering a frame, the Android View system follows three main phases: measure, layout, and draw. Jetpack Compose follows a similar flow but has an important extra phase at the beginning, called composition.
Composition (What to Show)
Composable functions execute and emit a UI tree (Nodes + Modifiers).
Re-runs when inputs change.
Layout (Where to Place)
Measures and positions elements in a single pass.
Solves constraints (size, weight, padding…) without multiple measurements.
Outputs exact coordinates (x/y, width/height) for each component.
Drawing (How to Render)
- Uses Skia under the hood for GPU rendering.
Composition: minimal work, max efficiency
In the composition phase, the Compose runtime executes composable functions and outputs a tree structure that represents your UI.
The UI tree consists of layout nodes that contain all the information needed for the subsequent phases.
During initial composition, Compose tracks which composables are called to build the UI. When the State<T>
value changes, the recomposer
schedules reruns all the composable functions that read that state's value.
Note that the runtime may decide to skip some or all of the composable functions if the inputs haven't changed.
(Same color means the Composable was not recomposed)
Also, the Compose UI might skip the layout and drawing phases if the content remains the same and the size and layout don’t change.
Alright, let’s take a break from the theory and check out what this looks like in practice.
@Composable
fun RecompositionExample1(modifier: Modifier = Modifier) {
var counter by remember {
mutableIntStateOf(0)
}
LogCompositions(msg = "Main Scope")
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
LogCompositions(msg = "Column Scope")
CustomText(text = "Text View A (Read counter) - $counter")
CustomText(text = "Text View B")
Button(
onClick = {
counter++
}
) {
LogCompositions(msg = "Button Scope")
Text(text = "[Button A] Click me to increase count")
}
CustomText(text = "Text View C")
Button(
onClick = {
// do nothing
}
) {
Text(text = "[Button B] Click me will do nothing")
}
}
}
// Full code: https://gist.github.com/nminh18898/049b2d249b0aa82608cc2426532beec3
Can you guess what happens if we click the button to increase the counter? What will be printed in the log? Obviously, Text View A
should get recomposed, since it reads the counter value. Let’s see if anything else gets recomposed.
So the log prints “Main Scope” and “Column Scope”. It kind of makes sense, our Text View A
is inside a Column
, which is located inside the RecompositionExample1
composable, so their scopes are recomposed.
With this in mind, let’s explore another example. We’ll move the Text View that reads the counter inside the Button.
@Composable
private fun RecompositionExample2(modifier: Modifier = Modifier) {
var counter by remember { mutableIntStateOf(0) }
LogCompositions(msg = "Main Scope")
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
LogCompositions(msg = "Column Scope")
CustomText(text = "Text View B")
Button(
onClick = {
counter++
}
) {
LogCompositions(msg = "Button Scope")
CustomText(text = "Button A (Read counter): $counter")
}
CustomText(text = "Text View C")
Button(
onClick = {
// do nothing
}
) {
Text(text = "[Button B] Click me will do nothing")
}
}
}
This time, the log prints 'Button Scope' along with the TextView that reads the state. However, unlike the previous example, 'Main Scope' and 'Column Scope' are not printed, surprisingly…
To understand how Compose optimizes recompositions, we need to look at the scopes of the composable functions in our examples.
Recompositon Scope
Recomposition scope is the smallest block of code that can be recomposed independently. It defines the boundaries of which composables are affected when a state changes.
→ Compose only re-executes composables that depend on the changed state, avoiding unnecessary updates to parent or child composables.
That’s why, in the second example, only the “Button Scope” gets recomposed. Recomposition scopes ensure that only the parts of the UI relying on the changed state are updated, leading to much better performance.
Let’s dive into the final recomposition example and see what else we can uncover.
@Composable
fun RecompositionExample3(modifier: Modifier) {
var fixedBoxSize by remember { mutableStateOf(100.dp) } // State for fixed Box size
var weight1 by remember { mutableFloatStateOf(1f) } // State for first weighted Box
var weight2 by remember { mutableFloatStateOf(1f) } // State for second weighted Box
LogCompositions(msg = "Main Scope")
Row(
modifier = modifier
.fillMaxWidth()
) {
LogCompositions(msg = "Row Scope")
CustomBox(
color = Color.Red,
size = fixedBoxSize,
onClick = {
Log.i("DemoRecomposition", "CustomBox 1 clicked, new size: ${fixedBoxSize + 20.dp}")
fixedBoxSize += 20.dp
}
)
CustomBox(
weight = weight1,
color = Color.Green,
onClick = {
Log.i("DemoRecomposition", "CustomBox 2 clicked, new weight: ${weight1 + 0.5f}")
weight1 += 0.5f
}
)
CustomBox(
weight = weight2,
color = Color.Blue,
onClick = {
Log.i("DemoRecomposition", "CustomBox 3 clicked, new weight: ${weight2 + 0.5f}")
weight2 += 0.5f
}
)
}
}
In this example, we’ve got three Box
lined up in a Row
. One of them has a fixed size, and the other two use weights to fill up the rest of the space. Each box can be clicked to change its size or weight.
Now here’s the question:
If I click on one box, do the others also recompose?
My intuition says the other two should recompose too. After all, changing the size of one box might shift things around, especially since they're all sharing space using the weight
modifier in the same Row
Well, turns out I shouldn’t have trusted my instincts. The logs show that clicking one Box doesn’t trigger recomposition in the others.
The key thing here is that, in Compose, recomposition is distinct from layout and draw phases. Recomposition only occurs when a composable reads a state that has changed.
Let’s break it down:
CustomBox 1 reads
fixedBoxSize
(size =fixedBoxSize
)CustomBox 2 reads
weight1
(weight =weight1
)CustomBox 3 reads
weight2
(weight =weight2
)
When you click on CustomBox 1
, fixedBoxSize
changes. Since only CustomBox 1
reads that value, it's the only one scheduled for recomposition.
CustomBox 2
(which depends on weight1
) and CustomBox 3
(which depends on weight2
) don’t care about fixedBoxSize
, so their composition logic is skipped.
The size changes you see in CustomBox 2
and CustomBox 3
happen during the Layout phase, when the Row redistributes weight.
In summary:
Composition: Only
RecompositionExample3
,Row
, andCustomBox 1
recompose.Layout: All
CustomBox
are remeasured due to changes in theRow
.Draw: All
CustomBox
are redrawn.
Hopefully, these examples above help clarify how smart recomposition works in Compose.
Slot Table & Gap Buffers
Now, let’s take a step back and look at the data structure Compose uses to manage the recomposition process. Compose uses SlotTables and Gap Buffer as core data structures to efficiently manage and update the UI.
A Gap Buffer is an array-based data structure, with a current index (or cursor). The array is larger than the collection of data that it represents, with the unused space referred to as the gap.
Get, Insert, Update, Delete: O(1)
Resize Gap: O(N) but is amortized to O(1)
Move Gap: O(N)
Compose uses this data structure based on the assumption that, on average, UIs don’t change structure very much. When they do, changes typically happen in big chunks, so O(N) gap move is a reasonable trade-off.
Something else worth noting is what the compiler does with @Composable
function. Behind the scenes, the compiler:
Transforms function: rewrites the
@Composable
function into IR (Intermediate Representation) code, adding Composer and other parameters to manage UI composition and enforce Compose rules.Optimizes composition: manages composition and recomposition with skippability and restartability, generating a slot table to track the UI tree and state.
Enables efficient rendering: produces optimized IR code for efficient UI rendering and updates, integrating with the Compose runtime.
Below is an example of a composable function and what the compiler turns it into (simplified version):
Let’s say when this code first executes result is null. Compose Runtime inserts the group with #123 into the slot table and Loading()
runs.
Assume that when the function runs a second time, result is no longer null, so the second branch of the if statement executes.
The call to composer.start creates a group with the key #456. The group in the slot table with key #123 doesn’t match, indicating that the UI structure has changed. Once a mismatch is detected, the Composer updates the slot table to reflect the new structure.
It moves the gap to the current cursor position, extends the gap across the outdated UI structure (the group with key #123 and its associated data), effectively marking that group for removal.
After clearing the outdated structure, the Composer executes the composable function as usual, inserting the new UI elements (the
Header()
andBody()
) into the slot table at the current position.
That’s all for the Composition phase. As you can see, Compose puts a lot of effort into avoiding unnecessary work during recomposition. By tracking what state each composable reads and organizing UI updates with tools like the Slot Table and Composer, it can smartly recompose only the parts of the UI that actually need to change.
Layout Phase: one pass to place them all
In the traditional View system, the framework tries to execute the layout or measure stage in a single pass. But in complex layouts, it might have to go through the same parts multiple times to figure things out. If this happens in deeply nested layouts, it can cause significant performance issues due to that extra work.
For example, a LinearLayout
with weight needs 2 layout passes:
First Pass: Measures all child views using their declared dimensions, ignoring weights. Calculates the remaining space after measuring non-weighted views.
Second Pass: Distributes the remaining space proportionally among weighted children based on their weight ratios.
Unlike the View system, Compose enforces a single layout pass for all layout composables via its API contract, allowing it to handle deep UI trees efficiently.
The layout phase in Compose follows a three-step algorithm to find each layout node's width, height, and x, y coordinates:
Measure children: A node measures its children, if any.
Decide own size: Based on these measurements, a node determines its size.
Place children: Each child node is placed relative to a node's position.
At the end of this phase, each layout node has:
An assigned width and height
An x, y coordinate where it should be drawn
Constraints and Modifiers
There are two important aspects to consider in this phase: modifiers and constraints.
Modifiers allow you to decorate or augment a composable, changing its size, layout, behavior, and appearance.
Constraints help find the right sizes for the nodes during the first two steps of the algorithm
Constraints define the min and max width/height a node can be.
Nodes must choose a size within these bounds during measurement and layout.
In the UI tree, modifiers can be visualized as wrapper nodes around layout nodes.
During the layout phase, the tree traversal algorithm also visits each modifier node. This allows modifiers to adjust the size and position of the nodes they wrap.
Here’s a simple overview of how the measurement process works:
It starts at the root of the UI tree, which measures its children using the same constraints it was given.
If a child is a modifier that doesn’t affect size (like padding or background), the constraints just keep passing down the chain. But if a modifier does affect measurement (like size or fillMaxWidth), the constraints get adjusted before moving on.
Once it reaches a view that doesn’t have any children (called a leaf node), that view picks a size based on the constraints it got and returns it to its parent.
The parent then updates the constraints based on what that child needed and moves on to measure the next child.
After all children are measured, the parent figures out their size and sends that result up to their parent.
Custom View in Compose
Remember the LinearLayout
with weight we talked about earlier?
Let’s take a look at how to build that same layout in Compose. Compose comes with built-in containers like Row
and Column
that work just like LinearLayout
. But for this example, we’ll create a custom Row to help explain how the layout process works.
@Composable
fun CustomRowDemo(paddingValues: PaddingValues) {
CustomRowWithWeight(modifier = Modifier
.padding(paddingValues)
.fillMaxWidth()
.wrapContentHeight()
) {
// Fixed width box
Box(
modifier = Modifier
.size(80.dp)
.background(Color.Red)
)
// Takes 2/3 of remaining space
Box(
modifier = Modifier
.weight(2f)
.height(80.dp)
.background(Color.Blue)
)
// Takes 1/3 of remaining space
Box(
modifier = Modifier
.weight(1f)
.height(80.dp)
.background(Color.Green)
)
}
}
In Compose, each layout container has its own scope that defines what child composables can do. For example, weight is only available in RowScope and ColumnScope, it won't work in BoxScope.
This is similar to the classic View system:
layout_weight
only works inLinearLayout
ConstraintLayout
uses constraints instead…
In this example, since we're creating a custom layout, we also need to define our own custom scope.
interface CustomRowScope {
fun Modifier.weight(weight: Float): Modifier
fun Modifier.customAlign(alignment: String): Modifier
}
private object CustomRowScopeImpl : CustomRowScope {
override fun Modifier.weight(weight: Float): Modifier {
return this.then(object : ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any {
val data = parentData as? CustomRowParentData ?: CustomRowParentData()
return data.copy(weight = weight)
}
})
}
override fun Modifier.customAlign(alignment: String): Modifier {
return this.then(object : ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any {
val data = parentData as? CustomRowParentData ?: CustomRowParentData()
return data.copy(customAlign = alignment)
}
})
}
}
With our custom scope in place, the next step is to build the layout that makes use of it. To create a custom layout in Compose, follow these three main steps:
Use Layout Composable: Define a custom layout by wrapping your content inside the Layout() function, optionally passing a modifier.
Use Measurable and Constraints: Inside the layout block, measure each child using measurable.measure(constraints) to get Placeables, which contain size and placement info.
Call layout(width, height): Compute the final size of your layout, then position each child using place() or placeRelative() inside the layout() block.
Our custom view is as follows:
@Composable
fun CustomRowWithWeight(
modifier: Modifier = Modifier,
content: @Composable CustomRowScope.() -> Unit
) {
Layout(
content = { CustomRowScopeImpl.content() },
modifier = modifier
) { measurables, constraints ->
Log.d("Layout", "CustomRowWithWeight > Constraints: $constraints")
/**
* MEASURE
*/
// Step 1: Separate weighted and non-weighted children, calculate total weight
val weighted = mutableListOf<Measurable>()
val nonWeighted = mutableListOf<Measurable>()
var totalWeight = 0f
measurables.forEach { measurable ->
val weight = measurable.getCustomRowWeight()
if (weight > 0f) {
weighted.add(measurable)
totalWeight += weight
} else {
nonWeighted.add(measurable)
}
}
// Step 2: Measure non-weighted children first
val nonWeightedPlaceables = nonWeighted.map {
it.measure(constraints.copy(
minWidth = 0,
minHeight = 0)
)
}
// Step 3: Calculate remaining space and measure weighted children
val usedWidth = nonWeightedPlaceables.sumOf { it.width }
val remainingWidth = (constraints.maxWidth - usedWidth).coerceAtLeast(0)
// For weighted children
val weightedPlaceables = weighted.map { measurable ->
val weight = measurable.getCustomRowWeight()
val childWidth = (remainingWidth * weight / totalWeight).toInt()
measurable.measure(constraints.copy(
minWidth = childWidth,
maxWidth = childWidth,
minHeight = 0,
))
}
/**
* LAYOUT
*/
val allPlaceables = nonWeightedPlaceables + weightedPlaceables
val totalWidth = allPlaceables.sumOf { it.width }
val maxHeight = allPlaceables.maxOfOrNull { it.height } ?: 0
layout(totalWidth, maxHeight) {
var x = 0
var nonWeightedIndex = 0
var weightedIndex = 0
measurables.forEach { measurable ->
val placeable = if (measurable.getCustomRowWeight() > 0f) {
weightedPlaceables[weightedIndex++]
} else {
nonWeightedPlaceables[nonWeightedIndex++]
}
placeable.placeRelative(x, 0)
x += placeable.width
}
}
}
}
Some notable things about the code:
Inside the Layout() composable, the measurables list represents the children to be measured, and constraints tell the layout how much space is available (passed from the parent).
The first part of the measurement logic separates the children into two groups: those with a weight and those without. It also calculates the total weight, which will later be used to proportionally divide space.
Non-weighted children are measured first using constraints: minWidth = 0, minHeight = 0. maxWidth and maxHeight follow the parent’s constraints, ensuring that the children never exceed the parent’s bounds.
The remaining horizontal space is then calculated by subtracting the width used by non-weighted children from the total available width (constraints.maxWidth).
Each weighted child is given a portion of the remaining space, proportional to its declared weight. They're measured with fixed widths based on that calculation.
Once all children are measured, the layout's final size is calculated by summing the widths of all children and taking the maximum height.
During the layout phase, the children are placed side by side. The code iterates over the original list of measurables and places each one in order.
Here is the full code for this example. The code in the built-in Row
is much more complex, this example only handles the main path, just to illustrate how to build a custom view in Compose.
When building custom layouts, it's important to think carefully about how you pass constraints to your child composables.
Are the children limited by your own maximum width or height?
Are they bound by your minimum constraints, or are they allowed to shrink freely?
Do their sizes depend on other children who need to be measured first?
Does padding reduce their available space? For example, if your layout’s maximum width is 100, and you apply horizontal padding of 10 on each side, is the child’s effective max width now only 80?
Once you've measured the children according to those constraints, the next step is to determine your own size. This should be based on both the children's measured sizes and the constraints passed to you from the parent:
Given the size requirements of your children, what size should your layout be?
Do the constraints you received allow for that size?
Remember one of the rules of Compose is that you should only measure your children once; measuring children twice throws a runtime exception.
java.lang.IllegalStateException: measure() may not be called multiple times on the same Measurable. If you want to get the content size of the Measurable before calculating the final constraints, please use methods like minIntrinsicWidth()/maxIntrinsicWidth() and minIntrinsicHeight()/maxIntrinsicHeight()
at androidx.compose.ui.internal.InlineClassHelperKt.throwIllegalStateException(InlineClassHelper.kt:28)
at androidx.compose.ui.node.MeasurePassDelegate.trackMeasurementByParent(MeasurePassDelegate.kt:1019)
at androidx.compose.ui.node.MeasurePassDelegate.measure-BRTryo0(MeasurePassDelegate.kt:451)
at com.minhhnn18898.democompose.MainActivityKt$CustomRowWithWeight$2.measure-3p2s80s(MainActivity.kt:90)
at androidx.compose.ui.node.InnerNodeCoordinator.measure-BRTryo0(InnerNodeCoordinator.kt:128)
at androidx.compose.foundation.layout.WrapContentNode.measure-3p2s80s(Size.kt:1029)
at androidx.compose.ui.node.LayoutModifierNodeCoordinator.measure-BRTryo0(LayoutModifierNodeCoordinator.kt:190)
at androidx.compose.foundation.layout.FillNode.measure-3p2s80s(Size.kt:721)
If you check the exception log, it tells us that if we want to get the content size of a Measurable before calculating the final constraints, we should use methods like minIntrinsicWidth(), maxIntrinsicWidth(), minIntrinsicHeight(), and maxIntrinsicHeight().
Let’s find out what this means.
Intrinsic size
Take a look at the example below. Suppose we want to create a UI that consists of multiple Rows inside a Column
, and each Row
should have the same width, matching the width of the longest Row
.
Normally, we need two layout passes to measure and layout this. The first layout pass measures and finds the longest child, and the second layout pass uses that result as a constraint to re-measure all the children.
However, that violates Compose's rules. Can we find a way to handle this problem?
Well, intrinsic size comes to the rescue.
Intrinsics lets you query children before they're actually measured.
Intrinsic sizes are defined as:
Minimum: the smallest size at which the content can be drawn properly.
Maximum: the largest size beyond which there is no visual change.
For example, with the text like "Very long text for intrinsics":
Min intrinsic width: Matches the longest word ("intrinsics"), representing the narrowest possible readable layout (one word per line).
Max intrinsic width: Matches the full single-line width. Any extra space won't change the text appearance
Appendix: How some Composable calculated their intrinsic size
Text
The getDesiredWidthWithLimit method calculates the width required to display a text slice with one line per paragraph.
Maximum Intrinsic Width: Measures the width of the text with each paragraph as a single line (no wrapping).
Minimum Intrinsic Width: Measures the width of the longest single word (text wraps at word boundaries).
/** * Return how wide a layout must be in order to display the * specified text slice with one line per paragraph. * * If the measured width exceeds given limit, returns limit value instead. * @hide */ public static float getDesiredWidthWithLimit( CharSequence source, int start, int end, TextPaint paint, TextDirectionHeuristic textDir, float upperLimit, boolean useBoundsForWidth ) { float need = 0; int next; for (int i = start; i <= end; i = next) { next = TextUtils.indexOf(source, '\n', i, end); if (next < 0) next = end; // Note: omits trailing paragraph char float w = measurePara(paint, source, i, next, textDir, useBoundsForWidth); if (w > upperLimit) { return upperLimit; } if (w > need) need = w; next++; } return need; }
Row & Column
These two functions, intrinsicMainAxisSize and intrinsicCrossAxisSize, handle intrinsic measurements, which determine the natural size a layout would take based on its children's intrinsic sizes, weights, and spacing, without forcing specific constraints.
private inline fun intrinsicMainAxisSize(
children: List<IntrinsicMeasurable>,
mainAxisSize: IntrinsicMeasurable.(Int) -> Int,
crossAxisAvailable: Int,
mainAxisSpacing: Int
): Int {
if (children.isEmpty()) return 0
var weightUnitSpace = 0
var fixedSpace = 0
var totalWeight = 0f
children.fastForEach { child ->
val weight = child.rowColumnParentData.weight
val size = child.mainAxisSize(crossAxisAvailable)
if (weight == 0f) {
fixedSpace += size
} else if (weight > 0f) {
totalWeight += weight
weightUnitSpace = max(weightUnitSpace, (size / weight).fastRoundToInt())
}
}
return (weightUnitSpace * totalWeight).fastRoundToInt() +
fixedSpace +
(children.size - 1) * mainAxisSpacing
}
private inline fun intrinsicCrossAxisSize(
children: List<IntrinsicMeasurable>,
mainAxisSize: IntrinsicMeasurable.(Int) -> Int,
crossAxisSize: IntrinsicMeasurable.(Int) -> Int,
mainAxisAvailable: Int,
mainAxisSpacing: Int
): Int {
if (children.isEmpty()) return 0
var fixedSpace = min((children.size - 1) * mainAxisSpacing, mainAxisAvailable)
var crossAxisMax = 0
var totalWeight = 0f
children.fastForEach { child ->
val weight = child.rowColumnParentData.weight
if (weight == 0f) {
// Ask the child how much main axis space it wants to occupy. This cannot be more
// than the remaining available space.
val remaining =
if (mainAxisAvailable == Constraints.Infinity) Constraints.Infinity
else mainAxisAvailable - fixedSpace
val mainAxisSpace = min(child.mainAxisSize(Constraints.Infinity), remaining)
fixedSpace += mainAxisSpace
// Now that the assigned main axis space is known, ask about the cross axis space.
crossAxisMax = max(crossAxisMax, child.crossAxisSize(mainAxisSpace))
} else if (weight > 0f) {
totalWeight += weight
}
}
// For weighted children, calculate how much main axis space weight=1 would represent.
val weightUnitSpace =
if (totalWeight == 0f) {
0
} else if (mainAxisAvailable == Constraints.Infinity) {
Constraints.Infinity
} else {
(max(mainAxisAvailable - fixedSpace, 0) / totalWeight).fastRoundToInt()
}
children.fastForEach { child ->
val weight = child.rowColumnParentData.weight
// Now the main axis for weighted children is known, so ask about the cross axis space.
if (weight > 0f) {
crossAxisMax =
max(
crossAxisMax,
child.crossAxisSize(
if (weightUnitSpace != Constraints.Infinity) {
(weightUnitSpace * weight).fastRoundToInt()
} else {
Constraints.Infinity
}
)
)
}
}
return crossAxisMax
}
Key Notes:
IntrinsicMeasurable: Represents a measurable child component in Compose’s layout system, providing methods to query intrinsic sizes (e.g., minIntrinsicWidth, maxIntrinsicHeight).
Main Axis vs. Cross Axis:
For a Row, the main axis is horizontal (width), and the cross axis is vertical (height).
For a Column, the main axis is vertical (height), and the cross axis is horizontal (width).
Weights: Children with weight > 0f share available space proportionally, while non-weighted children (weight == 0f) take their intrinsic size.
Constraints.Infinity: Represents an unbounded dimension, often used during intrinsic measurements to query a child’s natural size without constraints.
Double Pass in Cross Axis: The intrinsicCrossAxisSize function uses two passes: the first to measure non-weighted children’s main-axis sizes and collect weights, and the second to measure weighted children’s cross-axis sizes based on the computed main-axis space allocation.
Now this begs a question: does calculating intrinsic size violate the “measure children once” rule?
And the answer is: Not really, but kind of…
Intrinsic measurements do a different kind of calculation, which is cheaper and easier, but it still bends the rule a little bit.
Think of intrinsic measurements as a kind of pre-measure step, which lets you query children before they're measured:
Lightweight queries: Intrinsics provide just the minimum or maximum size needed, without fully measuring or laying out the content.
Efficient reuse: These intrinsic results are cached and later reused during the real layout pass, so they don’t lead to repeated expensive computations.
“Asking for intrinsics measurements doesn't measure the children twice. Children are queried for their intrinsic measurements before they're measured and then, based on that information the parent calculates the constraints to measure its children with.”
Alright, What Else Is There?
Now that we’ve answered the two main questions from earlier, let’s move on and explore some other aspects of Compose.
Drawing phase
After the composition builds the UI tree and the layout phase measures and positions every Composable, the drawing phase is responsible for actually rendering pixels on the screen. This is where the visual representation of the UI is drawn.
Compose employs the View Canvas and RenderNode - an abstraction over the native Android rendering system (Skia). This allows for seamless integration of Compose with the Android View system, since they share the same rendering surface and engine under the hood.
Compose provides a declarative way to draw custom graphics using the Canvas API (similar to the traditional Android Canvas). Drawing is done inside a Modifier.drawBehind {} or the Canvas composable.
At the core of Compose’s drawing system is DrawScope, which provides everything needed for custom drawing:
The size of the drawing area (as Size), with helpers like center, topLeft, bottomRight…
Access to the density and layout direction
Convenient drawing utilities like drawRect(), drawCircle(), drawLine(), and more - plus the low-level API drawIntoCanvas() for interoperability with android.graphics.Canvas
Built-in support for alpha, blend modes, stroke vs. fill, colors, and more
Optimize state reads
Take a look at this example, where we animate the background color of a Box()
@Composable
fun DemoAnimateBackground(modifier: Modifier = Modifier) {
val infiniteTransition = rememberInfiniteTransition()
val animatedColor by infiniteTransition.animateColor(
initialValue = Color.Red,
targetValue = Color.Blue,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 1000,
easing = LinearEasing
),
repeatMode = RepeatMode.Reverse
)
)
Box(
modifier = modifier
.height(300.dp)
.fillMaxWidth()
.background(animatedColor)
) {
Text(
text = "Smooth Infinite Animation",
color = Color.White,
fontSize = 24.sp,
modifier = Modifier.align(Alignment.Center)
)
}
}
If you open Layout Inspector, you can see that this code is recomposed on every frame. In this code, the color changes on every frame for animation, so the whole Composable is also recomposed on every frame.
There is an optimization that can be done here. Since we only need to change the color, we can just redraw the box with a different color, skipping recomposition and the layout phase altogether.
Deferring the reading of state until it is required is an important concept in Compose. Because Compose performs localized state read tracking, we can minimize the amount of work performed by reading each state in the appropriate phase.
Back to the example, we can use the drawBehind modifier to draw the background so that only the draw phase is re-executed when the color changes.
Box(
modifier = modifier
.height(300.dp)
.fillMaxWidth()
.drawBehind {
drawRect(animatedColor)
}
) {
Text(
text = "Smooth Infinite Animation",
color = Color.White,
fontSize = 24.sp,
modifier = Modifier.align(Alignment.Center)
)
}
Animation in Compose
Compose provides two levels of animation APIs to give us both simplicity and fine-grained control, depending on the use case.
High-Level APIs
These APIs are easy to use and cover common animation scenarios, such as animating visibility, transitions, and state changes…
Common functions:
animate*AsState(): Animates a value change (e.g., color, offset, size).
AnimatedVisibility(): Animates visibility changes (enter/exit transitions).
updateTransition(): Groups multiple animations for the same state.
Crossfade(): Fades between two composable states.
AnimatedContent(): Animates content replacement with transitions.
Low-Level APIs
These APIs give more control over animation, suitable for advanced use cases like interrupting animations, synchronizing multiple animations, or responding to gestures…
Take this animation for example (the code is here):
Users can drag circular image boxes (chibi characters). On release, the box springs back to its origin. If it collides with another during the return, the other box is pushed away and returns with its animation. The dragged box spins 360°, unless a collision occurs, then it snaps back to 0° instead.
We use low-level animation APIs (Animatable) because they help:
Handle complex interactions (drag, release, collision detection mid-animation)
Provide manual control (snapTo, animateTo, conditionally starting/stopping)
Cancel ongoing animations (by using snapTo) when touch events begin, since user interaction should take the highest priority.
Unbound List (Lazy List)
Let’s say we need to display a lot of items, but they cannot fit on the screen at the same time. Of course, we don’t want to load everything at once, we want to render only the visible items and fetch more as the user scrolls. In the View system, this can be achieved by using RecyclerView.
In Compose, we can do the same with LazyList, which, under the hood, performs the following steps:
Measures list items to obtain their sizes.
Calculates how many items fit within the available viewport.
Composes only the items that will be visible.
Each LazyList item has a unique key and content type. Compose reuses composable instances (layout node, state, and associated metadata) for new items if the key and type match when scrolling.
LazyList is built on top of SubComposeLayout. SubComposeLayout is used for cases where the size is needed to decide what to compose: measuring and composition cannot be separate passes.
For example, lazy containers build content on demand by creating and measuring items one at a time until the available space is filled.
Compose provides several components that use this approach to a different extent:
And all the lazy layouts (like LazyColumn, LazyVerticalGrid, LazyLayout...)
But these come with some impact on performance. Since it waits to compose until it knows the size, it can’t determine its intrinsic size. If you need a component with a dynamic UI and intrinsic size, a custom layout might be a good fit. It can create all nodes and place only the ones it needs.
It can also affect performance because it recomposes every time it measures. Lazy lists do some smart caching to help with that, but it still adds complexity. So, it’s best to use SubcomposeLayout only when you really need it.
Integrate Compose into the existing project
Going full Compose sounds ideal, but the truth is most apps will be a mix of Views and Compose for quite a while.
One of the most common migration strategies is as follows:
Build new screens with Compose.
As you’re building features, identify reusable elements and start creating a library of common UI components.
Replace existing features one screen at a time.
These are some problems I’ve hit while migrating to Compose. I’ll add more as I discover new ways to suffer.
Deciding what to keep and what to replace
When migrating a whole screen from XML, there might be some components that are better kept as traditional Views, depending on your app’s requirements.
For example, our app has a custom action bar with logic to handle insets. This action bar is reused across different screens, so for now, it's better to keep it as a View for consistency.
Compose makes it easy to drop into an existing View-based screen, especially if the new feature you’re adding is just a part of it. You can simply add a ComposeView to the View hierarchy like any other View.
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:background="?attr/social_feed_feedfamily_background_blue">
<ZdsActionBar
style="@style/WhiteZdsActionBar.AppType"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/zds_action_bar"
app:leadingType="back"
app:middleType="title"
app:trailingType="icon"/>
<androidx.compose.ui.platform.ComposeView
android:id="@+id/compose_view"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</LinearLayout>
Also, just because you used a component in Views doesn’t mean you need an exact match in Compose. It’s a good chance to simplify, redesign, or drop components that aren’t really needed anymore. Always aim to build small, self-contained, and reusable UI pieces so the migration process gets faster as you go.
Interoperability with custom image loader
Many libraries help load and display images in Compose, such as Coil or Glide. But what if your app uses a custom image loader? How do you handle that?
Let’s say we have a custom loader like this:
class ImageLoader {
fun loadPhoto(
url: String,
targetWidth: Int,
callback: ImageCallback
) {
val result = internalApi.loadPhoto(url, targetWidth)
if(result != null) {
callback.onSuccess(result.bitmap)
}
else {
callback.onError(result.error)
}
}
interface ImageCallback {
fun onSuccess(bitmap: Bitmap)
fun onError(errorCode: Int)
}
}
The next step is to create an image view that uses the loader to show the photo, display a loading icon, or an error if needed.
sealed class ImageLoadingState {
data object Loading : ImageLoadingState()
data class Success(val imageBitmap: ImageBitmap) : ImageLoadingState()
data class Error(val errorCode: Int) : ImageLoadingState()
}
@Composable
fun AsyncImage(
url: String,
aqueryLoader: ImageLoader,
targetWidth: Int,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Crop
) {
// State to track loading, success, or error
var state by remember { mutableStateOf<ImageLoadingState>(ImageLoadingState.Loading) }
// Key to trigger reload on retry
var retryKey by remember { mutableStateOf(0) }
// Manage image loading lifecycle
DisposableEffect(url, aqueryLoader, retryKey) {
val callback = object : ImageLoader.ImageCallback {
override fun onSuccess(bitmap: Bitmap) {
state = ImageLoadingState.Success(bitmap.asImageBitmap())
}
override fun onError(errorCode: Int) {
state = ImageLoadingState.Error(errorCode)
}
}
// Trigger image loading
aqueryLoader.loadPhoto(url, targetWidth, callback)
onDispose {
// Add cancellation logic here if necessary
}
}
// Render UI based on state
Box(modifier = modifier, contentAlignment = Alignment.Center) {
val itemBackgroundColor = Color(ThemeUtils.themeAttributeToColor(LocalContext.current, R.attr.progress_blue_background))
when (state) {
is ImageLoadingState.Loading -> {
Box(modifier = Modifier
.fillMaxSize()
.background(itemBackgroundColor),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(32.dp),
color = Color(ThemeUtils.themeAttributeToColor(
LocalContext.current,
R.attr.progress_blue_indicator)
)
)
}
}
is ImageLoadingState.Success -> {
val imageBitmap = (state as ImageLoadingState.Success).imageBitmap
Image(
bitmap = imageBitmap,
contentDescription = null,
contentScale = contentScale,
modifier = Modifier.fillMaxSize()
)
}
is ImageLoadingState.Error -> {
Box(
modifier = Modifier
.fillMaxSize()
.background(itemBackgroundColor),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(R.drawable.zds_ic_retry_line_24),
contentDescription = null,
tint = Color(
ThemeUtils.themeAttributeToColor(
LocalContext.current,
R.attr.accent_neutral_gray_icon
)
),
modifier = Modifier
.size(32.dp)
.clickable {
state = ImageLoadingState.Loading
retryKey += 1
}
)
}
}
}
}
}
In the code above:
ImageLoadingState represents three states of an async image: Loading, Success, and Error. The UI updates reactively depending on these states.
DisposableEffect is used to manage the image loading lifecycle, calling the load function with a callback for success or error. Cleanup can be added in onDispose.
Retry Mechanism: A retryKey triggers reloading when the user clicks the retry icon (incrementing retryKey refreshes the DisposableEffect).
Interoperability with APIs that require Views
Let’s say we’ve migrated a screen to Compose, but now need to use a legacy API that still requires a View instance.
For example, in our app, we have a showTip(view: View)
function that displays a tooltip pointing to a specific View. In this case, we want to show a tooltip for a particular Composable.
@Composable
fun PrivacyInfoView(modifier: Modifier = Modifier) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(R.drawable.zds_ic_lock_solid_16),
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = Color(ThemeUtils.themeAttributeToColor(LocalContext.current, R.attr.icon_tertiary))
)
Text(
text = stringResource(R.string.fsfg_snapshot_privacy_prompt),
fontSize = 12.sp,
lineHeight = 16.sp,
fontFamily = RobotoFontFamily,
color = Color(ThemeUtils.themeAttributeToColor(LocalContext.current, R.attr.text_tertiary)),
maxLines = 1,
modifier = Modifier.padding(horizontal = 4.dp)
)
}
}
Getting the actual View backing a specific Composable is tricky because Composables don’t have a direct View equivalent. Compose renders UI elements directly to the screen using its own rendering engine, not traditional View objects.
There’s still a way to make it happen, though. But first, let’s talk about two components used to mix Compose and Android Views:
ComposeView (an Android View component):
A traditional View that acts as a container for Jetpack Compose UI.
Let’s you embed Compose content inside XML layouts or existing View-based screens by calling
setContent { }
with composable functions.
AndroidView (a Jetpack Compose component):
A Composable that enables traditional Android Views inside Compose UI hierarchies.
Bridges legacy UI components into Compose by inflating them within a Composable lambda.
TL;DR:
ComposeView = Host for Compose UI inside the old View system
AndroidView = Host for old Views inside Compose UI
Back to our example, we can use a mix of ComposeView and AndroidView to grab a View that's tied to a specific Composable.
@Composable
fun PrivacyInfoViewCompatible(
onViewReady: (View) -> Unit // Callback to pass View reference to parent
) {
AndroidView(
factory = { ctx ->
ComposeView(ctx).apply {
setContent {
PrivacyInfoView()
}
// Pass the ComposeView to the parent via callback
onViewReady(this)
}
}
)
}
Here’s the result:
Need More Info? Here’s Where to Look
Official docs, deep dives, and a few links I shamelessly stole to make this blog sound more legit. Use this list to dive deeper into Jetpack Compose with confidence.
🧱 Foundation
Understanding Jetpack Compose by Leland Richardson
Declarative UI patterns (Google I/O'19)
Compose runtime and performance - Android Developers Backstage
🔁 Recomposition and Layout
What is “donut-hole skipping” in Jetpack Compose?
Exercises in futility: Jetpack Compose Recomposition
How Jetpack Compose Measuring Works
Advanced layout concepts - MAD Skills
🎞️ Animation
[Jetpack Compose] How to animate and improve performance for a smoother animation
🔌 Intergration
Migrating to Jetpack Compose - an interop love story, by Simona Milanović
Jetpack Compose Migration: Best Practices and Strategies
🚀 Performance
Subscribe to my newsletter
Read articles from Huynh N N Minh directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Huynh N N Minh
Huynh N N Minh
Android Software Developer Email: huynhnguyennhatminh98@gmail.com