Features
New Cassowary based Constraint Layout
This release includes a new layout engine that will soon replace the current constraint based layout. This new implementation is based on the well known Cassowary algorithm and is therefore much more expressive and capable. The new implementation has a very similar API to the current one, so migration is less difficult. But there are key differences that mean current constraints won't always translate exactly. The new API is located in the io.nacular.doodle.layout.constraints
package.
Basic Usage
Constraints are created using the new constrain
builder found in io.nacular.doodle.layout.constraints
. It takes a list of Views and a constraint block with rules for how those Views are related to each other and their parent. Each View is mapped to a Bounds
within the constraint block. These contain properties that let you manipulate the View's bounds
.
constrain(view1, view2, view3) { v1, v2, v3 ->
// constraints defined here
}
Constraints are now specified using equations and inequalities. These are written as expressions that contain a single operator to indicate equal (eq
), less-than-or-equal (lessEq
), and greater-than-or-equal (greaterEq
). The layout will solve the set of equations and assign values for each of the properties within each relationship.
constrain(view1, view2, view3) { v1, v2, v3 ->
v1.right eq parent.right - 10
v2.right lessEq v1.left - 10
v1.height + v2.height + v3.height eq parent.height
}
Expressions with eq
are similar to what the legacy constraint system offers. But there are still very important differences (see below). While lessEq
and greaterEq
are entirely new ways of expressing relationships. They provide a much easier way to express boundary conditions than the legacy system.
This example shows how you could keep a view's horizontal bounds confined to a region within its parent while still letting it move or scale.
constrain(view) {
it.left greaterEq 10
it.right lessEq parent.right - 10
}
Differences Compared to Legacy
Constrain Blocks Are Live
The new constraint builder looks a lot like the legacy one: a list of Views with a configuration block. But there is a major difference. The new layout will actually invoke that provided block on every layout, while the legacy implementation only calls its block once, treating it as data rather than an active handler.
This makes the legacy layout a lot more cumbersome and less intuitive. That is because it is not clear at all that the constraint lambda does not behave like others and will only be invoked once. This fact also means legacy constraints require extra hacks to capture external variables that update over time. It is also impossible to use control logic with the legacy system to dynamically change constraints; this is no longer an issue.
Legacy
constrain(view) {
it.width = parent.width - capturedValue // capturedValue is only read once when the layout is constructed
}
constrain(view) {
it.width = parent.width - { capturedValue } // an inner lambda is needed to keep capturedValue updated
}
container.layout = constrain(view1, view2) { v1, v2 ->
// This will only be evaluated once when the Layout is constructed,
// which means the constraint won't flip as container.width changes
when {
container.width < 100 -> it.width = parent.width / 2
else -> it.width = parent.width
}
}
New
constrain(view) {
it.width eq parent.width - capturedValue // capturedValue is read on every layout as expected
}
container.layout = constrain(view1, view2) { v1, v2 ->
// This will result in different constraints being applied dynamically as
// container.width crosses 100
when {
container.width < 100 -> it.width eq parent.width / 2
else -> it.width eq parent.width
}
}
Views Don't Need To Be Siblings
You cannot constrain Views that are not siblings in the legacy implementation. This is intended to avoid unintuitive behavior, but it made constraints more cumbersome to use, since it forces all Views to be in the same container before configuration. The new implementation takes a different approach. It allows you to configure any set of Views, regardless of their hierarchy. But, it only updates 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 the constraints. This avoids the issue of a layout for one container affecting the children of another.
Legacy
val view1 = view {}
val view2 = view {}
val container1 = container{
children += view1
layout = constrain(view1, view2) { v1, v2 ->
v1.width = v2.width // <======================= Error since view2 does not share the same parent ❌
}
}
New
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) ✅
}
}
Relationships Are Bidirectional
Legacy constraints are all defined using assignment, making them inherently unidirectional. In the example below, v1
would get its width from v2
, but v2
's width would not be modified.
Legacy
constrain(view1, view2) { v1, v2 ->
v1.width = v2.width // v2.width would not be modified
// v2.width = v1.width is not the same as above
}
The new implementation produces bidirectional constraints by default. This means expressions can be written like mathematical equations, where the order of terms is often (but not always) unimportant. It is still possible to create unidirectional relationships using readOnly
on a property or an entire expression.
New
constrain(view1, view2) { v1, v2 ->
v1.width eq v2.width // both v1.width and v2.width can be changed to ensure they are equal
// v2.width eq v1.width is the same as above
}
constrain(view1, view2) { v1, v2 ->
v1.width eq v2.width.readOnly // v2.width won't be modified
(parent.height - v2.height).readOnly eq v1.height // neither parent.height nor v2.height will be modified
}
Bidirectionality applies to parent constraints as well (if the View is not top-level). So the following constraint might actually update the parent's width.
constrain(view) {
it.width eq 100
it.width eq parent.width // parent.width would be modified to ensure the equality
}
This can be prevented by using readOnly
.
Constraints Added/Removed Symmetrically
The legacy constraint system allows single Views to be unconstrained. This is helpful when you need to let a View move more freely, or when it is removed from its parent. The API for this is as follows:
Legacy
val layout: ConstraintLayout = constrain(view1, view2) { v1, v2 ->
// ...
}
layout.unconstrain(view2) // all associated constraints removed
The new API changes this approach to avoid confusion now that constraint blocks are invoked repeatedly during layout. This avoids the case where some subset of the constraints in a block are "removed", even though the block is still being invoked. This means constraint blocks behave a lot more like event handlers.
New
val constraints: ConstraintDslContext.(Bounds, Bounds) -> Unit = { v1, v2 ->
// ...
}
val layout: ConstraintLayout = constrain(view1, view2, constraints)
// layout.unconstrain(view2) // no longer available
layout.unconstrain(view1, view2, constraints) // remove all constraints in a block
Constraints Have Strengths
The new API allows you to define relationships that can be in conflict with each other. Such situations result in an error since they have no clear solution. API also provides a way to resolve such cases with a relative priority or strength of constraints. 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.
constraint(view) {
it.left eq 0
it.width greaterEq 100
(it.right eq parent.right) .. Strong // constraint will be broken if needed since the one above is Required
}
- New
singleChoiceList
for choosing a single item like radioList
offers
- New
optionalSingleChoiceList
for choosing zero or one item like optionalRadioList
offers
APIs
Always
and WhenInvalid
RequiredIndicatorStyle
s (used for form fields) no longer require an explicit string when constructed, and will default to using "*"
labeled
Form fields can now change the vertical spacing between the label and nested field via a new defaultLayout
method that takes an optional spacing
parameter.
- Added new
on
helper for KeyListener
s
Render Performance
- Avoid unnecessary List copy in View.child(at)
- Avoid display tree traversal to determine if a View is displayed
- No longer updating GraphicsSurface for Views with no area.
- Made SquareMatrix application to single Point more efficient since it is the more common case.
Fixes | Improvements
- General
- Bug in
BasicTreeBehavior
when Tree width is less than node indent
- Bug in
TreeRow
that affected icon placement
Tree
now allows its root node to expand/collapse
- Bug in
Tree
height when root node visible
- Rendering issue in
Tree
- Invalid path handling in
SimpleTreeModel
- Hover rendering for radio buttons in BasicTheme
- Minor text inconsistency in
RequiredIndicatorStyle
for some form labels
- Bug in
TextInput
selection updating when range deleted
- Bug in
TextInput
paste updating when range deleted
- Bug in
ClosedRange.intersect
- Bug in
ClosedRange.intersects
- Bugs in native TextField behavior text selection
- Browser
ImageLoader
now queues up requests for loading items and resumes/fails them correctly when it finishes/errors
- Now using
pagehide
event instead of unload
to shut down an app
- Suppressing default tap highlight on Webkit.
- Improve handling of javascript file being loaded within header.
- Native ScrollPanel bug in edge case
Source code(tar.gz)
Source code(zip)