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.