Observables
Ceramic has a powerful observable API thanks to the tracker library.
It allows you to know when a field has changed so you can update your display (or anything else) as a reaction to that change.
How to make a field observable
name
fieldimport ceramic.Entity;
import tracker.Observable;
/**
* A `Person` is an `Entity` that is made observable
* by implementing the `Observable` interface
*/
class Person extends Entity implements Observable {
/**
* An observable field
*/
public var name:String = null;
public function new() {
super();
// Track name change
onNameChange(this, nameChanged);
}
function nameChanged(name:String, previousName:String) {
trace('Name: $name (previous: $previousName)');
}
}
This is a simple example where:
-
We create a
Person
class that inherits fromEntity
, and more importantly implements theObservable
interface. -
Thanks to the
Observable
interface we implement, we can now use the@observe
meta on any instance field. That's what we do on thename
field to make it observable. -
Now that the
name
field is observable, it provides a newnameChange
event on the class, so we can useonNameChange()
to be notified when thename
field is modified. We do that in the constructor: ournameChanged()
method will be called every time thename
field changes.
Want to learn more about events? Check the events guide out!
- In the
nameChanged()
method of our class, we print the name so that we will add a line to the console output everytime the name changes.
Let's test this code!
Person
code// Create a person object
var person = new Person();
// Set its name to John
person.name = 'John';
...
// Later, update the name and set it to Ellen
person.name = 'Ellen';
This is what you should see in your console:
Name: John (previous: null)
Name: Ellen (previous: John)
That's it! You know how to create observable fields and get notified when they change.
Computed fields
What if you want to have a field that is computed from another observable field? For instance, we'd like to add a valid
field to our Person
object that tells us if the current object is valid or not. That is: does it have a valid name
field?
Let's assume a Person
object is valid if:
- The
name
field is not null - The
name
field has at least 2 characters - The
name
field has maximum 20 characters
This is of course just an example to illustrate the feature. A proper name validation would likely be a bit more advanced than that, using a regular expression, checking there is no line break etc...
We could create a custom getter that does all these checks to return true
or false
everytime we query it, but there is a better solution: computed fields.
A computed field is also an observable field, but is computed from other observable fields. Let's see how it looks like in practice:
valid
fieldimport ceramic.Entity;
import tracker.Observable;
/**
* A `Person` is an `Entity` that is made observable
* by implementing the `Observable` interface
*/
class Person extends Entity implements Observable {
/**
* An observable field
*/
public var name:String = null;
/**
* A computed field
*/
public function valid():Bool {
// Get our observable `name` value
var name = this.name;
// Do a few checks
if (name == null)
return false;
if (name.length < 2)
return false;
if (name.length > 20)
return false;
// Everything ok, return true
return true;
}
public function new() {
super();
// Track name change
onNameChange(this, nameChanged);
}
function nameChanged(name:String, previousName:String) {
trace('Name: $name (previous: $previousName)');
}
}
What did we do?
-
We added a computed field, which is written as a function with the
@compute
meta. In our code, we will read the field as a variable, as the function is only used as the compute function internally. -
Inside that compute function, we generate a boolean
true
orfalse
depending on whether the name fits our criterias or not. -
That means when we will read
person.valid
, the result will betrue
orfalse
depending on thePerson
, andPerson.name
data.
Let's test this:
// Create a person object
var person = new Person();
// Check if it becomes valid/invalid later
person.onValidChange(this, (valid, _) -> {
trace('Valid: $valid');
});
// Set the name to John
person.name = 'John';
...
// Later, update the name and set it to ?
person.name = '?';
This is what you should see in your console this time:
Name: John (previous: null)
Valid: true
Name: ? (previous: John)
Valid: false
As you can see, the valid
field acts like an observable field too: we can listen to the validChange
event and get notified when it changes, even when just the name
it depends on has changed. Pretty cool isn't it?
Computed fields can be useful to optimize your code when the values you need to resolve are expensive to evaluate. Indeed, Ceramic will run the computation methods only when needed, that is, when the observable values it depends on have changed. It is also good to note that if you never use some computed field in your code, it will not even be computed!
In the above examples, we implemented the Observable
interface so that the @observe
and @compute
metas are properly handled. If you want to add observable or computed fields in a Scene
subclass, know that you don't need to implement the Observable
interface because it is already implemented by the parent Scene
class already!
Autorun
Sometimes, you may want to run some code everytime a groupe of multiple observable and/or computed fields have changed. In that situation, you could listen to the {field}Change
event of each of the fields you depend on, but that could become tedious to maintain and require a lot of boilerplate code in the long run.
Instead, you can cover that use case with much less code using: autorun()
import ceramic.Scene;
import ceramic.Timer;
class MainScene extends Scene {
var person1:Person;
var person2:Person;
override function create() {
person1 = new Person();
person2 = new Person();
// Bind `printNames()` with an `autorun()`
autorun(printNames);
// Change names gradually
Timer.delay(this, 1.0, () -> person1.name = 'John');
Timer.delay(this, 2.0, () -> person2.name = 'Ellen');
Timer.delay(this, 3.0, () -> person1.name = '?');
}
function printNames() {
// Our autorun-bound method will be evaluated once,
// and everytime one of the observable/computed fields
// it has read is modified. In that case, it will
// run again if person1.valid, person1.name,
// person2.valid or person2.name have changed.
if (person1.valid && person2.valid) {
trace('Hello, ${person1.name} and ${person2.name}!');
}
else if (person1.valid) {
trace('Hello, ${person1.name}, and you!');
}
else if (person2.valid) {
trace('Hello, you, and ${person2.name}!');
}
else {
trace('Hello you two!');
}
}
}
What did we do this time?
-
We created two
Person
objects -
We bound the
printNames()
method with anautorun()
-
Then, we gradually changed the name of each person through time
-
In reaction, the bound
printNames()
method should be re-evaluated automatically when the names are changing.
This is what you should see in the console output:
Hello you two!
Name: John (previous: null)
Hello, John, and you!
Name: Ellen (previous: null)
Hello, John and Ellen!
Name: ? (previous: John)
Hello, you, and Ellen!
What is happening there?
In the printNames()
method bound by an autorun()
, Ceramic creates implicit bindings. Remember when we used onNameChange()
or onValidChange()
? Well, this is basically what an autorun()
does for you without having to write this code yourself. For instance, if, in the bound printNames()
method we read person1.valid
, it will automatically listen to the validChange
event of the person1
variable, and invalidate the autorun if later person1.valid
does change.
When an autorun()
function is invalidated, it runs again, then all observable and computed fields are read again and new implicit bindings are created.
So, in other words: an autorun-bound
function runs once when it is created, then runs again every time anything it observes changes.
The autorun()
method is available from any Entity
subclass, including Scene
and Visual
objects. It can be a powerful building block to create reactive UI or trigger actions automatically when some conditions are met. Think of this as a new versatile item in your tool belt!
This autorun API is quite similar to MobX Autorun.
unobserve()
/ reobserve()
Sometimes, you may want to run side effects from an autorun()
function that will read observable or computed values that you don't want to depend on. The unobserve()
and reobserve()
methods help you achieve that:
autorun()
autorun(() -> {
// Read the `valid` computed value.
// This will let the autorun
// observe the `valid` field
var valid = person.valid;
// Stop observing fields
unobserve();
// Reading `person.name` here won't have
// any effect to the autorun's bindings, even
// though the `name` field is observable,
// because the read is surrounded by unobserve/reobserve
if (valid) {
trace('Yay!');
trace('Name "' + person.name + '" is valid!');
}
else {
trace('Unfortunately...');
trace('Name "' + person.name + '" is invalid.');
}
// Resume observing fields
reobserve();
});
The final reobserve()
call is actually optional here because we didn't need to evaluate any code after and are just reaching the end of the autorun function. In that situation, it can be omitted as Ceramic will take care of auto-closing any unobserve
/reobserve
block at the end of an autorun function evaluation. If, however, you wanted to actually read another observable or computed field after logging person.name
in the above code, you could do so by keeping the reobserve()
call and write code just after.
Using unobserve()
and reobserve()
calls at the right places can be useful in some complex situations where you read a lot of values and want to execute side effects without creating an uncontrollable chain of reaction. This could be necessary if your side effects are modifying the values you are also observing. Surrounding some code with unobserve()
and reobserve()
will ensure it doesn't trigger unexpected behaviour from your autorun()
function.
That's all about observables
All in all, using observable and computed fields, in addition to autorun()
functions, can be very powerful to create advanced features with very little code.
Feel free to take a look at the elements plugin source code if you want to see a real use case taking advantage of these features!
Continue reading ➔ Systems