Skip to main content

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 c and a eq c - b are 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 }
caution

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.

How is this different from 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.
  • prefers suggests a value to the solver without constraining anything, so it never narrows that range. ideal is the constant that stands for the size a View would pick for itself.
  • Give a constraint a Strength (Required by default) to let the solver break it gracefully instead of erroring when constraints conflict.
  • center/centerX/centerY expand into constraints on width/height too.
  • Mark an attribute readonly to 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.