SubComposeLayout and BoxWithConstraints internals in Jetpack Compose
In this article, you'll dive deep into SubcomposeLayout, the internal mechanisms that power it, how BoxWithConstraints leverages it.

Jetpack Compose has revolutionized Android UI development with its declarative paradigm, where UI is a function of state. At its core, Compose follows a well-defined execution flow: Composition → Measurement → Layout. During composition, you declare what your UI should look like. During measurement, the system determines the size of each component. Finally, during layout, components are positioned on the screen.
But what if you need to break this flow? What if you need to know the measurement constraints before deciding what to compose? This is where SubcomposeLayout
comes in, a versatile primitive that allows you to subcompose content during the measure pass, enabling you to use measurement information to drive composition decisions.
In this article, you’ll dive deep into SubcomposeLayout
, exploring how it works under the hood, the internal mechanisms that power it, how BoxWithConstraints
leverages it to provide constraint-aware composition, and exploring real-world use cases including RevenueCat’s Android SDK and Compose Material3 library.
Understanding the core problem: Why SubcomposeLayout exists
In a standard Compose layout, you compose your content first, then measure it. This works perfectly for most cases, but some scenarios require you to know the constraints before deciding what to compose:
Scenario 1: Adaptive Layouts You want to show a different UI structure based on available width, a wide layout for tablets and a narrow layout for phones. You can’t make this decision until you know the constraints.
Scenario 2: Lazy Lists LazyColumn needs to compose only the visible items. It can’t know which items are visible until it measures the available space.
Scenario 3: Constraint-Based Composition You need to compose different children based on the exact constraints passed by the parent, or you need to measure one child and use its size to determine what other children to compose.
The traditional Compose flow can’t solve these problems because composition happens before measurement. SubcomposeLayout
inverts this relationship, allowing you to compose during measurement.
The SubcomposeLayout API surface
If you look into the API of SubcomposeLayout
, you’ll see it’s designed as a direct analogue to the standard Layout composable, but with a important difference in its measure policy:
1@Composable
2fun SubcomposeLayout(
3 modifier: Modifier = Modifier,
4 measurePolicy: SubcomposeMeasureScope.(Constraints) -> MeasureResult,
5)
The measure policy receives a SubcomposeMeasureScope
, which extends MeasureScope
with one important additional capability:
1interface SubcomposeMeasureScope : MeasureScope {
2 fun subcompose(slotId: Any?, content: @Composable () -> Unit): List<Measurable>
3}
This single function is the gateway to dynamic composition. When you call subcompose()
during measurement, it composes the given content immediately and returns a list of Measurable
children that you can then measure and lay out. The slotId
parameter is used to identify and track different composition slots, enabling recomposition, reuse, and proper state management.
Internal mechanisms of SubcomposeLayout
The real magic happens inside LayoutNodeSubcompositionsState
, a complex state management system that orchestrates the entire subcomposition process. This class is stored directly on the LayoutNode
, maintaining a 1-1 mapping between the state and the node.
The three-tier node organization
If you explore the internal implementation, you’ll discover that SubcomposeLayout
organizes its child nodes into three distinct regions within the root.foldedChildren
list:
1[Active nodes] [Reusable nodes] [Precomposed nodes]
Active nodes: These are the slots currently being used in the latest measure pass. They’re tracked in the slotIdToNode
map for quick lookup by slot ID.
Reusable nodes: These are nodes that were previously active but are no longer being used. Instead of disposing them immediately (which would destroy their composition state), SubcomposeLayout
keeps them in a reusable pool. The count is tracked via reusableCount
.
Precomposed nodes: These are nodes that have been composed ahead of time (via the precompose()
function) but haven’t been used in a measure pass yet. They’re tracked in the precomposeMap
and counted via precomposedCount
.
This three-tier organization is the foundation of SubcomposeLayout
‘s performance optimization strategy.
The subcompose execution flow
When you call subcompose(slotId, content)
during measurement, the internal machinery follows a sophisticated lookup and allocation strategy. Let’s trace through the code in LayoutNodeSubcompositionsState.subcompose()
:
1fun subcompose(slotId: Any?, content: @Composable () -> Unit): List<Measurable> {
2 makeSureStateIsConsistent()
3 val layoutState = root.layoutState
4 checkPrecondition(
5 layoutState == LayoutState.Measuring ||
6 layoutState == LayoutState.LayingOut ||
7 layoutState == LayoutState.LookaheadMeasuring ||
8 layoutState == LayoutState.LookaheadLayingOut
9 ) {
10 "subcompose can only be used inside the measure or layout blocks"
11 }
12
13 val node =
14 slotIdToNode.getOrPut(slotId) {
15 val precomposed = precomposeMap.remove(slotId)
16 if (precomposed != null) {
17 precomposedCount--
18 precomposed
19 } else {
20 takeNodeFromReusables(slotId) ?: createNodeAt(currentIndex)
21 }
22 }
23
24// Position the node correctly in the children listif (root.foldedChildren.getOrNull(currentIndex) !== node) {
25 val itemIndex = root.foldedChildren.indexOf(node)
26 requirePrecondition(itemIndex >= currentIndex) {
27 "Key \"$slotId\" was already used. If you are using LazyColumn/Row please make " +
28 "sure you provide a unique key for each item."
29 }
30 if (currentIndex != itemIndex) {
31 move(itemIndex, currentIndex)
32 }
33 }
34 currentIndex++
35
36 subcompose(node, slotId, pausable = false, content)
37
38 return if (layoutState == LayoutState.Measuring || layoutState == LayoutState.LayingOut) {
39 node.childMeasurables
40 } else {
41 node.childLookaheadMeasurables
42 }
43}
44
The execution follows this precise sequence:
Step 1: State validation – The function first ensures the internal state is consistent and verifies that we’re actually in a measure or layout pass. You cannot call subcompose()
during composition, it’s strictly a measurement-time operation.
Step 2: Node lookup and allocation – The system checks slotIdToNode
to see if this slot ID already has an active node. If not, it follows a fallback chain:
- Check if the slot was precomposed (
precomposeMap
) - Try to reuse a compatible node from the reusable pool (
takeNodeFromReusables()
) - Create a brand new LayoutNode (
createNodeAt()
)
Step 3: Position management – Once we have a node, we need to ensure it’s at the correct position in the foldedChildren
list. The currentIndex
tracks where we are in the active region. If the node is found at a different index, it’s moved to the correct position. This maintains the invariant that active nodes are always at the front of the list, in the order they were subcomposed.
Step 4: Composition – The subcompose(node, slotId, pausable, content)
internal function handles the actual composition into the node. It manages the ReusableComposition lifecycle, handles content changes, and applies paused compositions if applicable.
Step 5: Return measurables – Finally, the function returns the appropriate measurables based on whether we’re in a lookahead pass or a regular measure pass.
The slot reuse optimization
One of the most sophisticated aspects of SubcomposeLayout
is its slot reuse mechanism. When a slot is no longer needed, instead of immediately disposing it, the system can keep it in the reusable pool. This is managed by disposeOrReuseStartingFromIndex()
:
1fun disposeOrReuseStartingFromIndex(startIndex: Int) {
2 reusableCount = 0
3 val foldedChildren = root.foldedChildren
4 val lastReusableIndex = foldedChildren.size - precomposedCount - 1
5 var needApplyNotification = false
6 if (startIndex <= lastReusableIndex) {
7// construct the set of available slot ids
8 reusableSlotIdsSet.clear()
9 for (i in startIndex..lastReusableIndex) {
10 val slotId = getSlotIdAtIndex(foldedChildren, i)
11 reusableSlotIdsSet.add(slotId)
12 }
13
14 slotReusePolicy.getSlotsToRetain(reusableSlotIdsSet)
15// iterating backwards so it is easier to remove itemsvar i = lastReusableIndex
16 Snapshot.withoutReadObservation {
17 while (i >= startIndex) {
18 val node = foldedChildren[i]
19 val nodeState = nodeToNodeState[node]!!
20 val slotId = nodeState.slotId
21 if (slotId in reusableSlotIdsSet) {
22 reusableCount++
23 if (nodeState.active) {
24 node.resetLayoutState()
25 nodeState.reuseComposition(forceDeactivate = false)
26
27 if (nodeState.composedWithReusableContentHost) {
28 needApplyNotification = true
29 }
30 }
31 } else {
32 ignoreRemeasureRequests {
33 nodeToNodeState.remove(node)
34 nodeState.composition?.dispose()
35 root.removeAt(i, 1)
36 }
37 }
38// remove it from slotIdToNode so it is not considered active
39 slotIdToNode.remove(slotId)
40 i--
41 }
42 }
43 }
44
45 if (needApplyNotification) {
46 Snapshot.sendApplyNotifications()
47 }
48
49 makeSureStateIsConsistent()
50}
This function is called after measurement completes. Any nodes from startIndex
onwards are no longer active. The system:
- Collects all the slot IDs that could potentially be reused
- Calls the
slotReusePolicy.getSlotsToRetain()
to let the policy decide which slots to keep - For kept slots, marks them as reusable and deactivates their composition
- For discarded slots, disposes the composition and removes the node entirely
The deactivation is handled by NodeState.reuseComposition()
:
1private fun NodeState.reuseComposition(forceDeactivate: Boolean) {
2 if (!forceDeactivate && composedWithReusableContentHost) {
3// Deactivation through ReusableContentHost is controlled with the active flag
4 active = false
5 } else {
6// Otherwise, create a new instance to avoid state change notifications
7 activeState = mutableStateOf(false)
8 }
9
10 if (pausedComposition != null) {
11// Cancelling disposes composition, so no additional work is needed.
12 cancelPausedPrecomposition()
13 } else if (forceDeactivate) {
14 record(SLOperation.ReuseForceSyncDeactivation)
15 composition?.deactivate()
16 } else {
17 val outOfFrameExecutor = outOfFrameExecutor
18 if (outOfFrameExecutor != null) {
19 record(SLOperation.ReuseScheduleOutOfFrameDeactivation)
20 deactivateOutOfFrame(outOfFrameExecutor)
21 } else {
22 if (!composedWithReusableContentHost) {
23 record(SLOperation.ReuseSyncDeactivation)
24 composition?.deactivate()
25 } else {
26 record(SLOperation.ReuseDeactivationViaHost)
27 }
28 }
29 }
30}
The deactivation strategy depends on whether the composition used ReusableContentHost
. If it did, changing the active
flag to false will trigger ReusableContentHost
to deactivate its content. Otherwise, the composition is directly deactivated either synchronously or scheduled out-of-frame via the OutOfFrameExecutor
.
When a reusable slot is needed again, takeNodeFromReusables()
implements a smart matching algorithm:
1private fun takeNodeFromReusables(slotId: Any?): LayoutNode? {
2 if (reusableCount == 0) {
3 return null
4 }
5 val foldedChildren = root.foldedChildren
6 val reusableNodesSectionEnd = foldedChildren.size - precomposedCount
7 val reusableNodesSectionStart = reusableNodesSectionEnd - reusableCount
8 var index = reusableNodesSectionEnd - 1
9 var chosenIndex = -1
10// first try to find a node with exactly the same slotIdwhile (index >= reusableNodesSectionStart) {
11 if (getSlotIdAtIndex(foldedChildren, index) == slotId) {
12// we have a node with the same slotId
13 chosenIndex = index
14 break
15 } else {
16 index--
17 }
18 }
19 if (chosenIndex == -1) {
20// try to find a first compatible slotId from the end of the section
21 index = reusableNodesSectionEnd - 1
22 while (index >= reusableNodesSectionStart) {
23 val node = foldedChildren[index]
24 val nodeState = nodeToNodeState[node]!!
25 if (
26 nodeState.slotId === ReusedSlotId ||
27 slotReusePolicy.areCompatible(slotId, nodeState.slotId)
28 ) {
29 nodeState.slotId = slotId
30 chosenIndex = index
31 break
32 }
33 index--
34 }
35 }
36 return if (chosenIndex == -1) {
37// no compatible nodes foundnull
38 } else {
39 if (index != reusableNodesSectionStart) {
40// we need to rearrange the items
41 move(index, reusableNodesSectionStart, 1)
42 }
43 reusableCount--
44 val node = foldedChildren[reusableNodesSectionStart]
45 val nodeState = nodeToNodeState[node]!!
46// create a new instance to avoid change notifications
47 nodeState.record(SLOperation.Reused)
48 nodeState.activeState = mutableStateOf(true)
49 nodeState.forceReuse = true
50 nodeState.forceRecompose = true
51 node
52 }
53}
54
The matching algorithm has two phases:
- Exact match: First, it tries to find a reusable node with the exact same slot ID. This is ideal because the content is likely very similar.
- Compatible match: If no exact match is found, it falls back to finding a compatible slot using the
slotReusePolicy.areCompatible()
method.
When a reusable node is taken, it’s marked with forceReuse = true
and forceRecompose = true
. The forceReuse
flag tells the composition system to use setContentWithReuse()
instead of the regular setContent()
, which allows the Compose runtime to maximize reuse of composition state. The forceRecompose
flag ensures the content is recomposed even if the composition already exists.
The measure policy implementation
The actual measure policy is created by createMeasurePolicy()
, which returns a specialized object that handles both regular and lookahead measure passes:
1fun createMeasurePolicy(
2 block: SubcomposeMeasureScope.(Constraints) -> MeasureResult
3): MeasurePolicy {
4 return object : LayoutNode.NoIntrinsicsMeasurePolicy(error = NoIntrinsicsMessage) {
5 override fun MeasureScope.measure(
6 measurables: List<Measurable>,
7 constraints: Constraints,
8 ): MeasureResult {
9 scope.layoutDirection = layoutDirection
10 scope.density = density
11 scope.fontScale = fontScale
12 if (!isLookingAhead && root.lookaheadRoot != null) {
13// Approach pass
14 currentApproachIndex = 0
15 val result = approachMeasureScope.block(constraints)
16 val indexAfterMeasure = currentApproachIndex
17 return createMeasureResult(result) {
18 currentApproachIndex = indexAfterMeasure
19 result.placeChildren()
20// dispose
21 disposeUnusedSlotsInApproach()
22 disposeOrReuseStartingFromIndex(currentIndex)
23 }
24 } else {
25// Lookahead pass, or the main pass if not in a lookahead scope.
26 currentIndex = 0
27 val result = scope.block(constraints)
28 val indexAfterMeasure = currentIndex
29 return createMeasureResult(result) {
30 currentIndex = indexAfterMeasure
31 result.placeChildren()
32 if (root.lookaheadRoot == null) {
33// If this is in lookahead scope, we need to dispose *after*// approach placement, to give approach pass the opportunity to// transfer the ownership of subcompositions before disposing.
34 disposeOrReuseStartingFromIndex(currentIndex)
35 }
36 }
37 }
38 }
39 }
40}
This implementation handles three distinct measure scenarios:
- Regular measure pass (when not in a lookahead scope): Resets
currentIndex
to 0, calls the user’s measure block, then disposes or reuses any unused slots after placement. - Lookahead pass (when
isLookingAhead == true
): Similar to regular measure, but defers disposal until after the approach pass completes. This gives the approach pass an opportunity to take ownership of subcompositions. - Approach pass (when
!isLookingAhead && root.lookaheadRoot != null
): Uses a separatecurrentApproachIndex
andapproachMeasureScope
. This pass can compose items that weren’t composed in lookahead, and it manages its own set of precomposed slots tracked inapproachPrecomposeSlotHandleMap
.
The reason for wrapping the result in createMeasureResult()
is to ensure that disposal happens during placeChildren()
, not during measurement. This is critical because placement is the final phase where we know definitively which children are actually being used.
How BoxWithConstraints leverages SubcomposeLayout
BoxWithConstraints
is perhaps the most elegant demonstration of SubcomposeLayout
‘s feature. Its entire implementation is remarkably concise:
1@Composable
2@UiComposable
3fun BoxWithConstraints(
4 modifier: Modifier = Modifier,
5 contentAlignment: Alignment = Alignment.TopStart,
6 propagateMinConstraints: Boolean = false,
7 content: @Composable @UiComposable BoxWithConstraintsScope.() -> Unit,
8) {
9 val measurePolicy = maybeCachedBoxMeasurePolicy(contentAlignment, propagateMinConstraints)
10 SubcomposeLayout(modifier) { constraints ->
11 val scope = BoxWithConstraintsScopeImpl(this, constraints)
12 @Suppress("ComposableLambdaInMeasurePolicy")
13 val measurables = subcompose(Unit) { scope.content() }
14 with(measurePolicy) { measure(measurables, constraints) }
15 }
16}
Let’s trace the execution flow when BoxWithConstraints is measured:
Step 1: Measure begins – The parent calls measure on the BoxWithConstraints
‘ LayoutNode
, passing the constraints it wants to impose.
Step 2: Scope creation – Inside the measure lambda, a BoxWithConstraintsScopeImpl
is created, wrapping the constraints and providing access to them as Dp values:
1private data class BoxWithConstraintsScopeImpl(
2 private val density: Density,
3 override val constraints: Constraints,
4) : BoxWithConstraintsScope, BoxScope by BoxScopeInstance {
5 override val minWidth: Dp
6 get() = with(density) { constraints.minWidth.toDp() }
7
8 override val maxWidth: Dp
9 get() =
10 with(density) {
11 if (constraints.hasBoundedWidth) constraints.maxWidth.toDp() else Dp.Infinity
12 }
13
14 override val minHeight: Dp
15 get() = with(density) { constraints.minHeight.toDp() }
16
17 override val maxHeight: Dp
18 get() =
19 with(density) {
20 if (constraints.hasBoundedHeight) constraints.maxHeight.toDp() else Dp.Infinity
21 }
22}
23
This scope implements BoxWithConstraintsScope
, which extends BoxScope
, giving the content access to both box alignment modifiers and constraint information.
Step 3: Subcomposition with constraint access – The important moment: subcompose(Unit) { scope.content() }
is called. This composes the user’s content lambda right now, during measurement, with the scope as the receiver. Because the scope contains the constraints, the user’s composable code can read maxWidth
, maxHeight
, and other constraint properties to make composition decisions:
1BoxWithConstraints {
2 if (maxWidth > 600.dp) {
3 WideScreenLayout()
4 } else {
5 NarrowScreenLayout()
6 }
7}
When the user’s lambda executes, maxWidth
is a property access on the BoxWithConstraintsScopeImpl
instance, which reads from the constraints
that were passed to the measure lambda. This is how the “future” (measurement constraints) informs the “past” (composition decisions).
Step 4: Measure and layout – After subcomposition returns the measurables, BoxWithConstraints
delegates to a standard Box
measure policy to handle the actual sizing and alignment logic. The Box measure policy measures each child and positions them according to the contentAlignment
and any individual align
modifiers.
This implementation is pretty clever, but simple. By using SubcomposeLayout
, BoxWithConstraints
doesn’t need to implement any complex state management, node tracking, or reuse logic, it’s all handled by the SubcomposeLayout
infrastructure. The entire implementation is just: create a scope, subcompose with that scope, measure the results.
Real-world use cases
SubcomposeLayout
is the foundational primitive behind many essential Compose components:
LazyColumn and LazyRow: These use SubcomposeLayout
to compose only the visible items based on the available space and scroll position. As you scroll, items are subcomposed just-in-time, measured, and laid out. When they scroll off-screen, they’re moved to the reusable pool for recycling.
TabRow: TabRow
uses SubcomposeLayout
to measure the tabs first, then subcompose the indicator based on the measured tab positions and sizes.
Scaffold: The Scaffold layout uses subcomposition to measure the top bar and bottom bar first, then subcompose the content area with the remaining space.
Adaptive layouts: Material3 Adaptive use BoxWithConstraints
(and by extension, SubcomposeLayout
) to show different layouts based on screen size and orientation.
Another great example from RevenueCat’s Android SDK is the use of BoxWithConstraints
to build UI components with dynamic widths. Since RevenueCat’s paywall UI is server-driven, it needs to handle a wide range of flexible layouts, configured through a web-based, Figma-like dashboard.
For instance, one of the paywall templates allows developers to dynamically configure the number of purchase options directly from the dashboard. Depending on the setup, this could be just one or two options, or even ten.

In this case, we use BoxWithConstraints
to calculate the width of each item, ensuring that all items have equal widths and can be either centered on the screen or stretched to fill the available space.
1 fun BoxWithConstraintsScope.packageWidth(numberOfPackages: Float): Dp {
2 val packages = packagesToDisplay(numberOfPackages)
3 val totalPadding = Template4UIConstants.packagesHorizontalPadding * 2
4 val totalSpaceBetweenPackages = Template4UIConstants.packageHorizontalSpacing * (packages - 1)
5 val availableWidth = maxWidth - totalPadding - totalSpaceBetweenPackages
6 return availableWidth / packages
7 }
8
9 BoxWithConstraints {
10 val numberOfPackages = state.templateConfiguration.packages.all.size
11 val packageWidth = packageWidth(numberOfPackages.toFloat())
12 Row(
13 ..
14 ) {
15 state.templateConfiguration.packages.all.forEach { packageInfo ->
16 SelectPackageButton(
17 state,
18 packageInfo,
19 viewModel,
20 Modifier.width(packageWidth), // calculated width size
21 )
22 }
23 }
24 }
Performance considerations
However, you should be aware of the performance characteristics of using SubcomposeLayout
and BoxWithConstraints
Subcomposing during measurement is inherently more expensive than standard composition. When you call subcompose()
, the Compose runtime must:
- Set up a new composition or reuse an existing one
- Run the composition lambda
- Apply state changes
- Materialize the composition into LayoutNodes
- Return the measurables
This all happens synchronously during the measure pass. If you’re not careful, you can create measure passes that take too long, leading to dropped frames. The slot reuse mechanism mitigates this by avoiding full recomposition when content is similar, but reuse isn’t free, it still requires reactivation and state reconciliation.
Another limitation is that SubcomposeLayout
doesn’t support intrinsic measurements. If you look at the measure policy implementation, you’ll see it extends LayoutNode.NoIntrinsicsMeasurePolicy
, which throws an exception if intrinsic measurements are requested:
1private val NoIntrinsicsMessage =
2 "Asking for intrinsic measurements of SubcomposeLayout " +
3 "layouts is not supported. This includes components that are built on top of " +
4 "SubcomposeLayout, such as lazy lists, BoxWithConstraints, TabRow, etc. To mitigate " +
5 "this:\n" +
6 "- if intrinsic measurements are used to achieve 'match parent' sizing, consider " +
7 "replacing the parent of the component with a custom layout which controls the order in " +
8 "which children are measured, making intrinsic measurement not needed\n" +
9 "- adding a size modifier to the component, in order to fast return the queried " +
10 "intrinsic measurement."
The reason is fundamental: intrinsic measurement asks “what size would this layout want to be?” without actually measuring. But SubcomposeLayout
doesn’t know what content it will compose until it receives the actual constraints, so it can’t answer intrinsic measurement queries.
Conclusion
In this article, you’ve learned how SubcomposeLayout
works under the hood, exploring its sophisticated three-tier node organization, slot reuse mechanism, and measure-time composition flow. You’ve seen how BoxWithConstraints leverages this infrastructure to provide a simple but powerful API for constraint-aware composition.
By understanding these internal mechanisms, you can make better decisions about when to use SubcomposeLayout
-based components and how to optimize their performance. Whether you’re building adaptive layouts with BoxWithConstraints
, creating custom lazy layouts, or designing new measurement-driven composition patterns, SubcomposeLayout
provides the foundation for breaking the traditional composition-measurement boundary.
As always, happy coding!
— Jaewoong
You might also like
- Blog post
Exploring Modifier.Node for creating custom Modifiers in Jetpack Compose
In this article, you will learn how to create custom modifiers using the three primary APIs, Modifier.then(), Modifier.composed(), and Modifier.Node
- Blog post
Server-driven UI SDK on Android: how RevenueCat enables remote paywalls without app updates
Learning server-driven UI by exploring RevenueCat's Android SDK.
- Blog post
remember vs rememberSaveable: deep dive into state management and recomposition in Jetpack Compose
Understanding the differences between remember and rememberSaveable by exploring their internal mechanisms, and how they relate to state and recomposition.