Custom layouts - wildware-uk/composegl GitHub Wiki

Custom layouts

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.


The shape of one

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 = { })
}

The two halves

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 { } block

layout(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.

Baselines

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) {}

Measure each child exactly once

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))

Intrinsic sizes

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) } ?: 0f

Three 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.


What a child can tell you

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 ?: contentAlignment

That 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.

Telling children apart

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"))
})

two list items from the same layout, one with a badge written before its icon

  • 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, Column and Box ignore 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 the Box.
  • Every child still has to be measured once, named or not, including ones your layout has no slot for.

Leaves

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.


When a layout runs

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.


Making it cost nothing per frame

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 child

It is good until your measure returns. Keeping it past that is keeping somebody else's paper.

The placement block is the expensive bit

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.

Constraints you work out yourself

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.


What next

  • Layout — the three that ship, and what they do
  • Modifiers — writing a modifier your layout reads
  • Testing — proving a layout places things where you think
⚠️ **GitHub.com Fallback** ⚠️