Skip to main content

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).

tip

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.

tip

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.

tip

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

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 }
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

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.

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.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.

warning

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.

info

Changes to a View's transform will not trigger layout.

tip

boundingBox == bounds when transform == Identity.