I'm bringing this up now as it sounds like MC is going to get a new renderer.
Description
At the moment, Layout recomputation is handled through an isDirty
/wasDirty
cycle, which has some drawbacks.
The first is that it limits the number of dependent values that may exist in MC layouts. I believe the current cap is 10. This cap could be removed, but then layouts could hang forever in an infinite loop, and since the flags are handled imperatively, there's no apparent way for the system to detect the issue.
On top of this, it's very difficult if not impossible to change the flow of data in different layouts. For example, an image needs to maintain its aspect ratio, so the width and height must depend on each other. An image layout may want to set the width and the parent, then the width of the first child, then the height of the first child, then the position and width of the second child, etc, before eventually setting the height of the parent. This flow is difficult to do, however, if layouts assume by default that positions flow down and dimensions flow up.
Proposed solution
Build a reactive system for layouts that automatically tracks dependencies and hides the dirty
/clean
flag, similar to SolidJS. So, there could be a computed property with
// illustrative code
layout(() => {
this.children().forEach(child => {
child.left(() => this.left() + 5);
child.width(() => this.width() - 10);
});
});
Whenever this.width
changes, child.width
will be marked as dirty
. During render, whenever the render function asks for the width of the component, this.width
will pull the necessary values to generate itself, which will cause these properties to be marked as clean. No need for wasDirty
as it's not a loop.
An issue arises when relationships change, however. If a parent component sets a property on its children, child.width(() => this.width() - 10)
, and then the array of children (this.children()
) changes, the old width settings are outdated, and should be removed in order to avoid "zombie" bugs. It's not enough to just let the new writes stomp the old writes, since children may have disappeared from the children array. Therefore, the layout
function should track writes as well as reads in order to be able to remove them.
This is different from how most reactive systems work. Usually, a property is given a function to compute itself, which is then used to detect the dependent values.
const B = reactive(() => C() + D())
// B depends on C and D
Here, the system is detecting the pull, C()
, during computation to discover the dependencies C
and D
. The dependent value B
is explicit. In the case of layout
, the function both reads and writes values. It reads from this.children
, and it writes to the children's width
. The written value is then also reactive, as the child's width depends on the parents width. layout
has created a reactive function that writes reactive functions, which is necessary for a layout engine, especially one robust enough to handle containers, arrows, and pins.
This does make tracking dependencies harder, as you may not detect a conditional write.
layout(() => {
if (this.width() < 10) this.child.width(5) // not always detected
})
A system built this way could not always rely on a naive "push dirty
, pull clean
" setup, but would instead need to perform a best guess recomputation of values while occasionally throwing away inconsistent results.
Considerations
Motion Canvas doesn't need the best, most feature-rich layout system ever designed, but we can still pick its trade-offs. In particular, Motion Canvas runs supervised, where conventional user interfaces do not. If a Motion Canvas component clips its children, hiding some important information, the developer can just change the layout. This absolves MC of the need for certain conventional features like wrapping overflow elements. That being said, there are still some issues that could heavily affect MC's learning curve.
bidirectional data flow
One issue with the proposed approach is that not all layouts have a clear direction of data flow. Many components want to maintain an aspect ratio, which means either their width or height can be derived from the other. This design can show up in many places. For instance, when I set the width and left side position of a component, the right is derived from the left, but if I set the width and right side, the left is derived from the right. You can try to avoid this issue by always choosing to set one property, for instance,
// right align children (for argument, assume only `left` and `width` exists)
child.left(() => {
const right = this.left() + this.width();
return right - child.width();
})
but then anyone who creates a layout has to learn which properties exist and which don't, which could change given different child components, parent components, or settings, making compatibility an issue.
One option to fix this is to allow for bidirectional data flow. Like with algebraic structures.
this.left(this.right().sub(this.width()));
This would be interpreted as 0 = child.left - child.right + child.width
. Then, if one of these values is requested after the other two values exist, the requested value would be computed from the others. Most users would never have to deal with this feature; it would mostly only be used by the base classes to set up convenient interrelations.
values can depend on themselves (sort of)
Occasionally, you'll have computed values that depend on themselves. Like a parent with vertically stacked children where the children stretch to match the widest child. First, the parent checks the width of the children to find the widest child. Then, the parent sets the width of the other children to match, but then the width of the children depends upon itself. This happens on the web in the Table and Flex layouts, and it would be difficult to pull off with the solution proposed above. I believe that it would also be difficult to pull off in the current system aswell, though.
One option to fix this is to use multiple values for dimensions, like fitWidth
and width
. This leaves users with more to learn and must be planned out in advance, but it's the simplest solution to the issue.
Another solution, however, is to just allow values to depend on themselves once, as long as the dependency happens in the same layout
.
layout(() => {
this.width(() => {
return this.children.reduce((max, child) => Math.max(max, child.width()), 0);
}); // first statement, given lowest priority
this.children.forEach(child => child.width(2, () => this.width())) // second statement, given higher priority
});
The layout engine would then allow the width to be set twice, with the layout
the read/writes running after the first setting. I'm oversimplifying both my code and my explanation here, but I think it captures the idea.