Observable
Generally, almost every UI control could be bound to a data object (all NativeScript controls are created with data binding in mind). After the code has met the following requirements, data-binding can be used out of the box.
- The target object has to be a successor of the
Bindable
class. All NativeScript UI controls inherit fromBindable
class. - For one-way binding, using a plain property is sufficient.
- For two-way data binding, the target property should be a dependency property.
- The data object should raise a
propertyChange
event for every change in the value of its property in order to notify all of the listeners interested in the change.
The article will show basic and advanced binding techniques including the architectural pattern used in NativeScript framework - MVVM (Model-View-ViewModel).
Basics
Creating and using Observable objects requires the tns-core-modules/data/observable
module.
const Observable = require("tns-core-modules/data/observable").Observable;
const fromObject = require("tns-core-modules/data/observable").fromObject;
const fromObjectRecursive = require("tns-core-modules/data/observable").fromObjectRecursive;
import { fromObject, fromObjectRecursive, Observable, PropertyChangeData } from "tns-core-modules/data/observable";
Observable Class
Observable is used when you want to be notified when a change occurs. Use on/off methods to add/remove listener.
<!-- Using basic string binding and tap event callback binding-->
<Label text="{{ clientName }}" tap="{{ onLabelTap }}" textWrap="true" class="h2" color="red"/>
<!-- Example for using binding with concatenation (text prop) and for using binding to change font-size -->
<Label text="{{ 'font-size:' + mySize }}" textWrap="true" fontSize="{{ mySize }}"/>
<!-- Example demonstrating the boolean property usage with visibility and ternary expression-->
<Label text="{{ isVisible }}" textWrap="true" visibility="{{ isItemVisible, isItemVisible ? 'visible' : 'collapsed' }}"/>
// creating an Observable and setting title propertu with a string value
const page = args.object;
const viewModel = new Observable();
// String binding using set with key-value
viewModel.set("clientName", "Jonh Doe");
// Number binding using set with key-value
viewModel.set("mySize", 26);
// Boolean binding using set with key-value
viewModel.set("isVisible", true);
// Binding event callback using set with key-value
viewModel.set("onLabelTap", (args) => {
// args is of type EventData
console.log("Tapped on", args.object); // <Label>
console.log("Name: ", args.object.text); // The text value
});
// using get to obtain the value of specific key
console.log(viewModel.get("clientName")); // Jonh Doe
console.log(viewModel.get("mySize")); // 42
console.log(viewModel.get("isVisible")); // true
// bind the view-model to the view's bindingContext property (e.g. the curent page or view from navigatingTo or loaded event)
page.bindingContext = viewModel;
// creating an Observable and setting title propertu with a string value
const viewModel = new Observable();
// String binding using set with key-value
viewModel.set("clientName", "Jonh Doe");
// Number binding using set with key-value
viewModel.set("mySize", 24);
// Boolean binding using set with key-value
viewModel.set("isVisible", true);
// Binding event callback using set with key-value
viewModel.set("onLabelTap", (args) => {
// args is of type EventData
console.log("Tapped on", args.object); // <Label>
console.log("Name: ", args.object.text); // The text value
});
// using get to obtain the value of specific key
console.log(viewModel.get("clientName")); // Jonh Doe
console.log(viewModel.get("mySize")); // 42
console.log(viewModel.get("isVisible")); // true
// bind the view-model to the view's bindingContext property (e.g. the curent view from loaded event)
const view = <Page>data.object;
view.bindingContext = viewModel;
Creating Observable with fromObject Method
The fromObject
method creates an Observable instance and sets its properties according to the supplied JavaScript object.
// fromObject creates an Observable instance and sets its properties according to the supplied JS object
const newViewModel = fromObject({ "myColor": "Lightgray" });
// the above is equal to
/*
let newViewModel = new Observable();
newViewModel.set("myColor", "Lightgray");
*/
// fromObject creates an Observable instance and sets its properties according to the supplied JS object
const newViewModel = fromObject({ "myColor": "Lightgray" });
// the above is equal to
/*
let newViewModel = new Observable();
newViewModel.set("myColor", "Lightgray");
*/
Creating Observable with fromObjectRecursive Method
The fromObjectRecursive
method creates an Observable instance and sets its properties according to the supplied JavaScript object. This function will create new Observable for each nested object (except arrays and functions) from supplied JavaScript object.
// fromObjectRecursive will create new Observable for each nested object (except arrays and functions)
const nestedViewModel = fromObjectRecursive({
client: "John Doe",
favoriteColor: { hisColor: "Green" } // hisColor is an Observable (using recursive creation of Observables)
});
// the above is equal to
/*
let newViewModel2 = new Observable();
newViewModel2.set("client", "John Doe");
newViewModel2.set("favoriteColor", fromObject( {hisColor: "Green" }));
*/
// fromObjectRecursive will create new Observable for each nested object (except arrays and functions)
const nestedViewModel = fromObjectRecursive({
client: "John Doe",
favoriteColor: { hisColor: "Green" } // hisColor is an Observable (using recursive creation of Observables)
});
// the above is equal to
/*
const newViewModel2 = new Observable();
newViewModel2.set("client", "John Doe");
newViewModel2.set("favoriteColor", fromObject( {hisColor: "Green" }));
*/
Adding Event Listener and Using propertyChangeEvent
Using propertyChangeEvent
and responding to property changes with arguments of type PropertyChangeData
.
const myListener = viewModel.addEventListener(Observable.propertyChangeEvent, (args) => {
// args is of type PropertyChangeData
console.log("propertyChangeEvent [eventName]: ", args.eventName);
console.log("propertyChangeEvent [propertyName]: ", args.propertyName);
console.log("propertyChangeEvent [value]: ", args.value);
console.log("propertyChangeEvent [oldValue]: ", args.oldValue);
});
const myListener = viewModel.addEventListener(Observable.propertyChangeEvent, (args: PropertyChangeData) => {
// args is of type PropertyChangeData
console.log("propertyChangeEvent [eventName]: ", args.eventName);
console.log("propertyChangeEvent [propertyName]: ", args.propertyName);
console.log("propertyChangeEvent [value]: ", args.value);
console.log("propertyChangeEvent [oldValue]: ", args.oldValue);
});
Removing Event Listener
The event listeners can be explicitly removed when no longer needed.
viewModel.removeEventListener(Observable.propertyChangeEvent, myListener);
viewModel.removeEventListener(Observable.propertyChangeEvent, myListener);
Mvvm Pattern
MVVM ((Model-View-ViewModel) is the base pattern on which NativeScript framework is build. MVVM facilitates a separation of development of the graphical user interface from development of the business logic or back-end logic (the data model).
- Model: The model defines and represents the data. Separating the model from the various views that might use it allows for code reuse.
- View: The view represents the UI, which in NativeScript is written in XML. The view is often data-bound to the view model so that changes made to the view model in JavaScript instantly trigger visual changes to UI components.
-
View Model: The view model contains the application logic (often including the model), and exposes the data to the view. NativeScript provides a module called
Observable
, which facilitates creating a view model object that can be bound to the view.
The biggest benefit of separating models, views, and view models, is that you are able to use two-way data binding; that is, changes to data in the model get instantly reflected on the view, and vice versa. The other big benefit is code reuse, as you're often able to reuse models and view models across views.
Complete example demonstrating MVVM pattern in NativeScript application with Plaing JavaScript and with TypeScript (using Class
).
Plain JavaScript
const Observable = require("tns-core-modules/data/observable").Observable;
function getMessage(counter) {
if (counter <= 0) {
return "Hoorraaay! You unlocked the NativeScript clicker achievement!";
} else {
return `${counter} taps left`;
}
}
function createViewModel() {
const viewModel = new Observable();
viewModel.set("counter", 42);
viewModel.set("message", getMessage(viewModel.counter));
viewModel.onTap = function () {
this.set("message", getMessage(--this.counter));
};
return viewModel;
}
exports.createViewModel = createViewModel;
const createViewModel = require("./main-view-model").createViewModel;
function onNavigatingTo(args) {
const page = args.object;
// using the view model as binding context for the current page
const mainViewModel = createViewModel();
page.bindingContext = mainViewModel;
}
exports.onNavigatingTo = onNavigatingTo;
<Page xmlns="http://schemas.nativescript.org/tns.xsd" navigatingTo="onNavigatingTo" class="page">
<Page.actionBar>
<ActionBar title="MVVM Pattern"/>
</Page.actionBar>
<StackLayout class="p-20">
<Label text="Tap the button" class="h1 text-center"/>
<!-- using the view model method `onTap` and property `message` -->
<Button text="TAP" tap="{{ onTap }}" class="btn btn-primary btn-active"/>
<Label text="{{ message }}" class="h2 text-center" textWrap="true"/>
</StackLayout>
</Page>
TypeScript
import { Observable } from "tns-core-modules/data/observable";
export class HelloWorldModel extends Observable {
private _counter: number;
private _message: string;
constructor() {
super();
// Initialize default values.
this._counter = 42;
this.updateMessage();
}
get message(): string {
return this._message;
}
set message(value: string) {
if (this._message !== value) {
this._message = value;
this.notifyPropertyChange("message", value);
}
}
public onTap() {
this._counter--;
this.updateMessage();
}
private updateMessage() {
if (this._counter <= 0) {
this.message = "Hoorraaay! You unlocked the NativeScript clicker achievement!";
} else {
this.message = `${this._counter} taps left`;
}
}
}
import { HelloWorldModel } from "./main-view-ts-model";
import { EventData } from "tns-core-modules/data/observable";
import { Page } from "tns-core-modules/ui/page";
export function onNavigatingTo(args: EventData) {
const page = <Page>args.object;
// using the view model as binding context for the current page
const mainViewModel = new HelloWorldModel();
page.bindingContext = mainViewModel;
}
<Page xmlns="http://schemas.nativescript.org/tns.xsd" navigatingTo="onNavigatingTo" class="page">
<Page.actionBar>
<ActionBar title="MVVM Pattern"/>
</Page.actionBar>
<StackLayout class="p-20">
<Label text="Tap the button" class="h1 text-center"/>
<!-- using the view model method `onTap` and property `message` -->
<Button text="TAP" tap="{{ onTap }}" class="btn btn-primary btn-active"/>
<Label text="{{ message }}" class="h2 text-center" textWrap="true"/>
</StackLayout>
</Page>
Parent Binding
Another common case in working with bindings is requesting access to the parent binding context. It is because it might be different from the bindingContext of the child and might contain information, which the child has to use. Generally, the bindingContext is inheritable, but not when the elements (items) are created dynamically based on some data source. For example, ListView creates its child items based on an itemТemplate, which describes what the ListView element will look like. When this element is added to the visual tree, it gets for binding context an element from a ListView items array (with the corresponding index). This process creates a new binding context chain for the child item and its inner UI elements. So, the inner UI element cannot access the binding context of the ListView
. In order to solve this problem, NativeScript binding infrastructure has two special keywords: $parent
and $parents
. While the first one denotes the binding context of the direct parent visual element, the second one can be used as an array (with a number or string index). This gives you the option to choose either N levels of UI nesting or get a parent UI element with a given type. Let's see how this works in a realistic example.
<Page navigatingTo="onNavigatingTo" xmlns="http://schemas.nativescript.org/tns.xsd">
<Page.actionBar>
<ActionBar title="Parents Binding"/>
</Page.actionBar>
<GridLayout rows="*" >
<ListView items="{{ items }}">
<!--Describing how the element will look like-->
<ListView.itemTemplate>
<GridLayout columns="auto, *">
<Label text="{{ $value }}" col="0"/>
<!--The TextField has a different bindingCotnext from the ListView, but has to use its properties. Thus the parents['ListView'] has to be used.-->
<TextField text="{{ $parents['ListView'].test, $parents['ListView'].test }}" col="1"/>
</GridLayout>
</ListView.itemTemplate>
</ListView>
</GridLayout>
</Page>
const fromObject = require("data/observable").fromObject;
function onNavigatingTo(args) {
const page = args.object;
const viewModel = fromObject({
items: [1, 2, 3],
test: "Parent binding! (the value came from the `test` property )"
});
page.bindingContext = viewModel;
}
exports.onNavigatingTo = onNavigatingTo;
import { fromObject, EventData } from "tns-core-modules/data/observable";
import { Page } from "tns-core-modules/ui/page";
export function onNavigatingTo(args: EventData) {
const page = <Page>args.object;
const viewModel = fromObject({
items: [1, 2, 3],
test: "Parent binding! (the value came from the `test` property )"
});
page.bindingContext = viewModel;
}
Plain Object Binding
A very common case is to provide a list (array) of plain elements (numbers, dates, strings) to a ListView items collection. All examples above demonstrate how to bind a UI element to a property of the bindingContext. If there is only plain data, there is no property to bind, so the binding should be to the entire object. Here comes another feature of NativeScript binding - object or value binding. To refer to the entire object, which is Date() in the example, the keyword $value
should be used.
<ListView items="{{ items }}" class="list-group">
<ListView.itemTemplate>
<StackLayout class="list-group-item">
<Label text="Date" class="list-group-item-heading" />
<!-- use $value to bind plain objects (e.g. number, string, Date)-->
<Label text="{{ $value }}" class="list-group-item-text" />
</StackLayout>
</ListView.itemTemplate>
</ListView>
const fromObject = require("tns-core-modules/data/observable").fromObject;
function onNavigatingTo(args) {
const page = args.object;
const list = [];
for (let i = 0; i < 15; i++) {
list.push(new Date());
}
const viewModel = fromObject({
items: list
});
page.bindingContext = viewModel;
}
exports.onNavigatingTo = onNavigatingTo;
import { fromObject, EventData } from "tns-core-modules/data/observable";
import { Page } from "tns-core-modules/ui/page";
export function onNavigatingTo(args: EventData) {
const page = <Page>args.object;
const list = [];
for (let i = 0; i < 15; i++) {
list.push(new Date());
}
const viewModel = fromObject({
items: list
});
page.bindingContext = viewModel;
}
Two Way
Two-way data binding is a way of binding that combines binding from and to the application UI (binding to model and binding to UI)
A typical example is a TextField
that reads its value from the Model but also changes the Model based on user input.
The example is demonstrating two-way binding via code-behind. The TextField
accepts an empty string as initial value (the same binding is used for the Label
element).
Then when the user inputs new string into the TextField
, the two-way binding will update the label's text
property simultaneously.
const observableSource = fromObject({
myTextSource: "" // initial binding value (in this case empty string)
});
// create the TextField
const targetTextField = new TextField();
// create the Label
const targetLabel = new Label();
stackLayout.addChild(targetTextField);
stackLayout.addChild(targetLabel);
// binding the TextField with BindingOptions
const textFieldBindingOptions = {
sourceProperty: "myTextSource",
targetProperty: "text",
twoWay: true
};
targetTextField.bind(textFieldBindingOptions, observableSource);
// binding the Label with BindingOptions
const labelBindingOptions = {
sourceProperty: "myTextSource",
targetProperty: "text",
twoWay: false // we don't need two-way for the Label as it can not accept user input
};
targetLabel.bind(labelBindingOptions, observableSource);
const observableSource = fromObject({
myTextSource: "" // initial binding value (in this case empty string)
});
// create the TextField
const targetTextField = new TextField();
// create the Label
const targetLabel = new Label();
stackLayout.addChild(targetTextField);
stackLayout.addChild(targetLabel);
// binding the TextField with BindingOptions
const textFieldBindingOptions = {
sourceProperty: "myTextSource",
targetProperty: "text",
twoWay: true
};
targetTextField.bind(textFieldBindingOptions, observableSource);
// binding the Label with BindingOptions
const labelBindingOptions = {
sourceProperty: "myTextSource",
targetProperty: "text",
twoWay: false // we don't need two-way for the Label as it can not accept user input
};
targetLabel.bind(labelBindingOptions, observableSource);
To create a binding in XML, a source object is needed, which will be created the same way, as in the example above. Then the binding is described in the XML (using a mustache syntax). With an XML declaration, only the names of the properties are set - for the target: text, and for source: textSource. The interesting thing here is that the source of the binding is not specified explicitly. More about this topic will be discussed in the Binding source article.
<Page xmlns="http://schemas.nativescript.org/tns.xsd">
<StackLayout>
<TextField text="" />
</StackLayout>
</Page>
Note: When creating UI elements with an XML declaration, the data binding is two-way by default.