Constraints
Back on Positioning Views, you got acquainted with the general Layout interface. Now let's meet Doodle's favorite implementation of it: a constraint-based Layout, where instead of computing positions yourself, you write equations describing how Views should relate to each other, and hand the arithmetic off to Doodle.
Every equation takes one of these three forms (attribute is a position, width, or height):
package constraints
import io.nacular.doodle.core.View
import io.nacular.doodle.layout.constraints.constrain
fun basicConstraints(constant1: Double, constant2: Double, v1: View, v2: View) {
constrain(v1, v2) { view1, view2 ->
//sampleStart
view1.left eq constant1 * view2.left + constant2
view1.top lessEq constant1 * view2.height + constant2
view1.width greaterEq constant1 * view2.bottom + constant2
//sampleEnd
}
}
Let's put that to work: two panels, laid out entirely with constraints, the first claiming the left third of its parent and the second filling the rest.
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.width / 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() {}
}
constrain(...) takes the Views you want to position and a lambda defining the equations — that's really the whole shape of it. A couple of tricks make these equations more flexible than they first appear:
- More than one View can share an equation:
view1.width + 2 * view2.width eq parent.width - 10. - Either side can be rearranged, so long as you flip signs correctly —
a + b eq canda eq c - bare the same constraint as far as Doodle's solver is concerned.
Constraints are live
Here's a neat detail: the equations you write get re-evaluated on every layout pass, not just once. That means conditionals and captured outside variables work perfectly well inside them:
package constraints
import io.nacular.doodle.core.Container
import io.nacular.doodle.core.View
import io.nacular.doodle.layout.constraints.constrain
fun basicConstraints(container: Container, view: View) {
//sampleStart
val threshold = 100
container.layout = constrain(view) {
// container.width crossing the threshold changes which constraint applies, live
when {
container.width < threshold -> it.width eq parent.width / 2
else -> it.width eq parent.width
}
}
//sampleEnd
}
Being live is exactly what makes this convenient — just keep in mind that a constraint block re-runs on every layout, so leave expensive work out of it.
Removing constraints
Since a constraint block behaves like a live handler rather than a one-off action, removing one works the same way removing any handler does: hand back the exact same Views and block you registered with.
package constraints
import io.nacular.doodle.core.Container
import io.nacular.doodle.layout.constraints.Bounds
import io.nacular.doodle.layout.constraints.ConstraintDslContext
import io.nacular.doodle.layout.constraints.ConstraintLayout
import io.nacular.doodle.layout.constraints.constrain
import viewcreation.view1
import viewcreation.view2
fun removal(container: Container) {
//sampleStart
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)
//sampleEnd
}
The Views passed to unconstrain must match constrain's in order and count, and the block itself must be the same reference — not just an equivalent-looking lambda. Store it in a variable, like constraints above, rather than writing it inline twice.
package constraints
import io.nacular.doodle.core.Container
import io.nacular.doodle.layout.constraints.constrain
import viewcreation.view1
fun badRemoval(container: Container) {
//sampleStart
val layout = constrain(view1) { it.center eq parent.center }
// DO NOT DO THIS
layout.unconstrain(view1) { it.center eq parent.center }
//sampleEnd
}Constraint strength
Conflicting constraints are a real possibility, and by default a conflict is treated as an error — fair enough, since there's no single correct answer to fall back on. Strength gives you a way out: every constraint is Required (the strictest setting) unless you say otherwise, and the solver is free to bend a lower-strength constraint in favor of a higher one.
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.TextMetrics
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.drawing.stroke
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, Black.stroke(2.0))
}
}
display += container {
this += panel
suggestSize(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 strength Strong // yields to the width constraint above when they conflict
it.height eq parent.height
}
//sampleEnd
render = { rect(bounds.atOrigin, fill = Gray.paint) }
// drag to see the panel cap out at 200 wide
Resizer(this, movable = false)
}
// use Layout that follows constraints to position items
display.layout = constrain(display.first(), center)
display.fill(White.paint)
}
override fun shutdown() {}
}
Here, right eq parent.right would ordinarily stretch the panel to fill its container — but it's marked Strong, one notch below the default Required, so it steps aside whenever the container narrows past 200px and the width lessEq 200 constraint would otherwise be broken.
Let's make it a little more realistic: three panels, where the middle one moves and resizes, while the others yield gracefully when space gets tight.
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.height
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.layout.constraints.Strength.Companion.Required
import io.nacular.doodle.layout.constraints.center
import io.nacular.doodle.layout.constraints.constrain
import io.nacular.doodle.layout.constraints.minus
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 inset = 5
val panel1 = Panel(textMetrics, "1").apply { backgroundColor = Pink }
val panel2 = Panel(textMetrics, "2").apply { backgroundColor = Gray }
val panel3 = Panel(textMetrics, "3").apply { backgroundColor = Lightgray }
with (panel2) {
suggestX (inset + 130.0)
suggestWidth( 50.0)
Resizer(this).apply { directions = setOf(East, West) }
}
display += container {
this += listOf(panel1, panel2, panel3)
suggestSize(width = min(400.0, display.width - 20), height = min(300.0, display.height - 20))
//sampleStart
layout = constrain(panel1, panel2, panel3) { p1, p2, p3 ->
p1.top eq inset
p1.left eq inset
p1.bottom eq parent.bottom - inset strength Required - 1
p2.top eq p1.top
p2.width prefers min(p2.width.value, parent.width.value - (p1.width.value + 2 * inset))
p2.left prefers p2.left.value
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 + readonly(p2.width) + p3.width eq parent.width - 2 * inset strength Required - 1
}
//sampleEnd
render = { rect(bounds.atOrigin, fill = BlueColor.paint) }
// helper to resize container
Resizer(this, movable = false)
}
// use Layout that follows constraints to position items
display.layout = constrain(display.first(), center)
display.fill(White.paint)
}
override fun shutdown() {}
}
Ranges and preferences
Here's a something in how constraint Layouts work that's worth understanding properly, because it brings them in line with every other Layout. Back on Positioning Views, you saw that a View's size comes out of a negotiation: the parent proposes a minimum and maximum, and the View picks a size somewhere in between. A constraint Layout takes part in that negotiation the same way. Your constraints define the range of sizes each View is allowed to have — and the View stays free to choose within it, instead of being pinned to a single value.
:::note Coming from an earlier version? Constraint Layouts used to pin each View's size range to a single value, which left a View with no say in its own size. If you're upgrading, that behavior has changed. :::
Two tools let you shape that negotiation without taking the choice away from the View: the prefers keyword, and the ideal constant.
prefers: a suggestion, not a constraint
package constraints
import io.nacular.doodle.core.container
import io.nacular.doodle.core.view
import io.nacular.doodle.layout.constraints.constrain
import io.nacular.doodle.layout.constraints.ideal
fun prefersIdeal() {
container {
+ view { }
layout = constrain(children.first()) { view ->
//sampleStart
view.width prefers ideal
//sampleEnd
}
}
}
prefers hands the solver a suggested value for an attribute. The idea comes straight from Cassowary, the algorithm that powers this Layout, where a suggestion is a hint about where a value should settle rather than an equation the solver is obliged to satisfy. That distinction is the whole point: because a suggestion isn't a constraint, it doesn't narrow the range of sizes a View can take. It only nudges where in that range the View lands. And if a suggestion can't be honored alongside your constraints, the constraints win.
strength Weak?A weak constraint is still an explicit constraint — just one the solver is willing to break when it has to. prefers isn't a constraint at all. Reach for strength when you want to rank competing requirements against each other; reach for prefers when you want to say how you'd like something to turn out without ruling anything out.
ideal: the size a View would pick for itself
ideal is a new constant that stands for the size a View would ideally choose for itself in the dimension you use it — the same notion of a preferred size that you met on Positioning Views. So view.width prefers ideal reads just the way it sounds: "make this View as wide as it would like to be, if there's room."
Putting them together
Combine the two with an ordinary constraint, and you get a View with a firm height and a flexible width:
package constraints
import io.nacular.doodle.core.container
import io.nacular.doodle.core.view
import io.nacular.doodle.layout.constraints.constrain
import io.nacular.doodle.layout.constraints.ideal
fun sizeRange() {
container {
+ view { }
layout = constrain(children.first()) { view ->
//sampleStart
view.height eq 100 // Constraint Pins height: min and max are both 100
view.width prefers ideal // Suggestion Leaves width's range open, and nudges where in it the View lands
// nothing else on width: it can be anything from 0 up to unbounded
//sampleEnd
}
}
}
Here's what each statement contributes to the range the View is allowed to pick from:
Together, they let the View select a size between a minimum of Size(0, 100) and a maximum of Size(Infinity, 100) — free to choose its own width, with its height held at exactly 100.
Referring to a value without changing it
Some constraints are secretly shorthand for several others. center is the clearest example — reach for center, centerX, or centerY, and you're not just constraining a position, you're quietly pulling width and height into the equation too:
package constraints
import io.nacular.doodle.core.View
import io.nacular.doodle.layout.constraints.constrain
fun centerExpansions(v: View) {
constrain(v) { view ->
//sampleStart
// this one constraint...
view.center eq parent.center
// ...is shorthand for these two...
view.centerX eq parent.centerX
view.centerY eq parent.centerY
// ...which are themselves shorthand for these, involving width and height:
view.top + view.height / 2 eq parent.height / 2 // what centerX really means
view.left + view.width / 2 eq parent.width / 2 // what centerY really means
//sampleEnd
}
}
So a single view.center eq parent.center quietly leaves width, height, top, and left all up for grabs by the solver. If you'd rather just refer to one of those values without letting the solver touch it, mark it readonly:
package constraints
import io.nacular.doodle.core.View
import io.nacular.doodle.layout.constraints.constrain
fun readOnly(v: View) {
constrain(v) { view ->
//sampleStart
view.top + readonly(view.height) / 2 eq parent.centerY
view.left + readonly(view.width ) / 2 eq parent.centerX
//sampleEnd
}
}
This defines only 2 constraints instead of 4 — height and width are read as constants at the moment the constraints run, rather than becoming variables the solver can change.
Recap
- Constraint equations relate one or more Views' bounds to each other, using equalities and inequalities.
- They're re-evaluated on every layout pass, so conditionals and captured variables work naturally — just avoid expensive work inside one.
- Remove constraints with
unconstrain, passing the exact same Views and block reference used to create them. preferssuggests a value to the solver without constraining anything, so it never narrows that range.idealis the constant that stands for the size a View would pick for itself.- Give a constraint a
Strength(Requiredby default) to let the solver break it gracefully instead of erroring when constraints conflict. center/centerX/centerYexpand into constraints onwidth/heighttoo.- Mark an attribute
readonlyto reference its value without letting the solver adjust it.
Next: on to User Input to make your Views actually respond to the pointer and keyboard.