View positioning and sizing
View bounds
Every View has an x
, y
position (in pixels) relative to its parent. This is exactly where the View will be rendered--unless it (or an ancestor) also has a transform
. Doodle ensures that there is never a disconnect between a View's position
, transform
and render coordinates.
Views get their positions and size constraints from their parent's Layout (or that of the Display
if they are top-level). A View's parent (via its layout
) dictates the min
and max
Size
the View can have. The View then picks a size within that range and shares it with the parent's layout
. This size is used as part of the layout
's placement and results in a final position ans size for the View.
This means you cannot directly specify a View's bounds, and can only suggest values using suggestPosition
, suggestSize
, suggestBounds
etc. These hints do not guarantee the View's bounds will update as expected, since that is governed by the process just described.
import io.nacular.doodle.core.Display
import io.nacular.doodle.core.view
fun positioning(display: Display) {
//sampleStart
val view = view {}
val panel = view {}
view.suggestX ( 0.0 ) // suggest move to [10, 0]
view.suggestPosition(13.0, -2.0) // suggest reposition to [13,-2]
display += panel // panel's position is 0,0
//sampleEnd
}
This demo shows how the pointer can be used to position Views easily. In this case, we use the Resizer
utility to provide simple resize/move operations. The Resizer
simply monitors the View
for pointer events and suggests updates to its bounds
accordingly. These suggestions take effect because the View's parent has no Layout, etc.
import io.nacular.doodle.core.View
import io.nacular.doodle.geometry.Rectangle
import io.nacular.doodle.utils.Resizer
fun resizer(view: View) {
//sampleStart
view.apply {
suggestBounds(Rectangle(100, 100))
Resizer(this) // monitors the View and manages resize/move
}
//sampleEnd
}
A View's preferred size
All Views have a preferredSize
property that they use when trying to determine what to adopt within the min
, max
constraints they receive from their parent. The default behavior uses the View's layout
to calculate a size or the latest size suggestion if no layout
is present.
This value is also used to derive the View's idealSize
; which is essentially a result of calling: preferredSize(Size.Empty, Size.Infinity)
.
You can override this behavior by providing your own lambda for this property.
Auditing a View's size
In addition to a customizable preferredSize
, Views can also "audit" their size using their sizeAuditor
. A View's sizeAuditor
is called whenever its size is being changed. The auditor is given the current size, the new size, and the min
, max
constraints the View has to fit into. It can then determine what final size the View should have. The value chosen is still coerced into the min-max range by the framework.
SizeAuditors makes creating Views with fixed aspect ratios much easier. And the framework even has an auditor that helps with this.
Using Layouts
A Layout
keeps track of a View
and its children and automatically arranges the children as sizes change. This happens (by default) whenever View's size
changes, or one of its children has its bounds
change. The View class also protects
its layout
property by default so it can encapsulate special handling if needed, but sub-classes are free to expose it.
This examples shows the use of
HorizontalFlowLayout
, which wraps a View's children from left to right within its bounds.
import io.nacular.doodle.core.container
import io.nacular.doodle.layout.HorizontalFlowLayout
fun horizontalLayout() {
//sampleStart
val container = container {}
container.layout = HorizontalFlowLayout() // Container exposes its layout
//sampleEnd
}
Doodle comes with several useful layouts, including one based on constraints. But you can easily create custom Layouts by implementing the Layout
interface or using the simpleLayout
utility function.
import io.nacular.doodle.core.Layout
import io.nacular.doodle.core.Layout.Companion.simpleLayout
import io.nacular.doodle.core.Positionable
import io.nacular.doodle.geometry.Rectangle
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.layout.Insets
fun example() {
//sampleStart
class CustomLayout: Layout {
override fun layout(views: Sequence<Positionable>, min: Size, current: Size, max: Size, insets: Insets): Size {
views.filter { it.visible }.forEach { child ->
child.updateBounds(Rectangle(/*...*/))
}
return current
}
}
// DSL for basic layout
simpleLayout { views, min, current, max, insets ->
views.filter { it.visible }.forEach { child ->
child.updateBounds(Rectangle(/*...*/))
}
current
}
//sampleEnd
}
Layouts are generally triggered whenever their monitored View changes size or has a child whose bounds change. This default behavior can also be customized for cases when a Layout wants to ignore certain changes. In fact, Layouts are asked whether they want to respond to size
changes in their container and bounds
for children.
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
.
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
}
}
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:
package constraints
import io.nacular.doodle.core.View
import io.nacular.doodle.layout.constraints.constrain
fun multiView1(v1: View, v2: View) {
constrain(v1, v2) { view1, view2 ->
//sampleStart
view1.width + 2 * view2.width eq parent.width - 10
//sampleEnd
}
}
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:
package constraints
import io.nacular.doodle.core.View
import io.nacular.doodle.layout.constraints.constrain
fun multiView2(v1: View, v2: View) {
constrain(v1, v2) { view1, view2 ->
//sampleStart
view1.width + 10 eq parent.width - 2 * view2.width
//sampleEnd
}
}
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() {}
}
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.
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) {
// 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
}
}
//sampleEnd
}
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.
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 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
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
It is possible to define constraints that conflict with each other. This result in an error since there is 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.
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
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 // 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, movable = false)
}
// use Layout that follows constraints to position items
display.layout = constrain(display.first(), center)
display.fill(White.paint)
}
override fun shutdown() {}
}
This results in the panel matching its parent's width whenever it is 200 or less.
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.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.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; suggestWidth(350.0); Resizer(this, movable = false).apply { directions = setOf(East ) } }
val panel2 = Panel(textMetrics, "2").apply { backgroundColor = Gray; suggestWidth( 50.0); Resizer(this ).apply { directions = setOf(East, West) } }
val panel3 = Panel(textMetrics, "3").apply { backgroundColor = Lightgray; Resizer(this, movable = false).apply { directions = setOf(West ) } }
display += container {
this += listOf(panel1, panel2, panel3)
suggestSize(width = min(400.0, display.width - 20), height = min(300.0, display.height - 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 strength 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, movable = false)
}
// use Layout that follows constraints to position items
display.layout = constrain(display.first(), center)
display.fill(White.paint)
}
override fun shutdown() {}
}
Readonly attributes
Sometimes it is necessary to refer to an attribute in a constraint without the risk of changing it. A good example of this trying to center a View
. The normal approach to doing this involves using the center
, centerX
, or centerY
attributes. But all of these are just short-hand that expand as follows. This means using them actually adds constraints to the width
and/or height
as well as top
and left
.
package constraints
import io.nacular.doodle.core.View
import io.nacular.doodle.layout.constraints.constrain
fun centerExpansions(v: View) {
constrain(v) { view ->
//sampleStart
view.center eq parent.center // =
// │
view.centerX eq parent.centerX // ◄─┘────┐
view.centerY eq parent.centerY // ◄─┘──┐ =
// = │
view.top + view.height / 2 eq parent.height / 2 // ◄────│─┘
view.left + view.width / 2 eq parent.width / 2 // ◄────┘
//sampleEnd
}
}
Readonly attributes can help you avoid these additional constraints:
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 + view.height.readOnly / 2 eq parent.centerY
view.left + view.width.readOnly / 2 eq parent.centerX
//sampleEnd
}
}
The use of readOnly
converts the attributes to constants that are evaluated whenever the constraints are evaluated. This means there are only 2 constraints defined for view
in this example, instead of 4.
Notice that it is possible to have a slightly stale value when using readOnly
. That's because the value is derived BEFORE the constraints are applied. Which means the source property could change if it is a part of some other constraint; leaving the readOnly
value behind.
To do this, you simply use the readOnly
property of the attribute:
View transforms
You can change the shape of a View using a linear transform. This will change the way the View is rendered to the screen, and can even change the size of the View. But a transformed View still retains the same bounds
, while its boundingBox
changes with the transform. This means layouts will continue to treat the View as though nothing about it changed.
Hit detection and other behavior work as expected for Views that have been transformed.
Changes to a View's transform will not trigger layout.