Mastering Compose: Custom Layout and Scoped Modifiers

Tezov
ITNEXT
Published in
6 min readMay 9, 2024

--

In the realm of mobile development with Jetpack Compose, the ability to create custom layouts and apply associated modifiers is a game-changer. When working with Column or Row, every child element gains access to modifiers specific to Column/Row like “weight”. In this article, I’ll guide you through the process of creating custom layout and custom associated modifiers to enhance your UI. Let’s delve into the details!

unsplash : Artem Maltsev

Table of Contents

  1. Public composable layout function: ColumnWithShrinkable
  2. Scope implementation
  3. Modifier implementation
  4. Measure Policy implementation
  5. Use case -> use of layout and modifier with an animation
  6. Source code and final thoughts

This is what we gonna do :

1.Public composable layout function

@Composable
fun ColumnWithShrinkable(
modifier: Modifier = Modifier,
content: @Composable ColumnWithShrinkableScope.() -> Unit
) {
val measurePolicy = remember {
columnWithShrinkableMeasurePolicy()
}
Layout(
content = { ColumnWithShrinkableScopeImpl.content() },
measurePolicy = measurePolicy,
modifier = modifier
)
}

Our composable layout will accept two arguments: the modifier and the content.

To build a custom layout, we call the “Layout” composable function. This function requires the content scoped by our ColumnWithShrinkableScope and a measurePolicy, specifically columnWithShrinkableMeasurePolicy. Finally, it also requires a modifier, which is received from the call of ColumnWithShrinkable.

Let’s examine how to do ColumnWithShrinkableScope and columnWithShrinkableMeasurePolicy.

2. Scope implementation

First, we need to define an interface for our scope where the signature of our custom modifier linking to our Layout will be. Therefore, here, we will define a shrink modifier.

This modifier will be responsible for reducing the height of the view, accepting values between 0 and 1. To clarify, if the view requires a height of 100dp and the shrink value is set to 0.5, our measure policy will allow the view to have a height of 50dp.

interface ColumnWithShrinkableScope {
fun Modifier.shrink(value: Float): Modifier
}

And the implementation wil be :

private object ColumnWithShrinkableScopeImpl : ColumnWithShrinkableScope {

override fun Modifier.shrink(value: Float): Modifier {

require(value >= 0.0f) { "invalid heightFactor $value; must be greater or equal than zero" }
require(value <= 1.0f) { "invalid heightFactor $value; must be lesser or equal than zero" }

return this.then(
ModifierShrinkImpl(
value = value,
inspectorInfo = debugInspectorInfo {
name = "shrink"
this.value = value
properties["value"] = value
}
)
)
}
}

We need to incorporate a comprehensive check to ensure the required conditions are met, accompanied by a clear message. Following this, we must chain our modifier accordingly. Preserving the modifier history is crucial to maintain integrity.

3. Modifier implementation

The modifier associated with Layout isn’t merely a straightforward extension function from Modifier. Instead, it’s a custom class that must implement the ParentDataModifier interface and extend InspectorValueInfo. Let’s proceed with implementing this.

// First we need to declare a data class which will store the values
// of our modifiers. In our case, we have only one modifier.
private data class ColumnWithShrinkableParentData(
var shrink: Float? = null,
)

// Then the srink modifier receiver.
private class ModifierShrinkImpl(
val value: Float,
inspectorInfo: InspectorInfo.() -> Unit
) : ParentDataModifier, InspectorValueInfo(inspectorInfo) {


override fun Density.modifyParentData(parentData: Any?) =
((parentData as? ColumnWithShrinkableParentData) ?: ColumnWithShrinkableParentData()).also {
it.shrink = value
}


... // equals, hascode, toString part

}

The crucial aspect lies in overriding the function “fun Density.modifyParentData(parentData: Any?)”. Here, we cast the parentData to our ColumnWithShrinkableParentData. If this casting isn’t a success, we create a new instance. Subsequently, we set the value received for our modifier and return the ColumnWithShrinkableParentData class.

4. Measure Policy implementation

The final segment involves the implementation of the Measure Policy, where all the logic will be executed. Our approach will be as follows:

  • All children are laid out sequentially below each other.
  • The preferred height of a child will be reduced by the value of the modifier. We don’t scale the content; rather, we allow a size weighted by our shrink value. This means that if the view requires a height of 100dp and our shrink value is 0.5, then the measure policy will only permit this child to have a height of 50dp.

Let’s do this implementation step by step.


private fun columnWithShrinkableMeasurePolicy() = object : MeasurePolicy {

// some convenient accessors to retrieve the shrink modifier value
val Measurable.columnWithShrinkableParentData get() = (parentData as? ColumnWithShrinkableParentData)
val Measurable.shrink get() = columnWithShrinkableParentData?.shrink

....

}

We need then to override the method “fun MeasureScope.measure”

  • First phase is to measure all child :
private fun columnWithShrinkableMeasurePolicy() = object : MeasurePolicy {
....

override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult {
// if no child, return an empty layout
if (measurables.isEmpty()) {
return layout(
constraints.minWidth,
constraints.minHeight
) {}
}

// this will be our layout width and height
var boxWidth = 0
var boxHeight = 0

// array of all children that we will lay out in second phase
val placeables = arrayOfNulls<Placeable>(measurables.size)

measurables.fastForEachIndexed { index, measurable ->

// preferred height of the child with the max width available
val height = measurable.minIntrinsicHeight(constraints.maxWidth)

// use of our modifier to alter the preferred height of the child
val shrink = measurable.shrink
val measureConstraints = shrink?.let { ratio ->
constraints.copy(
maxHeight = (height * ratio).toInt(),
)
} ?: constraints

// measure and retain the child with his accepted measure constraints
val placeable = measurable.measure(measureConstraints)
placeables[index] = placeable

// compute the width and height of our layout with all children
boxWidth = max(boxWidth, placeable.width)
boxHeight += placeable.height
}

// Our layout's preferred width and height are coerced to its own constraints
boxWidth = boxWidth.coerceIn(constraints.minWidth, constraints.maxWidth)
boxHeight = boxHeight.coerceIn(constraints.minHeight, constraints.maxHeight)

.... // lay out phase

}
  • Once the measure phase is completed, we proceed to lay out the placeables:
private fun columnWithShrinkableMeasurePolicy() = object : MeasurePolicy {
....

override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult {

.... // measure phase

return layout(boxWidth, boxHeight) {
var yOffset = 0
placeables.forEach { placeable ->
placeable as Placeable
placeable.place(IntOffset(0,yOffset))
yOffset += placeable.height
}
}
}

}

This phase is straightforward; all children are laid out sequentially below each other.

That concludes the implementation; everything is set. Now, let’s proceed with the use case.

5. Use Case

To begin, let’s construct a straightforward animation. Since our shrink modifier accepts a float value ranging from 0 to 1, the animation will involve transitioning this value from 0 to 1 and then back to 0, repeating indefinitely.

Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
) {

// controller of the animation
val reverseAnimation = remember { mutableStateOf(false) }

val animatedValue = animateFloatAsState(
label = "animation",
targetValue = if (reverseAnimation.value) 1.0f else 0.0f,
animationSpec = tween(
durationMillis = if (reverseAnimation.value) 1500 else 2000,
easing = if (reverseAnimation.value) FastOutLinearInEasing else LinearOutSlowInEasing,
),
finishedListener = {
// reverse the animation when it reaches the end.
reverseAnimation.value = !reverseAnimation.value
}
)

// start the animation
LaunchedEffect(Unit) { reverseAnimation.value = true }

.... // layout part

}

Simplifying the approach, we have an “animatedValue” transitioning from 0 to 1 and reversing indefinitely.

Now our UI :

Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
) {

.... // animation part

// call of our layout
ColumnWithShrinkable(
modifier = Modifier
.background(Color.Green)
.align(Alignment.Center)
.width(IntrinsicSize.Max)

) {
Box(
modifier = Modifier
.background(Color.Blue)
.fillMaxWidth()
.height(12.dp)
)

Text(
modifier = Modifier
// this text uses our modifier shrink
// and the value is the animated value
.shrink(animatedValue.value),
text = "Hello World",
style = MaterialTheme.typography.headlineLarge
)

Box(
modifier = Modifier
.background(Color.Red)
.fillMaxWidth()
.height(12.dp)
)
}
}

5. Source code and final thoughts

Creating layouts with custom scoped modifiers is remarkably straightforward, opening up a plethora of possibilities. As your layout, composed of Box, Column, Row, LazyGrid, or any other elements, begins to grow in complexity, opting for a custom layout can often yield faster and more efficient results. Don’t hesitate to explore this avenue when seeking the desired outcome for your UI.

Find the source code available in this repository:

tezov.medium.adr.column_with_shrinkable_child

Don’t hesitate to comment or request more information. If you find this story helpful, consider following or subscribing to the newsletter. Thanks.

I’ll gladly also accept any support to help me to write more content.

--

--

Born with a C real-time system background. Nowadays, i'm a fanatic Android developer and Swift lover.