Advance Layout Techniques in Jetpack Compose
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 ConstraintLayout
and 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.
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
)
)
}
}
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
.
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.