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.
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 View
s 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() {}
}
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)
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 }