Mix06 demo part 2: building the accordion control
In the previous post, I've shown how to use the accordion control. Today, I'm going to explain how to build such a control. I'll try to give as much background as possible on the different patterns in this sample control. This article is going to be fairly technical, so please keep in mind that you don't need to know any of this to use the control. This post is mainly for people who want to build their own Atlas client-side controls.
The first thing you need to do is to create a namespace. In Atlas, everything is namespaced and we've tried to keep the number of global-scoped objects to a minimum to avoid conflicts between components (exactly like in managed code). Namespaces are not a native JavaScript concept, so we simulated that using plain objects. Here, we're using a simple namespace called Dice because the demo it was developed for was called that way, but you can have as deeply nested a namespace as you want:
Type.registerNamespace('Dice');
Now we can define the accordion class. In JavaScript, classes are expressed as functions which are really the constructor of the class. When you new up an object using a constructor function, the function is run and the result is an object of this type. Technically, the constructor becomes a field of the namespace but you don't really need to be aware of that and by just following the convention you'll get something that's really close to real namespaces and classes. To get object-oriented semantics that are closer to managed code, we provide a few helper methods and implement our classes following a pattern:
Dice.Accordion = function(associatedElement) {
Dice.Accordion.initializeBase(this, [associatedElement]);
// ...
}
Dice.Accordion.registerClass('Dice.Accordion', Sys.UI.Control);
The above code is roughly equivalent to the following C# declaration:
namespace Dice {
public class Accordion : Sys.UI.Control {
public Accordion(associatedElement) : base(associatedElement) {
}
}
}
If you want your class to implement interfaces in addition to inheriting from a base class, you can just add them as additional parameters in the registerClass after the base class (Sys.UI.Control here).
All controls in Atlas take the associated HTML element as a constructor parameter. To enable our control to work declaratively, we also need to declare it as a tag to the global type descriptor and we need to expose a description of all properties, events and methods that need to be accessible from xml-script. The global type declaration is done by adding this line after the registerClass:
Sys.TypeDescriptor.addType('dice', 'accordion', Dice.Accordion);
The first parameter here is the tag prefix, the second is the tag name and the last is the type itself. For the moment, there is no way for the page developer to redefine the tag prefix that you chose so choose it well like you would for a namespace, with conflict minimization in mind (you don't want to use "controls" or "library" or something as generic but more something along the lines of "myCompanyMyProject"). Now that this is done, xml-script can instantiate the control using a <dice:accordion id="someElement"/> element if the dice namespace has been added to the page tag (xmlns:dice="http://schemas.microsoft.com/xml-script/2005/dice"). The description of the declaratively accessible members is done by adding this method to the class:
this.getDescriptor = function() {
var td = Dice.Accordion.callBaseMethod(this, 'getDescriptor');
td.addProperty('viewIndex', Number);
td.addProperty('transitionDuration', Number);
return td;
}
Dice.Accordion.registerBaseMethod(this, 'getDescriptor');
Here, registerBaseMethod declares the method as virtual. The method calls its base class implementation using callBaseMethod which enables it to inherit all the declarative members from Sys.UI.Control. We're adding two properties of type Number: viewIndex and transitionDuration. Actually implementing these properties is easy once you know the convention that property getters and setters are declared using the get_ and set_ prefixes:
var _viewIndex = 0;
var _duration = 0.5;
this.get_transitionDuration = function() {
return _duration;
}
this.set_transitionDuration = function(value) {
_duration = value;
}
this.get_viewIndex = function() {
return _viewIndex;
}
this.set_viewIndex = function(value) {
if (_viewIndex != value) {
_viewIndex = value;
_ShowCurrentPane.call(this, true);
this.raisePropertyChanged('viewIndex');
}
}
The underscored variables here are limited to the scope of the function which means that they are inaccessible to code outside of the function (which is the class). So this is equivalent to private fields. We have two different patterns here. The transitionDuration property is implemented with trivial accessors whereas the viewIndex property is implemented with side effects in the setter when the set value is different from the current one. We're not really interested in monitoring the changes of the transition duration so we just don't do anything special as there would be an unnecessary cost associated with that. Now the viewIndex property is the center of the behavior of our accordion control. Whenever this is set, we want the control to transition from its current pane to the one that's being set so we need to call the private function that sets the current pane (we'll come to that in a moment) and we need to raise a change notification so that other components binding to this property can pick up the changes automatically.
At this point, we've built the scaffolding of our control but we need to implement its actual behavior. Let's start with the method that will show the current pane and hide the others. This method is implemented as a private function (notice there is no this. in the declaration, which will limit its scope to the class). The underscore in front of the property is a convention to indicate a private member.
function _ShowCurrentPane(animate) {
for (var i = _viewPanes.length - 1; i >= 0; i--) {
var pane = _viewPanes[i];
if (animate) {
var anim = _getAnimation(i);
if (anim.get_isPlaying()) {
anim.stop();
}
anim.set_startValue(pane.offsetHeight);
anim.set_endValue((i == _viewIndex) ? pane.scrollHeight : 1);
anim.play();
}
else {
pane.style.overflow = 'hidden';
if (i != _viewIndex) {
pane.style.height = '1px';
}
}
}
}
The function is looping over the view panes and hides or shows them. It has two modes of operation. One just uses overflow:hidden styles and sets a height of one pixel to hide a pane while the other uses an animation. The reason why we need an unanimated mode is that during initialization we won't want the animation. When we do want the animation, for each panel we stop the current animation, reinitialize it to the new parameters (from its current height to the full scrollHeight of the pane or one pixel depending if it's the current one or not) and play it. It uses another private method to get the animation for a given panel:
function _getAnimation(index) {
var anim = _animations[index];
if (!anim) {
_animations[index] = anim = new Sys.UI.LengthAnimation();
var pane = _viewPanes[index];
pane.style.overflow = 'hidden';
anim.set_target(pane);
anim.set_property('style');
anim.set_propertyKey('height');
anim.set_duration(_duration);
anim.initialize();
}
return anim;
}
This function is basically building a LengthAnimation which is a type of animation that is defined in the Atlas Glitz library that animates a length style property such as height from one value to another. It's also setting the overflow style of the pane to hidden so that changing the height will actually hide the overflowing contents.
What we now need to do is to wire up the click events on the pane headers so that they trigger a pane transition. By the way, that's an important pattern in Atlas: you never wire up your events from the HTML elements, but do it from control initialization code instead (or xml-script as a page developer). This helps to keep a good separation of layout and behavior and it also prevents bugs where the event is wired and fired before the handler function is defined.
this.initialize = function() {
Dice.Accordion.callBaseMethod(this, 'initialize');
_viewClickHandler = Function.createDelegate(this, _onViewClick);
var children = this.element.childNodes;
var isHead = true;
for (var i = 0, p = 0; i < children.length; i++) {
var child = children[i];
if (child.nodeType == 1) {
if (isHead) {
_viewHeads.add(child);
child.viewIndex = p++;
child.attachEvent('onclick', _viewClickHandler);
}
else {
_viewPanes.add(child);
}
isHead = !isHead;
}
}
_ShowCurrentPane.call(this, false);
}
Dice.Accordion.registerBaseMethod(this, 'initialize');
The initialization code is looping over the child elements of the associated element (this.element). It considers every other child as the head or the pane. References to the heads and panes are kept in private arrays and the click event is wired to the _onViewClick private method. We also set the view index on each head element as an expando property to be able to easily find the index of a view from its header element.
The way the click event is wired is another very important pattern in Atlas: we have partially recreated the concept of a delegate, which is a pointer to an object method. It's not just a function pointer because that would not retain the object's context. In other words, if you passed _onViewClick directly as the event handler, you would be unable to access "this" from the function. Using createDelegate ensures that your function will be able to work exactly as if you were calling it directly, with access to "this" and to private variables. Those who want to understand how it works can look at the implementation of createDelegate, which is using a very simple closure.
The click handler itself is fairly simple as it just needs to find which header was clicked (looping through the parents of the clicked node if necessary) and just sets the view index of the control to that of the clicked header:
function _onViewClick() {
var pane = window.event.srcElement;
while (pane && (typeof(pane.viewIndex) == 'undefined')) pane = pane.parentNode;
this.set_viewIndex(pane.viewIndex);
return false;
}
The last thing that we need to do is some housekeeping. As some of you know, web browsers, and IE6 in particular, can have memory leaks under certain circumstances. One such circumstance is the existence of circular references between Javascript objects and HTML elements. We need to implement dispose for our control and make sure that all such references are broken by detaching events and freeeing all references we may be keeping:
this.dispose = function() {
if (_viewClickHandler) {
for (var i = _viewHeads.length - 1; i >= 0; i--) {
var head = _viewHeads[i];
if (head) {
head.detachEvent('onclick', _viewClickHandler);
}
}
_viewClickHandler = null;
_viewHeads = null;
_viewPanes = null;
}
for (var i = _animations.length - 1; i >= 0; i--) {
if (_animations[i]) {
_animations[i].dispose();
delete _animations[i];
}
}
Dice.Accordion.callBaseMethod(this, 'dispose');
}
Dice.Accordion.registerBaseMethod(this, 'dispose');
This is it, we now have a functional Accordion control with nice animations. In the next posts, I'll show how to create a server control extender.
The complete source code for this control (and the rest of the demo) can be downloaded from Brad's blog:
http://blogs.msdn.com/brada/archive/2006/03/29/563648.aspx
The original post showing how to use the control is here:
http://weblogs.asp.net/bleroy/archive/2006/03/28/441343.aspx
UPDATE: corrected bad use of delete.