Tabris Decorators Part 4: Event Handling
NOTE: This article is about Tabris 2.x. Some information is outdated when targeting Tabris 3.x.
In this article, we will cover the new event handling API of the Tabris.js framework provided by the tabris-decorators Tabris.js. It is a part of a blog post series dedicated to that extension. Tabris-decorators make developing mobile apps with Tabris.js 2.x and TypeScript more convenient. It features data binding capabilities, enhanced event handling, and dependency injection, all explicitly designed for the Tabris.js mobile framework. To use it the npm module must be installed separately.
It’s recommended to read the previous posts in this series, though you may skip the first one.
- Tabris Decorators Part 1: Intro to TypeScript Decorators
- Tabris Decorators Part 2: Introducing Tabris Components
- Tabris Decorators Part 3: Data Binding Basics
In this chapter we take a look at the event handling features. Unlike data binding, event handling is already built into Tabris.js. However, this API was designed with JavaScript in mind and is a bit clumsy to use in TypeScript. With tabris-decorators
we offer an abstraction layer that is not only TypeScript optimized, but also adds a lot of convenient features on top of it.
For the most part this improved event system is independent from decorators and TypeScript. For that reason it will (with minor changes) be moved to the
tabris
core module in Tabris.js 3.0. In fact, it is already available in the beta 1 release. The old event API will still be supported.
Previously…
We continue with our example component LabeledInput
. As of now it consists of a TextView
bound to the labelText
property of LabeledInput
and a TextInput
bound do text
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
import { Composite } from 'tabris'; import { bind, component, property, ComponentJSX } from 'tabris-decorators'; @component export default class LabeledInput extends Composite { @bind('#input.text') public text: string; @property public labelText: string; private jsxProperties: ComponentJSX<this>; constructor(properties: Partial<LabeledInput>) { super(properties); this.append( <widgetCollection> <textView id='label' height={32} centerY={0} bind-text='labelText' font='20px'/> <textInput id='input' left='prev() 12' width={250} font='20px'/> </widgetCollection> ); } } |
What we are still missing is the application’s ability to react to user inputs.
Introducing @event
With the @event
decorator this is just one line:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import { Composite } from 'tabris'; import { bind, ChangeListeners, component, event, property } from 'tabris-decorators'; @component export default class LabeledInput extends Composite { @bind('#input.text') public text: string; @event public readonly onTextChanged: ChangeListeners<string>; @property public labelText: string; private jsxProperties: ComponentJSX<this>; // remaining class... } |
And now we can do this:
1 2 3 |
labeledInput.onTextChanged(ev => { console.log(ev.value); }); |
Or this:
1 2 3 4 |
<LabeledInput id='input' labelText='First Name:' text='Jane' onTextChanged={ev => console.log(ev.value)}/> |
How it works
The @event
decorator creates a function of the type ChangeListeners
and attaches it to the component instance. This function allows registering event listeners in a type-safe way. The type of the event that the listeners are registered for is derived from the name of the property the @event
is attached to:
onTextChanged
registers listeners fortextChanged
events.onFooChanged
registers listeners forfooChanged
events.onBar
registers listeners forbar
events.
The change event itself is triggered by the property setter that the @bind
decorator generated. It too derives the matching event name from the name of the property. So to listen to change events of a two-way binding you only need to have the @event
decorator on a field with a name and type that match that of the bound property:
Properties decorated with @property
also generate change events whenever their value changes. Therefore we could also add the @event
for labelText
:
1 2 3 4 5 6 7 8 9 10 11 12 |
@component export default class LabeledInput extends Composite { @bind('#input.text') public text: string; @event public readonly onTextChanged: ChangeListeners<string>; @property public labelText: string; @event public onLabelTextChanged: ChangeListeners<string>; private jsxProperties: ComponentJSX<this>; // remaining class... } |
Again the even type is determined by the names of the two properties:
Event Triggering
But @event
can do more ChangeListeners
actually extends the Listeners
type, which is not just a function but also an object providing new API to manage listeners and dispatch events. It is an abstraction of the built-in Tabris event system (i.e. on
, off
and trigger
methods) and can be used interchangeably.
Let’s assume another component that has the @event
on onFoo
:
1 2 3 4 |
class MyWidget extends Composite { @event public readonly onFoo: Listeners<{bar: string}>; //... remaining class... } |
This creates the public API for event registration of foo
events. The generic notation <{bar: string}>
indicates that the event object that the listeners receive has a field bar
of the type string. This is in addition to target
, type
, and timeStamp
that are available on every EventObject
.
Note that we don’t need
@component
for@event
to work. The event target does not even have to be a widget.
To trigger an event we can use the trigger
method of Listeners
available directly on onFoo
.
Example:
1 2 |
myWidget.onFoo(listner); myWidget.onFoo.trigger({bar: 'Hello World!'}); |
This is the same as doing…
1 2 |
myWidget.on('foo', listner); myWidget.trigger('foo', Object.assign(new EventObject(), {bar: 'Hello World!'})); |
…except that the former is type-safe and the latter isn’t.
As you can see the new API also does not require us to create an EventObject
instance, the new trigger
method will do that for us and copy the given data to it.
Event Forwarding
Back to LabeledInput
. In addition to change event we also want to be notified when the user presses enter while TextInput
has the focus. The goal would be to enable the following code:
1 2 3 |
<LabeledInput labelText='First Name:' onAccept={ev => console.log(`Your name is ${ev.text}`)}/> |
We will call the event accept
, just like it does on TextInput
. Lets start by adding the @event
:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
@component export default class LabeledInput extends Composite { @bind('#input.text') public text: string; @event public readonly onTextChanged: ChangeListeners<string>; @property public labelText: string; @event public readonly onLabelTextChanged: ChangeListeners<string>; @event public readonly onAccept: Listeners<{text: string}>; private jsxProperties: ComponentJSX<this>; // remaining class... } |
Now to forward the accept
event from TextInput
to LabeledInput
we can simply do this:
1 2 3 4 |
<textInput id='input' left='prev() 12' width={250} font='20px' onAccept={ev => this.onAccept.trigger(ev)}/> |
But it can be even shorter:
1 2 3 4 |
<textInput id='input' left='prev() 12' width={250} font='20px' onAccept={this.onAccept.trigger}/> |
This is possible because the trigger
method of Listeners
is permanently bound to event type and target, no matter how it is called. The event object that is passed on will not be re-used, instead trigger
will copy the text
property to a new EventObject
instance with the correct type
and target
fields.
Putting it all together
Now LabeledInput
looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
import { Composite } from 'tabris'; import { bind, component, property, ComponentJSX } from 'tabris-decorators'; @component export default class LabeledInput extends Composite { @bind('#input.text') public text: string; @event public readonly onTextChanged: ChangeListeners<string>; @property public labelText: string; @event public readonly onLabelTextChanged: ChangeListeners<string>; @event public readonly onAccept: Listeners<{text: string}>; private jsxProperties: ComponentJSX<this>; constructor(properties: Partial<LabeledInput>) { super(properties); this.append( <widgetCollection> <textView id='label' height={32} centerY={0} bind-text='labelText' font='20px'/> <textInput id='input' left='prev() 12' width={250} font='20px' onAccept={this.onAccept.trigger}/> </widgetCollection> ); } } |
And now we can update the demo snippet from last time to use the new accept
event:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
import { ui } from 'tabris'; import LabeledInput from './ui/LabeledInput'; ui.contentView.append( <widgetCollection> <LabeledInput id='firstname' labelText='First name:'/> <LabeledInput id='lastname' labelText='Last name:' onAccept={showName}/> </widgetCollection> ).children().set({left: 12, top: 'prev() 12'}); function showName() { const firstName = ui.find('#firstname').first(LabeledInput).text; const lastName = ui.find('#lastname').first(LabeledInput).text; new AlertDialog({message: `Hello ${firstName} ${lastName}!`}).open(); } |
For now this completes our LabeledInput
example component. For more example code using the Tabris decorators, have a look at the reddit_viewer project here.
Get Started with the Tabris Framework
To try out Tabris.js,
- Install the new Tabris.js 2 developer app on your device
- Try out the examples bundled in this app
- Run your own code snippets from the playground, our online Tabris.js editor
To start developing real apps,
- Install the latest Tabris CLI on your machine:
npm install -g tabris-cli
- Type
tabris init
in an empty directory – this will create a simple example app - Type
tabris serve
and load it in the developer app
The documentation contains everything you need to know (tip: try the doc search). Beginners find a step-by-step guide in this ebook. If you have questions or comments, you’re also invited to join the community chat.
Happy coding!
Feedback is welcome!
Want to join the discussion?Feel free to contribute!