Create a custom user control using JavaScript and WinJS for Windows 8

VDav

As you may know, you can easily develop native applications for Windows 8 using JavaScript and WinJS.

Among a lot of other things, WinJS allows you to create custom control in order to factorize your UI.

Today, I would like to show you how to create this kind of control and to do so, please let me introduce you with the final version of the control:



As you can see, this is a 3D carousel that can be used to display a long list of items alongside a great (if you like itSourire) mirror.

The sample Visual Studio 2012 project can be downloaded here.

(If you want to know how to achieve the same goal but with C# and XAML, you can read this article written by my colleague Sébastien Pertus: https://blogs.msdn.com/b/mim/archive/2013/03/19/create-a-custom-user-control-using-xaml-and-c-for-windows-8.aspx)

So you may now wonder how you can develop such a beautiful (!!) control. The response will be decomposed on 6 parts:

  1. How to set up the layout?
  2. Declaring and instancing the control
  3. Using templates
  4. Using data binding
  5. Integrating user inputs and animations
  6. Adding events
  7. Optimizations

How to set up the layout?

The control itself is built using a grid (CSS3 Grid) defined as the “carousel-viewport”. Inside the viewport there are two items: one called the “carousel-container” to handle all items and one called “carousel-mirror” for the mirror (obviously).

Every item is built inside a “carousel-item-container” and contains two parts: a “carousel-item” for the standard part and a “carousel-mirror-item” for the mirror part.

clip_image002

You can then use the magic of CSS in order to completely style every part of the control:

.carousel-item {    
    transition: transform 0.1s ease;
}

.carousel-item:hover {
    transform: scale(0.95);
}

For instance, with these rules, I added a cool hover effect to my items. Simple, isn’t it?

Declaring and instancing the control

To declare a custom control, you just have to declare a WinJS class:

var carousel = WinJS.Class.define(
    function(element, options) {
    // Constructor
}, {// Instance members }, {// Static members });

The first argument of the WinJS.Class.define function is the constructor of you control (used to instantiate your class), the second one is the list of instance members and the last one is the list of static members.

Inside the constructor, you have to define the root DOM elements you want to use. The instance members can be properties or public functions you want to make available.

For our control, the constructor will produce the layout with the following code:

var carousel = WinJS.Class.define(
    function(element, options) {
        this._element = element || document.createElement("div");
        this._element.winControl = this;
        this._selectedIndex = -1;

        // Root element
        this._element.style.display = "-ms-grid";
        this._element.style.msGridRows = "1fr";
        this._element.style.msGridColumns = "1fr";
        this._element.style.msTouchAction = "none";
        WinJS.Utilities.addClass(this._element, "carousel-viewport");

        // Items element
        this._itemsRoot = document.createElement("div");
        this._element.appendChild(this._itemsRoot);
        this._itemsRoot.msGridRow = "1";
        this._itemsRoot.msGridColumn = "1";
        this._itemsRoot.style.display = "-ms-grid";
        this._itemsRoot.style.msGridRows = "1fr";
        this._itemsRoot.style.msGridColumns = "1fr";
        this._itemsRoot.style.perspectiveOrigin = "50% 50%";
        WinJS.Utilities.addClass(this._itemsRoot, "carousel-container");

        // Mirror
        this._mirror = document.createElement("div");
        this._mirror.style.msGridRow = "1";
        this._mirror.style.msGridColumn = "1";
        this._mirror.style.msGridRowAlign = "stretch";
        this._mirror.style.msGridColumnAlign = "stretch";
        this._mirror.style.backgroundColor = "Black";
        WinJS.Utilities.addClass(this._mirror, "carousel-mirror");

The constructor is also responsible for initialization options:

  • maxVisibleItems: Count of items visible on each side of the current item
  • depth: Depth of each item relatively to previous
  • animationSpeed : Speed of the animation when you change the current item
  • mirrorEnabled: Define if the mirror is visible
  • perspective: Value indicating the amount of perspective

These options are defined by the user during the instantiation of the control:

<div id="carousel" data-win-control="DarkStone.Controls.Carousel" data-win-options="{mirrorEnabled: true}">
</div>

The instance of your class can be retrieved using the following code:

var carousel = element.querySelector("#carousel").winControl;

Every exposed properties can also be manipulated by the client. Here is for instance, how a slider can control the perspective:

element.querySelector("#perspective").onchange = function(evt) {
    carousel.perspective = element.querySelector("#perspective").value;
};

And the result:



In addition to the previous properties, our control also exposes some other useful members (always defined with the WinJS.Class.define function):

var carousel = WinJS.Class.define(
    function(element, options) {
        // Code hidden for readability
    },
{
    _doBinding: function () {
        // Code hidden for readability
    },
    refreshLayout: function () {
        // Code hidden for readability
    },
    element: {
        get: function () { return this._element; }
    },
    itemRender: { // Main item template
        set: function (value) {
            this._itemRender = value;
            this._doBinding();
        }
    },
    mirrorRender: { // Mirror template
        set: function (value) {
            this._mirrorRender = value;
            this._doBinding();
        }
    },
    itemsSource: { // Data source
        set: function (source) {
            this._source = source;
            this._doBinding();
        }
    },
    selectedIndex: { // Selected index
        get: function () { return this._selectedIndex; },
        set: function (index) {
            if (index < 0 || index >= this._items.length || index == this.selectedIndex)
                return;
            this._selectedIndex = index;
            this.refreshLayout();
        }
    },
    // more code…       
});

Using templates

WinJS also provides a way to generate item content based on templates. For our control, I decided to use two templates: one for the item and one for the mirror.

The user can define his templates like this:

<div id="templateDiv" data-win-control="WinJS.Binding.Template">
    <img data-win-bind="src: imageUrl" class="templateImg" />
</div>
<div id="mirrorTemplate" data-win-control="WinJS.Binding.Template">
    <div class="mirrorContainer">
        <img data-win-bind="src: imageUrl" class="templateImg" />
        <div class="opacityMask"></div>
    </div>
</div>

The templates are retrieved from the web page:

var template = this._itemRender.winControl;
var mirrorTemplate= this._mirrorRender.winControl;

And used to create the DOM content:

template.render(data, item);

These templates are used by a function called createItem which takes a data from the data source and generate DOM objects using templates:

// Create item
var createItem = function (data) {
    // Creating DOM element and setting correct position
    var root = document.createElement("div");
    root.style.msGridRow = "1";
    root.style.msGridColumn = "1";
    root.style.msGridRowAlign = "center";
    root.style.msGridColumnAlign = "center";
    root.style.msTouchAction = "none";

    root.style.display = "-ms-grid";
    root.style.msGridRows = "1fr";
    root.style.msGridColumns = "1fr";
    root.style.opacity = 0;
    WinJS.Utilities.addClass(root, "carousel-item-container");


that._itemsRoot.appendChild(root);
var item = document.createElement("div"); root.appendChild(item); WinJS.Utilities.addClass(item, "carousel-item"); // Template template.render(data, item); if (mirrorTemplate) { // Clone for mirror var clone = document.createElement("div"); mirrorTemplate.render(data, clone); WinJS.Utilities.addClass(clone, "carousel-mirror-item"); root.appendChild(clone); clone.style.transform = "translateY(" + item.clientHeight + "px) rotateX(180deg)"; } that._itemsRoot.removeChild(root); return root; };

This function generates the correct class for each part of the item. This will allow you to skin them easily. For instance, using the following templates:

<div id="templateDiv" data-win-control="WinJS.Binding.Template">
</div>
<div id="mirrorTemplate" data-win-control="WinJS.Binding.Template">
</div>

And the following CSS rules:

.carousel-item {    
    width: 600px;
    height: 600px;
    background-color: red;
}

.carousel-mirror-item {
    width: 600px;
    height: 600px;
    background-color: blue;
}

You will obtain the following result:

 

Using data binding

The user can define data using a property called itemsSource:

itemsSource: { // Data source
    set: function (source) {
        this._source = source;
        this._doBinding();
    }
},

You can use a simple array or a WinJS.Binding.List:

var carousel = element.querySelector("#carousel").winControl;
var list = new WinJS.Binding.List();

list.push({ imageUrl: "/assets/pic01.jpg" });
list.push({ imageUrl: "/assets/pic02.jpg" });
list.push({ imageUrl: "/assets/pic03.jpg" });
list.push({ imageUrl: "/assets/pic04.jpg" });
list.push({ imageUrl: "/assets/pic05.jpg" });
list.push({ imageUrl: "/assets/pic06.jpg" });

carousel.itemsSource = list;

Once the itemsSource is set, the control just have to browse it and call createItem for each element.

If you use a WinJS.Binding.List, the control can also handle two events to dynamically support insertion and deletion of items:

// Events
if (usingBindingList) {
    this._source.addEventListener("itemremoved", function (evt) { // On item removed
        var previous = that._items.splice(evt.detail.index, 1)[0];

        that._itemsRoot.removeChild(previous);

        if (evt.detail.index < that.selectedIndex) {
            that.selectedIndex--;
        } else if (Math.abs(that.selectedIndex - evt.detail.index) <= that.maxVisibleItems) {
            that.refreshLayout();
        }
    });
    this._source.addEventListener("iteminserted", function (evt) { // On item inserted
        var current = evt.detail.index;
        var newOne = createItem(that._source.getAt(current));

        that._items.splice(evt.detail.index, 0, newOne);

        if (Math.abs(that.selectedIndex - current) <= that.maxVisibleItems) {
            that.refreshLayout();
        }
    });
}

Using this events, if the user executes this code:

element.querySelector("#button1").onclick = function () {
    list.splice(0, 1); // Removing first item
};

element.querySelector("#button2").onclick = function () {
    list.push({ imageUrl: "/assets/pic01.jpg" });  // Adding new item
    list.push({ imageUrl: "/assets/pic02.jpg" }); // Adding new item
};

The list will behave like this:



Integrating user inputs and animations

To respond to user inputs, you just have to handle pointer events. The pointer events (more about here) are used to unify mouse, touch and pens inside a single event model. So for our control, you just have to add the following code to handle all kind of inputs:

// Pointer events
this._element.onmspointerdown = function (evt) {
    initialOffset = evt.clientX;

    leftButtonDown = evt.buttons == 1;

    that._pageChanged = false;
};

this._element.onmspointermove = function (evt) {
    if (!leftButtonDown)
        return;

    var moveThreshold = 40; // 40px for a slide

    if (Math.abs(evt.clientX - initialOffset) > moveThreshold) {
        if (evt.clientX < initialOffset) {
            that.selectedIndex++;
        } else {
            that.selectedIndex--;
        }
        that._pageChanged = true;
        leftButtonDown = false;
    }
};

this._element.onmspointerup = function (evt) {
    leftButtonDown = false;
};

The principle is simple: if the user moves his finger/mouse/pen over more than 40 pixels, we can change the current item according to the direction of the movement.

To create a good looking control, you must ensure that every update of the layout is animated (fast and fluid!). To do so, you may have noticed that every “carousel-item-container” has a CSS style to define transitions:

root.style.transition = "transform " + that._animationSpeed + "s ease-in-out, opacity " + that._animationSpeed + "s linear";

This transition defines that every change on the transform and opacity properties is not immediate but animated from its original value to the final value during a specific duration.

If you want to learn more about CSS3 Transitions, you can go just here.

To take advantage of this great feature, the code just have to change the transformation and the opacity of each item.

The transformation itself (CSS 3D Transforms) allows elements styled with CSS to be transformed in two-dimensional or three-dimensional space.

The transform matrix is built using this code:

var depth = (current == this.selectedIndex) ? 0 : -this._depth;
var rotation = (current == this.selectedIndex) ? 0 : ((current < this.selectedIndex) ? -45 : 45);
var offset = (current == this.selectedIndex) ? 0 : ((current < this.selectedIndex) ? -this._baseWidth : this._baseWidth);

// Transform
item.style.transform = "rotateY(" + rotation + "deg) translate3d(" + 
(((current -
this.selectedIndex) * this._baseWidth) + offset) + "px, 0px, " + depth + "px)";

To summarize, the items are transformed according to the following schema:


clip_image004

The problem you can face when dealing with CSS 3D Transforms is about zIndex. Indeed, when you move objects using matrices, something objects may overlap in a wrong order.

For resolving this issue, you just have to correctly organize the zIndex value of your items:

// Z-Index
var total = this._items.length;
item.style.zIndex = (current == this.selectedIndex) ? total + 1 : total - Math.abs(current - this.selectedIndex);

Using this code, you can ensure that items are rendered in the correct order (depending on their distance to the selected item).

The opacity is computed using this code:

// Opacity
var currentOpacity;
if (current == this.selectedIndex) {
    currentOpacity = 1;

} else {
    currentOpacity = 1.0 - (Math.abs(current - this.selectedIndex) / this._maxVisibleItems);
}

Every item has an opacity proportional to its distance from the current item:

Adding events

Obviously, a good control must raise events. For instance, if an user select an item, it would be nice to raise a “selectedindexchanged”.

For our control, first of all you have to “dispatch” the event with the following code:

selectedIndex: { // Selected index
    get: function () { return this._selectedIndex; },
    set: function (index) {
        if (index < 0 || index >= this._items.length || index == this.selectedIndex)
            return;
        this._selectedIndex = index;
        this.refreshLayout();

        this.dispatchEvent("selectedindexchanged", {
            selectedIndex: index
        });
    }
},

When the user changes the selectedIndex, we just have to raise the event with dispatchEvent function of the control.

But the control will not natively provide this function. To add it, you just have to call a small WinJS function:

WinJS.Class.mix(DarkStone.Controls.Carousel,
    WinJS.Utilities.createEventProperties("selectedindexchanged"),
    WinJS.UI.DOMEventMixin);

With only these lines, you can add event listener on your control like any other control:

carousel.addEventListener("selectedindexchanged", function() {

});

Optimizations

Finally, just to be sure our control works well on very low end hardware, we must ensure that only visible items are effectively handled by the DOM.

In order to achieve this goal, we just have to remove items from the DOM when their opacity is inferior or equal to 0. Obviously you must re-inject them when they become visible again:

// Opacity
var currentOpacity;
if (current == this.selectedIndex) {
    currentOpacity = 1;

    if (!isPresentInsideDOM) {
        this._itemsRoot.appendChild(item);
    }
} else {
    currentOpacity = 1.0 - (Math.abs(current - this.selectedIndex) / this._maxVisibleItems);

    // Virtualization
    if (currentOpacity <= 0 && isPresentInsideDOM) {
        this._itemsRoot.removeChild(item);
    } else if (currentOpacity > 0 && !isPresentInsideDOM) {
        this._itemsRoot.appendChild(item);
    }
}

Using this code, we are now ready to handle thousands of items even on a low end devices.

The control is now complete, feel free to use it in your own app Clignement d'œil

Going further

If you want to read more about some subjects I addressed in this article, here are some links you may find useful:

Complete code

The complete code can be downloaded here or you can copy/paste from here:

(function () {

    var carousel = WinJS.Class.define(
        function(element, options) {
            this._element = element || document.createElement("div");
            this._element.winControl = this;
            this._selectedIndex = -1;

            // Root element
            this._element.style.display = "-ms-grid";
            this._element.style.msGridRows = "1fr";
            this._element.style.msGridColumns = "1fr";
            this._element.style.msTouchAction = "none";
            WinJS.Utilities.addClass(this._element, "carousel-viewport");

            // Items element
            this._itemsRoot = document.createElement("div");
            this._element.appendChild(this._itemsRoot);
            this._itemsRoot.msGridRow = "1";
            this._itemsRoot.msGridColumn = "1";
            this._itemsRoot.style.display = "-ms-grid";
            this._itemsRoot.style.msGridRows = "1fr";
            this._itemsRoot.style.msGridColumns = "1fr";
            this._itemsRoot.style.perspectiveOrigin = "50% 50%";
            WinJS.Utilities.addClass(this._itemsRoot, "carousel-container");

            // Mirror
            this._mirror = document.createElement("div");
            this._mirror.style.msGridRow = "1";
            this._mirror.style.msGridColumn = "1";
            this._mirror.style.msGridRowAlign = "stretch";
            this._mirror.style.msGridColumnAlign = "stretch";
            this._mirror.style.backgroundColor = "Black";
            WinJS.Utilities.addClass(this._mirror, "carousel-mirror");

            this._items = [];
            this._source = null;
            this._itemRender = null;
            this._mirrorRender = null;
            this._pageChanged = false;
            this._baseWidth = 0;

            // Default values
            this._maxVisibleItems = 15;
            this._depth = 100;
            this._animationSpeed = 0.3;
            this._mirrorEnabled = false;
            this.perspective = 800;

            // Options
            if (options) {
                this.depth = options.depth || this._depth;
                this.animationSpeed = options.animationSpeed || this._animationSpeed;
                this.perspective = options.perspective || this.perspective;
                this._mirrorEnabled = options.mirrorEnabled || this._mirrorEnabled;
                this._maxVisibleItems = options.maxVisibleItems || this._maxVisibleItems;
            }

            if (this._mirrorEnabled) {
                this._itemsRoot.appendChild(this._mirror);
            }

            // Pointer events
            var that = this;
            var initialOffset = 0;
            var leftButtonDown = false;

            this._element.addEventListener("MSPointerDown", function (evt) {
                initialOffset = evt.clientX;

                leftButtonDown = evt.buttons == 1;

                that._pageChanged = false;
            }, false);

            this._element.addEventListener("MSPointerMove", function (evt) {
                if (!leftButtonDown)
                    return;

                var moveThreshold = 40; // 40px for a slide

                if (Math.abs(evt.clientX - initialOffset) > moveThreshold) {
                    if (evt.clientX < initialOffset) {
                        that.selectedIndex++;
                    } else {
                        that.selectedIndex--;
                    }
                    that._pageChanged = true;
                    leftButtonDown = false;
                }
            }, false);

            this._element.addEventListener("MSPointerUp", function () {
                leftButtonDown = false;
            }, false);
        },
    {
        _doBinding: function () {
            if (!this._source || !this._itemRender || (this._mirrorEnabled && !this._mirrorRender))
                return;

            var usingBindingList = (this._source.getAt !== undefined);

            var template = this._itemRender.winControl;
            var mirrorTemplate;

            if (this._mirrorEnabled && this._mirrorRender)
                mirrorTemplate = this._mirrorRender.winControl;

            var that = this;

            var onItemClick = function (evt) { // Selecting the current item
                var newOne = that._items.indexOf(evt.currentTarget.parentElement);
                if (!that._pageChanged) {
                    that.selectedIndex = newOne;
                }
            };

            // Browse data source
            for (var index = 0; index < this._items.length; index++) {
                var item = this._items[index];

                if (item.parentElement) {
                    this._itemsRoot.removeChild(item);
                }
            }
            this._items = [];

            // Create item
            var createItem = function (data, initalTransform) {
                var root = document.createElement("div");
                root.style.msGridRow = "1";
                root.style.msGridColumn = "1";
                root.style.msGridRowAlign = "center";
                root.style.msGridColumnAlign = "center";
                root.style.msTouchAction = "none";
                if (initalTransform)
                    root.style.transform = initalTransform;
                root.style.transition = "transform " + that._animationSpeed + "s ease-in-out, opacity " 
+ that._animationSpeed + "s linear"; root.style.display = "-ms-grid"; root.style.msGridRows = "1fr"; root.style.msGridColumns = "1fr"; root.style.opacity = 0; root.onmousemove = function (evt) { evt.preventDefault(); // Preventing picture manipulation }; WinJS.Utilities.addClass(root, "carousel-item-container"); that._itemsRoot.appendChild(root); var item = document.createElement("div"); root.appendChild(item); item.onclick = onItemClick; WinJS.Utilities.addClass(item, "carousel-item"); // Template template.render(data, item); if (mirrorTemplate) { // Clone for mirror var clone = document.createElement("div"); mirrorTemplate.render(data, clone); WinJS.Utilities.addClass(clone, "carousel-mirror-item"); root.appendChild(clone); clone.style.transform = "translateY(" + item.clientHeight + "px) rotateX(180deg)"; } that._itemsRoot.removeChild(root); return root; }; // Browse data source for (var index = 0; index < this._source.length; index++) { var data = usingBindingList ? this._source.getAt(index) : this._source[index]; this._items.push(createItem(data)); } this._selectedIndex = 0; this.refreshLayout(); // Events if (usingBindingList) { this._source.addEventListener("itemremoved", function (evt) { // On item removed var previous = that._items.splice(evt.detail.index, 1)[0]; if (previous.parentElement) { that._itemsRoot.removeChild(previous); } if (evt.detail.index < that.selectedIndex) { that.selectedIndex--; } else if (Math.abs(that.selectedIndex - evt.detail.index) <= that.maxVisibleItems) { that.refreshLayout(); } }); this._source.addEventListener("iteminserted", function (evt) { // On item inserted var current = evt.detail.index; var newOne = createItem(that._source.getAt(current)); that._items.splice(evt.detail.index, 0, newOne); if (Math.abs(that.selectedIndex - current) <= that.maxVisibleItems) { that.refreshLayout(); } }); } // Mirror this._mirror.style.transform = "translateY(" + this.element.clientHeight / 2 + "px)"; }, refreshLayout: function () { if (this._items.length == 0) { return; } var current = this.selectedIndex; var total = this._items.length; for (var index = 0; index < total; index++) { var item = this._items[current]; var isPresentInsideDOM = (item.parentElement !== null); // Opacity var currentOpacity; if (current == this.selectedIndex) { currentOpacity = 1; if (!isPresentInsideDOM) { this._itemsRoot.appendChild(item); isPresentInsideDOM = true; } } else { currentOpacity = 1.0 - (Math.abs(current - this.selectedIndex) / this._maxVisibleItems); // Virtualization if (currentOpacity <= 0 && isPresentInsideDOM) { this._itemsRoot.removeChild(item); isPresentInsideDOM = false; } else if (currentOpacity > 0 && !isPresentInsideDOM) { this._itemsRoot.appendChild(item); isPresentInsideDOM = true; } } // Setting opacity item.style.opacity = currentOpacity; if (isPresentInsideDOM) { if (!this._baseWidth) this._baseWidth = item.clientWidth; var depth = (current == this.selectedIndex) ? 0 : -this._depth; var rotation = (current == this.selectedIndex) ? 0 : ((current < this.selectedIndex) ? -45 : 45); var offset = (current == this.selectedIndex) ? 0 : (
(current <
this.selectedIndex) ? -this._baseWidth : this._baseWidth); // Transform item.style.transform = "rotateY(" + rotation + "deg) translate3d("
+ (((current - this.selectedIndex) * this._baseWidth) + offset) + "px, 0px, " + depth + "px)"; // Z-Index item.style.zIndex = (current == this.selectedIndex) ? total + 1 : total
- Math.abs(current -
this.selectedIndex); } current++; if (current == this._items.length) current = 0; } }, element: { get: function () { return this._element; } }, itemRender: { // Main item template set: function (value) { this._itemRender = value; this._doBinding(); } }, mirrorRender: { // Mirror template set: function (value) { this._mirrorRender = value; this._doBinding(); } }, itemsSource: { // Data source set: function (source) { this._source = source; this._doBinding(); } }, selectedIndex: { // Selected index get: function () { return this._selectedIndex; }, set: function (index) { if (index < 0 || index >= this._items.length || index == this.selectedIndex) return; this._selectedIndex = index; this.refreshLayout(); this.dispatchEvent("selectedindexchanged", { selectedIndex: index }); } }, animationSpeed: { get: function () { return this._animationSpeed; }, set: function (value) { this._animationSpeed = value; } }, depth: { get: function () { return this._depth; }, set: function (value) { if (this._depth === value) return; this._depth = value; this._refreshLayout(); } }, perspective: { get: function () { return parseInt(this._itemsRoot.style.perspective.replace("px")); }, set: function (value) { this._itemsRoot.style.perspective = value + "px"; } }, maxVisibleItems: { get: function () { return this._maxVisibleItems; }, set: function (value) { if (this._maxVisibleItems === value) { return; } this._maxVisibleItems = value; this._refreshLayout(); } } }); WinJS.Namespace.define("DarkStone.Controls", { Carousel: carousel }); WinJS.Class.mix(DarkStone.Controls.Carousel, WinJS.Utilities.createEventProperties("selectedindexchanged"), WinJS.UI.DOMEventMixin); })();