Skip to main content

Constraint layouts

Doodle also supports a constraints based layout that uses linear equations to define placement. This approach lets you write equations that define how several anchor points on a View (based on a provided Bounds) will be placed relative to other Views and the parent View. This covers many of the common layout use cases and is easy to use.

Each linear equation is one of the following forms. Where attribute refers to anchor points or width and height.

view1.attribute eq constant1 * view2.attribute + constant2 view1.attribute lessEq constant1 * view2.attribute + constant2 view1.attribute greaterEq constant1 * view2.attribute + constant2

The Constraint system will modify all the attributes provided to it to ensure every equation (or inequality) is satisfied. In the above example, that means updating widths fo view1 and view2 to ensure they add up to the parent width minus 10.

tip

You can also include multiple Views in a single equation:

view1.width + 2 * view2.width eq parent.width - 10

The fact that these are equations (or inequalities) means you can flip the order of attributes and have the same effect, as long as you obey the rules of mathematics and change signs accordingly. So the equation above is the same as:

view1.width + 10 eq parent.width - 2 * view2.width

Creating constraints

Constraint layouts are created using the constrain function. This function takes a list of Views and a lambda that defines the constraints to apply. For example, the following shows a new layout being created to position and size two panels within a container:

package io.nacular.doodle.docs.apps import io.nacular.doodle.application.Application import io.nacular.doodle.core.Display import io.nacular.doodle.docs.utils.Panel import io.nacular.doodle.drawing.Color.Companion.Gray import io.nacular.doodle.drawing.Color.Companion.Lightgray import io.nacular.doodle.drawing.TextMetrics import io.nacular.doodle.layout.constraints.constrain class ConstraintCreationApp(display: Display, textMetrics: TextMetrics): Application { init { val panel1 = Panel(textMetrics, "Panel 1").apply { backgroundColor = Lightgray } val panel2 = Panel(textMetrics, "Panel 2").apply { backgroundColor = Gray } display += listOf(panel1, panel2) // use Layout that follows constraints to position items //sampleStart display.layout = constrain(panel1, panel2) { panel1, panel2 -> panel1.top eq 0 panel1.left eq 0 panel1.right eq parent.right / 3 panel1.bottom eq parent.bottom panel2.top eq panel1.top panel2.left eq panel1.right panel2.right eq parent.right panel2.bottom eq parent.bottom } //sampleEnd } override fun shutdown() {} }

Readonly attributes

Sometimes it is necessary to refer to a View's attribute in a constraint without the risk of changing it. This comes up if a View has some property specified outside the constraint block that should be preserved, of if you simply want readOnly access to the attribute.

To do this, you simply use the readOnly property of the attribute:

// view1.width won't be modified to satisfy this equation view2.width eq view1.width.readOnly * 2

Constraint strength

It is possible to define constraints that conflict with each other. Such situations result in an error since they have no clear solution. But you can resolve these conflicts by providing a relative priority or Strength for the constraints in question. This allows the engine to break lower strength constraints when there are conflicts.

All constraints have the Required strength by default. This is the highest possible strength that tells the engine to enforce such a constraint. But you can specify the strength explicitly as follows.

This results in the panel matching its parent's width whenever it is 200 or less.

package io.nacular.doodle.docs.apps import io.nacular.doodle.application.Application import io.nacular.doodle.core.Display import io.nacular.doodle.core.container import io.nacular.doodle.core.width import io.nacular.doodle.docs.utils.Panel import io.nacular.doodle.drawing.Canvas import io.nacular.doodle.drawing.Color.Companion.Black import io.nacular.doodle.drawing.Color.Companion.Gray import io.nacular.doodle.drawing.Color.Companion.Lightgray import io.nacular.doodle.drawing.Color.Companion.White import io.nacular.doodle.drawing.Stroke import io.nacular.doodle.drawing.TextMetrics import io.nacular.doodle.drawing.paint import io.nacular.doodle.geometry.Size import io.nacular.doodle.layout.constraints.Strength.Companion.Strong import io.nacular.doodle.layout.constraints.center import io.nacular.doodle.layout.constraints.constrain import io.nacular.doodle.utils.Resizer import kotlin.math.min class ConstraintStrengthApp(display: Display, textMetrics: TextMetrics): Application { init { val panel = object: Panel(textMetrics, "Panel") { init { backgroundColor = Lightgray } override fun render(canvas: Canvas) { super.render(canvas) canvas.rect(bounds.atOrigin, Stroke(Black, 2.0)) } } display += container { this += panel size = Size(min(400.0, display.width - 20), min(300.0, display.width - 20)) //sampleStart layout = constrain(panel) { it.left eq 0 it.width lessEq 200 (it.right eq parent.right) .. Strong // ignored when conflicts with above constraint it.height eq parent.height } //sampleEnd render = { rect(bounds.atOrigin, fill = Gray.paint) } // helper to resize container Resizer(this).apply { movable = false } } // use Layout that follows constraints to position items display.layout = constrain(display.first(), center) display.fill(White.paint) } override fun shutdown() {} }
tip

Notice that the constraints indicate a weaker priority/strength for the view's right property. This approach allows you to relax certain constraints when there are conflicts.

The following shows a more complex set of constraints that also use strengths and inequality.

package io.nacular.doodle.docs.apps import io.nacular.doodle.application.Application import io.nacular.doodle.core.Display import io.nacular.doodle.core.container import io.nacular.doodle.core.width import io.nacular.doodle.docs.utils.BlueColor import io.nacular.doodle.docs.utils.Panel import io.nacular.doodle.drawing.Color.Companion.Gray import io.nacular.doodle.drawing.Color.Companion.Lightgray import io.nacular.doodle.drawing.Color.Companion.Pink import io.nacular.doodle.drawing.Color.Companion.White import io.nacular.doodle.drawing.TextMetrics import io.nacular.doodle.drawing.paint import io.nacular.doodle.geometry.Size import io.nacular.doodle.layout.constraints.Strength.Companion.Weak import io.nacular.doodle.layout.constraints.center import io.nacular.doodle.layout.constraints.constrain import io.nacular.doodle.utils.Direction.East import io.nacular.doodle.utils.Direction.West import io.nacular.doodle.utils.Resizer import kotlin.math.min class ComplexConstraintsApp(display: Display, textMetrics: TextMetrics): Application { init { val panel1 = Panel(textMetrics, "1").apply { backgroundColor = Pink; width = 350.0; Resizer(this).apply { directions = setOf(East ); movable = false } } val panel2 = Panel(textMetrics, "2").apply { backgroundColor = Gray; width = 50.0; Resizer(this).apply { directions = setOf(East, West) } } val panel3 = Panel(textMetrics, "3").apply { backgroundColor = Lightgray; Resizer(this).apply { directions = setOf(West ); movable = false } } display += container { this += listOf(panel1, panel2, panel3) size = Size(min(400.0, display.width - 20), min(300.0, display.width - 20)) val inset = 5 //sampleStart layout = constrain(panel1, panel2, panel3) { p1, p2, p3 -> p1.top eq inset p1.left eq inset p1.width lessEq (parent.width - inset) / 3 (p1.width eq 350) .. Weak p1.height eq parent.height - 2 * inset p2.top eq p1.top p2.left eq p1.right p2.height eq p1.height p3.top eq p2.top p3.left eq p2.right p3.height eq p2.height p1.width + p2.width + p3.width eq parent.width - 2 * inset } //sampleEnd render = { rect(bounds.atOrigin, fill = BlueColor.paint) } // helper to resize container Resizer(this).apply { movable = false } } // use Layout that follows constraints to position items display.layout = constrain(display.first(), center) display.fill(White.paint) } override fun shutdown() {} }

Parents are read-only by default

Doodle Layouts are all independent and operate primarily on the contents of their container without modifying the container's size. This helps avoid contention where the container itself is within a Layout that disagrees about the size it should have. Constraints support to this approach by having all attributes of the parent container default to read-only. This means the properties of parent will not be modified by any constraint as the default. In effect, all parent attributes can be thought of as pure constants.

// parent.width can be thought of as constant number view.width eq parent.width

However, it can be very useful to propagate size information up to the parent container. A good example is wanting to ensure the parent is large enough to show its children. This can be done by explicitly designating a parent's attribute as writable. Doing so means that attribute will be updated as needed to satisfy the constraint.

// parent.width will be updated if needed view.width eq parent.width.writable

Non-siblings constraints

You can constrain any set of Views, regardless of their hierarchy. But, the Constraint Layout will only update the Views that within the Container it is laying out. All other Views are treated as readOnly. This adjustment happens automatically as the View hierarchy changes. A key consequence is that Views outside the current parent will not conform to any constraints they "participate" in. This avoids the issue of a layout for one container affecting the children of another.

val view1 = view {} val view2 = view {} val container1 = container { children += view1 layout = constrain(view1, view2) { v1, v2 -> v1.width eq v2.width // v2.width treated as immutable value (i.e. v2.width.readOnly) } }

Constraints are live

The constraint definitions provided when creating a layout are "live", meaning they are invoked on every layout. This makes it easy to capture external variables or use conditional logic in constraints. But it also means care has to be taken to avoid inefficient layouts. Below is an example of a layout that changes behavior based on a threshold variable.

val threshold = 100 container.layout = constrain(view1, view2) { v1, v2 -> // This will result in different constraints being applied dynamically as // container.width crosses the threshold when { container.width < threshold -> it.width eq parent.width / 2 else -> it.width eq parent.width } }

Removing constraints

Live constraints make it easy to support conditional logic, which covers most cases where constraints may need to be suppressed. But sometimes it is necessary to remove a set of constraints from a layout entirely. This is done in a very similar way to how we define constraints to begin with. Removing constraints is analogous to removing a handler. That is if we think of a constraint block and the list of Views it targets as a single handler that is invoked whenever a layout is triggered. In that way, removing the constraints requires specifying the same list of Views and the block when removing it.

val constraints: ConstraintDslContext.(Bounds, Bounds) -> Unit = { v1, v2 -> // ... } val layout: ConstraintLayout = constrain(view1, view2, constraints) // remove constraints applied to these views layout.unconstrain(view1, view2, constraints)
caution

The order and number of Views provided to unconstrain must match what was used during constrain. That is because changing order or arity would result in a different constraint effect than was applied.

You should store a reference to the constrain block to ensure it is identified as the same one used during register. Do not do the following

val layout = constrain(view1) { it.center eq parent.center } // DO NOT DO THIS layout.unconstraint(view1) { it.center eq parent.center }