Custom layouts - wildware-uk/composegl GitHub Wiki
When Column, Row and Box are not enough — a radial menu, a hex grid, a
ring of icons round a crosshair — you write a MeasurePolicy.
There is no private hook the built-in layouts use and you cannot. Row is written
against exactly this interface.
val Ring = MeasurePolicy { measurables, constraints ->
val radius = 90f
val placeables = measurables.map { it.measure(constraints.loosen()) }
val side = radius * 2f + (placeables.maxOfOrNull { it.width } ?: 0f)
layout(constraints.constrainWidth(side), constraints.constrainHeight(side)) {
placeables.forEachIndexed { index, placeable ->
val angle = index * 2f * PI.toFloat() / placeables.size
placeable.at(
side / 2f + cos(angle) * radius - placeable.width / 2f,
side / 2f + sin(angle) * radius - placeable.height / 2f,
)
}
}
}
@Composable
fun RadialMenu(content: @Composable () -> Unit) {
Layout(measurePolicy = Ring, content = content)
}Layout's content is not its last parameter — the policy is — so pass it by name
rather than as a trailing lambda.
Then use it like anything else:
RadialMenu {
IconButton(heal, onClick = { })
IconButton(grenade, onClick = { })
IconButton(scan, onClick = { })
}Measure, then place. They are separate because a parent has to know how big all its children are before it can decide where any of them go — a row cannot centre its children without having measured them first.
val placeable = measurable.measure(constraints) // how big it wants to be
…
placeable.at(x, y) // inside the layout { } blocklayout(width, height) { … } says what size you chose. The block runs after every
layout in the tree has a size, which is why you can place a child you measured
earlier without measuring it again.
A measured child knows where its text stands: placeable.firstBaseline and
placeable.lastBaseline, down from its top, or NaN when it has no text. That is
how a layout of your own lines things up by their letters.
You rarely need to report one. A layout that only arranges children reports the baselines of the text inside them without being asked. A layout that draws text itself says where its lines are:
layout(width, height, firstBaseline = ascent, lastBaseline = ascent + (lines - 1) * lineHeight) {}Ask a child its size twice in one pass and you get an exception naming the widget:
Panel was measured twice in one pass.
It is an error rather than a warning because measuring twice doubles the cost of everything underneath, and two of them nested squares it.
If you want a child's size before deciding the constraints for it — the classic
reason people measure twice — measure it loose once, keep the Placeable, and work
from that.
Or ask it first. Asking is not measuring, so it does not count:
val wanted = measurables[0].maxIntrinsicWidth(height = constraints.maxHeight)
val placeable = measurables[0].measure(Constraints.fixed(wanted, 40f))Modifier.width(IntrinsicSize.Max) asks a layout how big it would like to be before
measuring it (see Layout). Your policy already has an answer: by default the
toolkit runs your measure over stand-ins that report each child's own intrinsic size,
so a ring of icons reports the ring's size without you writing anything.
Override the four when that is wrong or wasteful:
override fun MeasureScope.maxIntrinsicWidth(measurables: List<IntrinsicMeasurable>, height: Float) =
measurables.maxOfOrNull { it.maxIntrinsicWidth(height) } ?: 0fThree cases where it is worth it. A leaf whose measure cannot tell its narrowest from its
widest — Text answers minIntrinsicWidth with its longest word. A layout with
weights, where measuring with unlimited room hands every weighted child a share of
nothing — Row and Column write their own for that reason.
And one where it is required: a measure that writes anything down. The default runs
your measure with made-up room, so a scroll area that clamps its position there, or a
lazy list that records its window size, would be scrolled back or recomposed by the
question. ScrollArea, LazyColumn, TextField and Typewriter all answer directly
for that reason.
An answer asks children; it never measures them.
Measurable.layoutData carries the parts of a child's modifier that only its parent
can act on:
val weight = measurables[index].layoutData.weight
val alignment = measurables[index].layoutData.alignment ?: contentAlignmentThat is how Modifier.weight(1f) and Modifier.align(...) reach the layout that
has to honour them. Nothing else about a child is visible, which is what stops
layouts reaching into each other.
A layout with named parts — a list item with an icon, a label and a badge; a HUD with a centre slot and four corners — should not find them by position. The moment one of them is only there sometimes, "the first child" is a different child. Give each one a name instead:
val ListItem = MeasurePolicy { measurables, constraints ->
val placeables = measurables.map { it.measure(constraints.loosen()) }
fun slot(id: String) =
measurables.indexOfFirst { it.layoutId == id }.takeIf { it >= 0 }?.let { placeables[it] }
val icon = slot("icon")
val label = slot("label")
val badge = slot("badge")
val width = constraints.maxWidth
layout(width, placeables.maxOfOrNull { it.height } ?: 0f) {
icon?.at(0f, 0f)
label?.at((icon?.width ?: 0f) + 10f, 0f)
badge?.at(width - badge.width, 0f)
}
}
Layout(measurePolicy = ListItem, content = {
if (unread > 0) Badge(unread, Modifier.layoutId("badge")) // only sometimes, and first
Image(mail, Modifier.layoutId("icon"))
Text("Inbox", Modifier.layoutId("label"))
})
- A name is anything with an
equals: a string, an enum, an object. - Named twice, the later name wins.
- It changes on a later frame like anything else — rename a child and it moves slot.
-
Row,ColumnandBoxignore it. It means something only to a layout that reads it. - A layout sees only its own children's names. A name on something inside a
child — an icon wrapped in a
Box— is not seen; put it on theBox. - Every child still has to be measured once, named or not, including ones your layout has no slot for.
A widget that draws but has no children is a LeafLayout — its policy decides its
size and a draw function paints it:
private class DialPainter(private val value: Float) : MeasurePolicy {
override fun MeasureScope.measure(measurables: List<Measurable>, constraints: Constraints) =
layout(constraints.constrainWidth(64f), constraints.constrainHeight(64f)) {}
val draw: UiCanvas.(Rect) -> Unit = { bounds ->
// wedge is your own function: the fan's points, x, y, x, y…, as a FloatArray
fan(wedge(bounds, value), Colour.rgb(0x5B8DEF))
}
}
@Composable
fun Dial(value: Float, modifier: Modifier = Modifier) {
val painter = remember(value) { DialPainter(value) }
LeafLayout(modifier = modifier, name = "dial", measurePolicy = painter, draw = painter.draw)
}One object measuring and drawing is the pattern the built-in widgets use, and it is deliberate: what was measured is what is drawn, and nothing can re-measure at draw time.
Note remember(value) — a new object when the value changes, the same object when
it has not, so an unchanged widget does not redraw the frame.
On a frame where something reached the tree: a recompose that changed a node, a node added, removed or moved, a size animation under way, or a different screen size. A screen standing still measures nothing at all — every rectangle is already the answer.
And on a frame where something did change, only that part is measured: the node that changed, and each node above it up to the root. Every other node keeps its size and where its children sit, as long as its parent offers it the same room as last time. A score changing on a HUD costs the score label and the boxes it sits in, not the rest of the HUD. A node offered different room — a row whose label grew leaves less for the one after it — is measured again, and so is everything under it that is offered different room in turn.
So read whatever moves your layout while composing, not inside measure. A
policy remembered against the value is a new policy when the value changes, and a
new policy is a change:
@Composable
fun CompassStrip(heading: Float, content: @Composable () -> Unit) {
// Read here: a new heading is a new policy, so that frame is laid out again.
val policy = remember(heading) {
MeasurePolicy { measurables, constraints ->
val placeables = measurables.map { it.measure(constraints.loosen()) }
val width = constraints.constrainWidth(360f)
layout(width, constraints.constrainHeight(24f)) {
placeables.forEach { it.at(width / 2f - heading, 0f) }
}
}
}
Layout(measurePolicy = policy, content = content)
}A policy that reads player.heading inside measure instead would not move at
all: nothing tells its node that anything changed, and a node nothing changed is
not measured again.
Reading where another node is from inside measure — lining a marker up with a
button elsewhere on the screen — works as it always has: you get wherever the last
pass left that node. The toolkit notices a measure that reads any node's x,
y, width or height, or anything worked out from them like boundsInRoot or
toLocal, and measures that layout on every pass, changed or not. After any pass
that moved, resized or first placed something, the next frame lays out once more,
so a layout like that catches up a frame later and then stops. On a screen's very
first frame the second pass runs straight away, so it is right before anything is
drawn.
The usual way to get hold of the button is onPlaced, and keep the node itself:
var button: UiNode? = null
val learn = PlacedHandler { button = it } // remember it, like any handler
val underButton = MeasurePolicy { measurables, constraints ->
val arrow = measurables.single().measure(constraints.loosen())
val x = button?.boundsInRoot?.left ?: 0f // read while measuring: noticed
layout(constraints.maxWidth, arrow.height) { arrow.at(x, 0f) }
}After a handler is told anything, the next pass measures every node, so the arrow
reads the button it has just been given — and the same holds for a copy of the
button's rectangle kept in a plain field. Keeping the node is the cheaper of the
two: from then on the arrow is seen reading it, and is measured on every pass by
itself. A handler that never feeds a layout can say so with readByLayout; see
Modifiers.
A layout runs on every frame where it or something inside it changed — every frame
of a scroll, a drag or an animation in it — so whatever it costs, it costs many
times a second. The
obvious implementation — map to a list, maxOfOrNull, forEachIndexed — makes
three or four objects per node per frame.
For a layout that is on screen all the time, the scope will lend you room that belongs to the node and is reused next frame:
val count = measurables.size
val placeables = placeables(count) // Array<Placeable?>, at least `count` long
val sizes = sizes(count) // FloatArray
val positions = positions(count) // FloatArray
val placements = placements(count) // FloatArray, x, y, x, y…
val offers = offers(count) // one ConstraintsCache per childIt is good until your measure returns. Keeping it past that is keeping somebody
else's paper.
This looks free and is not:
layout(width, height) {
for (index in 0 until count) placeables[index]?.at(xs[index], ys[index])
}The block mentions count and placeables, so Kotlin makes a fresh object for it
every time the layout runs — once per node, on every frame of every scroll,
drag and animation. On a HUD of twenty widgets that was most of what a pass cost.
So write the corners down instead, and hand back the count:
val placements = placements(count)
for (index in 0 until count) {
val placeable = placeables[index] ?: continue
placements[index * 2] = … // x
placements[index * 2 + 1] = … // y
}
return layout(width, height, count)Nothing is mentioned, so nothing is made. Ring above rewritten this way:
val Ring = MeasurePolicy { measurables, constraints ->
val radius = 90f
val count = measurables.size
val placeables = placeables(count)
val placements = placements(count)
var widest = 0f
for (index in 0 until count) {
val placeable = measurables[index].measure(constraints.loosen(offers(1)[0]))
placeables[index] = placeable
if (placeable.width > widest) widest = placeable.width
}
val side = radius * 2f + widest
for (index in 0 until count) {
val placeable = placeables[index] ?: continue
val angle = index * 2f * PI.toFloat() / count
placements[index * 2] = side / 2f + cos(angle) * radius - placeable.width / 2f
placements[index * 2 + 1] = side / 2f + sin(angle) * radius - placeable.height / 2f
}
layout(constraints.constrainWidth(side), constraints.constrainHeight(side), count)
}Keep the block form when the corners genuinely are not known until placing time, or when arrays would make the code harder to read than it is worth.
If you hand a child something other than what you were given — a share of a row, a
fixed size — that is an object per child per frame too. offers(count) lends one
remembered answer per child:
val offers = offers(count)
val room = offers[index].of(0f, share, 0f, crossMax)ConstraintsCache.of(...) hands back the same object when the four numbers have not
moved, which on a still screen is always. constraints.loosen(cache) and
constraints.shrink(h, v, cache) take one for the same reason.
One cache per child, never shared: two different sets of numbers through one cache means neither is ever the one that was kept.
Ignore all of it and build your own lists if you would rather — nothing checks, and for a layout that appears once on a settings screen it does not matter. For a HUD, it does: see Testing for the allocation test that watches this.