This is more a of a general issue to discuss basic architecture and future improvements to a substantial area of the application.
We currently have in place a base abstract class called CanvasItem which we use to build all the different items on top of it (Rectangle, Ellipse, Image, etc.).
The problems for this approach are starting to show as we implement more and more features and we make the interactions and transformations with the Canvas more complex.
These are few highlights of what we stumbled upon so far:
How can we improve this?
The Transform panel allows to edit item's attributes (x, y, width, height, etc) through input fields.
These attributes can be also modified directly on the canvas, by moving and resizing the selected items.
These fields need a bidirectional binding with the specific attribute (eg. the X input is binded to the item's X attribute), so when one changes, the other follows accordingly, and proper signals are emitted to allow the UI to react accordingly (eg. the selection bounds follow, the artboards get updated, etc.).
Since items can be manipulated directly from the Canvas, we need to deactivate some of these signals and bindings to not fall into an infinite loop of, the item gets updated, the input field receives the signal which updates the value, the input field emits the signal because the value changed, the item receives the signal to updated the value, the item emits the signal with the new value, the input receives the signal... and it goes on and on and on.
We solved this issue by implementing some conditions and by unbinding properties at the right time, but this solution causes some problems:
These wouldn't be critical issues if we assume the user mainly interact with the canvas and changes those values sporadically, maybe a few pixels at a time, but we can't assume that.
Sometimes the user might focus on the X input and hold down the arrow up key, causing hundreds of triggers consecutively.
How can we improve this?
I think the problems you describe here are all the result of the one thing:
"Two-way data binding is the Devil."
Ember.js used to rely on two-way data binding until they figured this out and eventually replaced it with what they call "Data Down, Actions Up" [1] [2].
Effectively, this means having a data binding that goes in one direction ("down") and sending messages ("actions") in the other direction ("up") to trigger data updates.
Consider a setup with the following components:
_---App---_
/ \
| |
TransformPanel Canvas
|
|
Shape
The App component has a bunch of data (the global app state) and passes some of this data on to the TransformPanel and Canvas components. This data is bound in one direction:
So, whenever the data is updated in App, that change is immediately propagated down to TransformPanel and Canvas. However, the latter components can mutate the data as they wish, it will not affect App. They effectively have their own copy of the data.
Conversely, if a change in a lower component should affect a component further up, an action (basically an event, optionally with data) can be sent upwards. Upon receiving this action, the relevant component can decide to update any data, triggering a flow back down.
For example, if the x coordinate of a shape is changed in TranformPanel, the following would take place:
TransformPanel sends an action up to App (something like x_changed(new_value: 42))App receives the action and updates the x coordinate of the relevant shape in the global state.Canvas.Canvas sends the received x coordinate down to Shape.Shape redraws itself to match the new state (data) it has.This approach has a number of advantages:
Whereas a two-way binding between TransformPanel and Shape can lead to conflicts (e.g. What if for some reason x is 1 in TransformPanel and x is 2 in Shape? Who wins?), DDUP forces you to put shared state in a single "higher" location so at every point in time the application state is unambiguous.
[1] https://dockyard.com/blog/2015/10/14/best-practices-data-down-actions-up
[2] https://discuss.emberjs.com/t/rationale-for-data-down-actions-up/13830/2
Here we go:
The canvas problem:
It seems like this is an issue of inheritance vs composition. While inheritance is really nice for simple implementations, it starts to fall short when it comes to complex systems like here. The more objects you implement, the more you have to fight with classes that you inherit, trying to find similarities and then somehow adapting them to fit each other which creates a mess. Which is why e.g many game engines like Unity or Unreal Engine prefer not to do that and go the composition way. Instead of inheriting a base object, they define it as just a container of "components". Each component can do something different with the parent base. I think this would be nicest to illustrate with an example:
public class Component : Object {
public unowned CanvasItem item { get; construct set; }
}
// Implements transform for a CanvasItem
public class Transform : Component {
public double x { get; set; }
public double y { get; set; }
public Transform (CanvasItem item) {
this.item = item;
}
// ...
}
// Implements rectangle for a CanvasItem
public class RectangleRenderer : Component {
public double width { get; set; }
public double height { get; set; }
public RectangleRenderer (CanvasItem item) {
this.item = item;
}
public void render () {
var transform = get_component<Transform> (typeof (Transform));
// render rectangle at (transform.x, transform.y)
}
// ...
}
public class CanvasItem : Goo.CanvasItemSimple, Goo.CanvasItem {
public Gee.ArrayList<Component> components { get; construct; }
construct {
components = new Gee.ArrayList<Component> ();
components.add (new Transform (this));
}
public T? get_component<T> (T type) {
foreach (unowned Component comp in components) {
if (comp.get_type () == type) {
return comp;
}
}
return null;
}
}
As you can see here, the CanvasItem now only holds components that make it up which allows for cleaner implementations of all things. If a component requires access to another, it simply calls get_component on the parent item to retrieve it. Whether or not kind of system is possible to implement with Goo, I don't really know.
@jtrees «Data Down, Actions Up» is to me a simple and safe way of property binding propagation. Thanks for sharing.
Just a note about object composition – it might be helpful to get inspired by the Sketch file format schema, which gives you an idea of how Sketch composes items: https://github.com/sketch-hq/sketch-file-format
@jtrees thank you so much for the detailed explanation.
I was looking at the "Data Down, Actions Up" a while ago, but then I decided to go with the 2 way bindings because it made the code leaner. But indeed, now that approach doesn't really work and doesn't scale well for our situation.
I'll create a branch to refactor the transform panel with this approach. Would it be okay for you if I ping you on that upcoming PR so we can continue the conversation, and maybe you could take a quick look at the code to see if I'm not messing anything up?
@donadigo ah, that's exactly what I was looking for, thank.
I think this is extremely doable as Goo.CanvasItems can be easily extended and need very few arguments to be properly initialized. I suspect it won't really matter how we handle the various components as long as we then apply the transformed attributes to the base canvasitem object.
I'll start working on this probably right after the first alpha release, as it doesn't affect the users but it's only an architecture refactor.
Thank you all for the suggestions and insights, I love Open Source!
Would it be okay for you if I ping you on that upcoming PR so we can continue the conversation, and maybe you could take a quick look at the code to see if I'm not messing anything up?
Sure!
Most helpful comment
The Transform Panel problem
I think the problems you describe here are all the result of the one thing:
"Two-way data binding is the Devil."
Ember.js used to rely on two-way data binding until they figured this out and eventually replaced it with what they call "Data Down, Actions Up" [1] [2].
Effectively, this means having a data binding that goes in one direction ("down") and sending messages ("actions") in the other direction ("up") to trigger data updates.
DDAU Example
Consider a setup with the following components:
The
Appcomponent has a bunch of data (the global app state) and passes some of this data on to theTransformPanelandCanvascomponents. This data is bound in one direction:So, whenever the data is updated in
App, that change is immediately propagated down toTransformPanelandCanvas. However, the latter components can mutate the data as they wish, it will not affectApp. They effectively have their own copy of the data.Conversely, if a change in a lower component should affect a component further up, an action (basically an event, optionally with data) can be sent upwards. Upon receiving this action, the relevant component can decide to update any data, triggering a flow back down.
For example, if the x coordinate of a shape is changed in
TranformPanel, the following would take place:TransformPanelsends an action up toApp(something likex_changed(new_value: 42))Appreceives the action and updates the x coordinate of the relevant shape in the global state.Canvas.Canvassends the received x coordinate down toShape.Shaperedraws itself to match the new state (data) it has.Benefits
This approach has a number of advantages:
Whereas a two-way binding between
TransformPanelandShapecan lead to conflicts (e.g. What if for some reason x is 1 inTransformPaneland x is 2 inShape? Who wins?), DDUP forces you to put shared state in a single "higher" location so at every point in time the application state is unambiguous.[1] https://dockyard.com/blog/2015/10/14/best-practices-data-down-actions-up
[2] https://discuss.emberjs.com/t/rationale-for-data-down-actions-up/13830/2