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.

Jaewoong Eum
Published

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:

  1. Check if the slot was precomposed (precomposeMap)
  2. Try to reuse a compatible node from the reusable pool (takeNodeFromReusables())
  3. 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:

  1. Collects all the slot IDs that could potentially be reused
  2. Calls the slotReusePolicy.getSlotsToRetain() to let the policy decide which slots to keep
  3. For kept slots, marks them as reusable and deactivates their composition
  4. 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:

  1. 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.
  2. 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 separate currentApproachIndex and approachMeasureScope. This pass can compose items that weren’t composed in lookahead, and it manages its own set of precomposed slots tracked in approachPrecomposeSlotHandleMap.

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 BoxWithConstraintsLayoutNode, 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 maxWidthmaxHeight, 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:

  1. Set up a new composition or reuse an existing one
  2. Run the composition lambda
  3. Apply state changes
  4. Materialize the composition into LayoutNodes
  5. 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

Share this post