Advance Layout Techniques in Jetpack Compose

Rafsanjani Abdul-Aziz
7 min readNov 5, 2023

--

Image Source: Compose layout basics | Jetpack Compose | Android Developers

Jetpack compose comes with several layout components that enables developers to lay out and arrange children components in various ways. For horizontal and vertical layouts, we have Rows and Columns respectively. There is also a Box layout for stacking components on top of each other. There are a few other layouts such as ConstraintLayoutand BoxWithConstraints which are beyond the scope of this article.

These are foundational layouts that are readily available to be used in any compose project with very little to no configuration required. There are times however, where the foundational layouts aren’t enough to meet the complex requirements of our user interface. Luckily for us, all the foundational layout components are built on top of the Layout component.

The Layout Composable

The Layout composable is is a component that can be used to measure and layout some child elements. All the foundational composables rely on theLayout composable for measuring and laying out their children. It has a few overrides but they all have similar properties in common. They all accept a composable or a list of composables as children and a MeasurePolicy for actually measuring and laying them out.

The signature of the Layout component is showed below.

This Layout overload accepts a list of composables and a MultiContentMeasurePolicy. We will save this one for a later discussion.

@Suppress("ComposableLambdaParameterPosition", "NOTHING_TO_INLINE")
@UiComposable
@Composable
inline fun Layout(
contents: List<@Composable @UiComposable () -> Unit>,
modifier: Modifier = Modifier,
measurePolicy: MultiContentMeasurePolicy
)

This Layout overload accepts a single composable and a MeasurePolicy and will be the focus of our discussion today.

@Suppress("ComposableLambdaParameterPosition")
@UiComposable
@Composable
inline fun Layout(
content: @Composable @UiComposable () -> Unit,
modifier: Modifier = Modifier,
measurePolicy: MeasurePolicy
)

The Layout Composable In Action: Circular Layout

Let’s try to build this Circular layout which arranges it’s children in a circle. None of the foundational layouts can fulfil this requirement so we need to come up with our own.

A circular layout showing a ring of buttons

With a quick glance, the anatomy of the layout is easily discernible. It comprises a rectangle encompassing an imaginary circle of defined radius. Positioned along the circumference of this conceptual circle are the children, evenly spaced apart at a fixed angle.

This is how the signature of our custom layout will look like. It accepts a radius which will be used to determine how far away the children will be from the centre of their imaginary circle.

/**
* Lays out it's children in a circular manner based on the specified
* radius
*
* @param content The composable content that will be laid out in a
* circular manner.
* @param radius The radius of the circular layout. The children will be
* positioned on a circle with this radius.
*/
@Composable
fun CircularLayout(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
radius: Float = 250f
)

We can proceed to use the Layout composable right away by passing it a modifier and content parameters. The third parameter is a functional interface with a single measure function which can be implemented right away. The measure function has two parameters which are the list of children to be measured, and the incoming layout constraints. It returns a MeasureResult

/**
* Lays out it's children in a circular manner based on the specified
* radius
*
* @param content The composable content that will be laid out in a
* circular manner.
* @param radius The radius of the circular layout. The children will be
* positioned on a circle with this radius.
*/
@Composable
fun CircularLayout(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
radius: Float = 250f
) {
Layout(modifier = modifier, content = content) { measurables, constraints ->
// Measure children and lay them out around an imaginary circle
}
}

All the children of the layout can be measured using the incoming constraints from the parent. This produces a list of Placeable that can be placed on the available working 2D space on the screen.

// Measure children and lay them out around an imaginary circle
val placeables = measurables.map {it.measure(constraints)}

In the Circular Layout, the items are placed in a circle with some spacing between individual items. The spacing is achieved by dividing the total internal angle of a circle by the number of placeables.

// Calculate angular separation between individual items
val angularSeparation = 360 / placeables.size

The available working area can be denoted by a rectangle obtained from the radius parameter. This produces an imaginary square, big enough to lay out all the children inside.

// Represent the available working area by a rectangle
val boundedRectangle = Rect(
center = Offset(
x = 0f,
y = 0f,
),
radius = radius + placeables.first().height,
).roundToIntRect()

We can proceed to calculate the center of our working area, which will be used as the origin or our angular calculations.

// Calculate the center of the layout and use it later for trigonometric calculations
val center = IntOffset(boundedRectangle.width / 2, boundedRectangle.height / 2)

Now that we have all the required pieces in place, we can proceed to actually lay out the child components on the screen. To do this we need to call the layout function and pass it the width and height of our working area.

When laying out items in a linear fashion, the width and height parameters of the layout function would usually be derived from the sum of the individual widths and heights of the items respectively. In the case of the circular layout, the width and height needs to be obtained from the rectangle denoting the working area.

Within the layout function, the item’s coordinates for placement are computed using the specified angle and the radius of the conceptual circle. To ensure the correct arrangement of all children on the imaginary circle, the angularSeparation is continuously added to the requiredAngle after each iteration.

layout(boundedRectangle.width, boundedRectangle.height) {
var requiredAngle = 0.0

placeables.forEach { placeable ->
// Calculate x,y coordinates where the layout will be placed on the
// circumference of the circle using the required angle
val x = center.x + (radius * sin(Math.toRadians(requiredAngle))).toInt()
val y = center.y + (radius * cos(Math.toRadians(requiredAngle))).toInt()

placeable.placeRelative(x - placeable.width / 2, y - placeable.height / 2)

requiredAngle += angularSeparation
}
}

Here is the complete implementation of the whole CircularLayout composable.

/**
* Lays out it's children in a circular manner based on the specified
* radius
*
* @param content The composable content that will be laid out in a
* circular manner.
* @param radius The radius of the circular layout. The children will be
* positioned on a circle with this radius.
*/
@Composable
fun CircularLayout(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
radius: Float = 250f
) {
Layout(modifier = modifier, content = content) { measurables, constraints ->
val placeables = measurables.map { it.measure(constraints.) }

// Calculate angular spacing between individual items
val angularSeparation = 360 / placeables.size


// Represent the available working area by a rectangle
val boundedRectangle = Rect(
center = Offset(
x = 0f,
y = 0f,
),
radius = radius + placeables.first().height,
).roundToIntRect()

// Calculate the center of the working area and use it later for trig calculations
val center = IntOffset(boundedRectangle.width / 2, boundedRectangle.height / 2)

// Constrain our layout to the working area
layout(boundedRectangle.width, boundedRectangle.height) {
var requiredAngle = 0.0

placeables.forEach { placeable ->
// Calculate x,y coordinates where the layout will be placed on the
// circumference of the circle using the required angle
val x = center.x + (radius * sin(Math.toRadians(requiredAngle))).toInt()
val y = center.y + (radius * cos(Math.toRadians(requiredAngle))).toInt()

placeable.placeRelative(x - placeable.width / 2, y - placeable.height / 2)

requiredAngle += angularSeparation
}
}
}
}

This layout can be employed similarly to any other layout to arrange items in a circular pattern by simply supplying one or more composable children to it’s content

 CircularLayout {
(0..10).map {
Box(
modifier = Modifier
.size(30.dp)
.background(
color = Color(
red = Random.nextInt(255),
green = Random.nextInt(255),
blue = Random.nextInt(255),
),
shape = CircleShape
)
)
}
}
A circular layout showing a ring of circles

The Layout Modifier: Laying out Non-Conforming Children

Very often, the foundational layouts will be suitable for our layout requirements, but there are special instances where we want to take over and decide how certain specific child elements are positioned on the screen. A typical example is a child element that ignores the incoming width constraints of the parent and decides to occupy the entire available width.

A column with 4 texts and a divider

Here we have a regular Column with 4 Text components and a Divider The Column enforces a maximum width constraint by setting a horizontal padding. The Divider overrides this incoming constraint and decides to occupy the full available width of it’s parent using the Modifier.layout functionality.

@Composable 
fun ItemList() {
val horizontalPadding = 8.dp

Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = horizontalPadding),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {

TextItem("Item 1")
TextItem("Item 2")

Divider(
modifier = Modifier
.layout { measurable, constraints ->
val placeable = measurable.measure(
constraints.copy(
// Add the horizontalPadding back to the divider to fill the maximum width
maxWidth = constraints.maxWidth + (2 * horizontalPadding.roundToPx()),
)
)

layout(placeable.width, placeable.height) {
placeable.placeRelative(0, 0)
}
},
thickness = 3.dp
)

TextItem("Item 3")
TextItem("Item 4")
}
}

From these few examples, we’ve seen how Jetpack compose offers a powerful and rich set of foundational components that allows developers to efficiently arrange and organise UI elements. However, when the need arises for more complex and specialised layouts, the Layout composable on top of which all the foundational layouts are built becomes a very essential tool, enabling the creation of custom layout structures tailored to specific design requirements.

The ability to customise the layout behaviour of individual components in a layout hierarchy showcases the versatility and fine-grain control provided by the layout modifier.

By understanding and utilising these advance layout techniques to our specific needs, developers can achieve very precise and intricate UI designs.

--

--